0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

近況ノートを誰でも使えるアプリにするためのグループ管理設計

0
Last updated at Posted at 2026-07-20

近況共有アプリを、特定のグループ専用ではなく、誰でもグループを作って使える形へ広げようとしています。

最初から管理画面、メンバー招待、複数幹事、通知まで作ると大きくなりすぎるので、今回はグループ作成者がグループ名・説明文・グループ画像を編集できるところに絞りました。ホームにあった固定の「みんなの近況をシェアしよう!」は、グループごとの説明文と役割が重なるため削除します。

この記事では、Cloudflare Workers + Hono + D1 と Next.js を使って、グループ作成者向けの情報編集機能をどのように追加したかをまとめます。

作るもの

今回作るのは、グループ作成者だけがグループ情報を編集できる機能です。

要件は次のようにしました。

  • グループ名を編集できる
  • グループ説明文を編集できる
  • グループ画像を設定・差し替え・削除できる
  • グループ作成者だけが編集できる
  • グループホームから編集画面へ移動できる
  • 共有URLの slug は変更しない
  • 保存後は同じグループホームへ戻る

編集画面のURLは次のようにしました。

/g/:slug/settings

たとえば hanchu というグループなら、次のURLになります。

/g/hanchu/settings

共有URLを知っている参加者は、これまでどおりログインなしでグループを閲覧できます。ログインが必要なのは、グループ情報を編集するときだけです。

問題

近況ノートは、もともと「判中 同級生近況ノート」という1つのグループを中心に作っていました。

そのため、グループの情報は表示できても、作成者があとから変更するための仕組みはありませんでした。

たとえば、グループホームには次の情報が表示されています。

  • グループ名
  • グループ説明文
  • 作成日
  • メンバー数
  • 公開範囲

このうち、グループ名と説明文は groups テーブルに保存されています。画像は既存の投稿アイコンと同じ方式で、group_avatars テーブルに保存します。

しかし、更新APIがなければ、幹事が説明文を変更するにはD1を直接操作する必要があります。これは運用として現実的ではありません。

そこで、既存のログイン基盤とグループ所有者情報を利用して、最小限の管理機能を追加することにしました。

既存のデータ設計を活かす

今回、新しい管理者テーブルやメンバーシップテーブルは追加していません。

すでに groups テーブルに owner_user_id があるためです。

export const groups = sqliteTable("groups", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  ownerUserId: integer("owner_user_id").references(() => users.id),
  name: text("name").notNull(),
  slug: text("slug").notNull().unique(),
  description: text("description").notNull().default(""),
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});

グループ作成時には、ログイン中のユーザーIDを ownerUserId に保存しています。

const session = await requireSession(c);

await db.insert(groups).values({
  ownerUserId: session.user.id,
  name: input.name,
  slug: input.slug,
  description: input.description,
  createdAt: now,
  updatedAt: now,
});

画像テーブルはグループごとに1枚だけ持つ構成にしました。

export const groupAvatars = sqliteTable("group_avatars", {
  groupId: integer("group_id").primaryKey(),
  mimeType: text("mime_type").notNull(),
  content: blob("content").notNull(),
  sizeBytes: integer("size_bytes").notNull(),
  contentHash: text("content_hash").notNull(),
  createdAt: integer("created_at").notNull(),
  updatedAt: integer("updated_at").notNull(),
});

今回のMVPでは、次のように単純化しています。

グループ作成者 = グループを編集できる唯一のユーザー

複数の幹事や権限の引き継ぎが必要になったら、その時点でmembershipテーブルなどを検討します。最初からそこまで抽象化せず、既存の ownerUserId を使うことにしました。

設計

全体の構成は次のようにしました。

画面では作成者にだけ編集リンクを表示します。

ただし、編集リンクを隠すことは認可ではありません。URLを直接入力したり、APIを直接呼び出したりできるため、API側でも必ず所有者を確認します。

認可を画面だけに置かない

たとえば、フロントエンドで次のように編集リンクを隠したとします。

{
  group.canManage ? (
    <Link href={`/g/${group.slug}/settings`}>グループ情報を編集する</Link>
  ) : null;
}

これだけでは不十分です。

悪意のある利用者が、次のAPIを直接呼び出せる可能性があるからです。

PATCH /api/me/groups/hanchu

そこで、API側ではセッションからユーザーIDを取得し、D1から取得したグループの ownerUserId と比較します。

async function getOwnedGroup(c, userId: number) {
  const slug = c.req.param("slug");
  const group = await db
    .select()
    .from(groups)
    .where(eq(groups.slug, slug))
    .get();

  if (!group) {
    throw new HTTPException(404, {
      message: "グループが見つかりません",
    });
  }

  if (group.ownerUserId !== userId) {
    throw new HTTPException(403, {
      message: "このグループを管理する権限がありません",
    });
  }

  return group;
}

認可の条件は、クライアントから送られたユーザーIDではなく、サーバー側のセッションから決めます。

未ログイン       -> 401
グループなし     -> 404
所有者ではない   -> 403
所有者           -> 更新可能

API設計

今回追加したAPIは、ログイン中のユーザーが管理できるグループを /api/me 配下に置きました。

管理対象グループの取得

GET /api/me/groups/:slug

このAPIは編集画面の初期表示に使います。

処理の流れは次のとおりです。

  1. セッションを確認する
  2. slug からグループを取得する
  3. ownerUserId とセッションユーザーIDを比較する
  4. 所有者ならグループ情報を返す
app.get("/api/me/groups/:slug", async (c) => {
  const session = await requireSession(c);
  const group = await getOwnedGroup(c, session.user.id);

  c.header("Cache-Control", "no-store");
  return c.json({ group });
});

グループ情報の更新

PATCH /api/me/groups/:slug

更新する入力は、グループ名と説明文だけです。

{
  "name": "2026年 同級生の近況ノート",
  "description": "同級生のみんなの近況をゆるく共有しましょう。"
}

slug は入力スキーマに含めていません。

export const groupUpdateSchema = z.object({
  name: z
    .string()
    .trim()
    .min(1, "グループ名を入力してください")
    .max(80, "グループ名は80文字以内で入力してください"),
  description: z
    .string()
    .trim()
    .max(200, "説明文は200文字以内で入力してください")
    .default(""),
});

更新処理では、通常の書き込みAPIと同じようにCSRFとOriginも検証します。

app.patch("/api/me/groups/:slug", async (c) => {
  const session = await requireSession(c);
  await verifyCsrf(c, session);

  const group = await getOwnedGroup(c, session.user.id);
  const input = groupUpdateSchema.parse(await c.req.json());

  await db
    .update(groups)
    .set({
      name: input.name,
      description: input.description,
      updatedAt: new Date(),
    })
    .where(eq(groups.id, group.id));
});

所有者の確認と入力値の検証を行ってから、名前と説明文だけを更新します。

グループ画像の更新

画像はテキスト更新と分けて、multipartのAPIにしました。

GET    /api/groups/:slug/avatar
PUT    /api/me/groups/:slug/avatar
DELETE /api/me/groups/:slug/avatar

クライアント側では投稿アイコンと同じ画像処理を使い、選択した画像を正方形のJPEGへ圧縮してから送信します。サーバー側では実体のバイト列からMIMEタイプを検証し、group_avatars.updated_at をURLのクエリに付けてキャッシュを更新します。

グループホームでは、画像が設定されていればホーム上部の背景に使い、未設定または画像を削除した場合は、特定の学校を連想させない汎用画像にフォールバックします。

説明文がある場合はその文章だけを表示し、固定キャッチコピーは置かない構成にしました。

canManage をどう画面へ渡すか

グループホームでは、現在のログインユーザーが所有者かどうかによって編集リンクの表示を変えます。

公開取得APIである次のエンドポイントに、現在のセッションに基づく canManage を追加しました。

GET /api/groups/:slug
return c.json({
  group: {
    ...group,
    canManage: session?.user.id === group.ownerUserId,
  },
});

ここで注意したのはキャッシュです。

canManage はグループそのものの属性ではなく、アクセスしているユーザーによって変わる値です。あるユーザー向けのレスポンスを別のユーザーへ返すと、編集リンクの表示を誤る可能性があります。

そのため、グループ取得レスポンスには次のヘッダーを付けています。

Cache-Control: no-store

最終的な認可はAPI側で行いますが、画面表示に使うユーザー固有の情報も共有キャッシュへ保存しないようにしました。

Next.js側の構成

Next.js側では、データ取得とフォーム操作を分けています。

GroupSettingsContainer

Server Componentとして管理APIを呼び出し、編集画面に必要な初期値を取得します。

export async function GroupSettingsContainer({ slug }) {
  const group = await getManagedGroup(slug);

  return (
    <GroupSettingsView
      slug={slug}
      initialValues={{
        name: group.name,
        description: group.description,
      }}
    />
  );
}

未ログインならログイン画面へ戻し、所有者でない場合はグループのNot Found境界へ送ります。

GroupSettingsView

ページ全体の構造を担当します。

  • ScreenShell
  • 戻るボタン
  • Bottom Navigation
  • GroupSettingsForm

GroupSettingsForm

フォームはClient Componentです。

  • React Hook Formで入力状態を管理する
  • Zodで入力値を検証する
  • 保存中はボタンを無効化する
  • 保存失敗時はフォーム内にエラーを表示する
  • 保存成功後にグループホームへ戻る

APIアクセスはApplication Usecaseの updateGroup を経由します。

GroupSettingsForm
  -> updateGroup
    -> updateMyGroup
      -> PATCH /api/me/groups/:slug

フォームからAPIの詳細を切り離すことで、既存の投稿編集機能と同じレイヤー構成に揃えています。

なぜslugを変更不可にしたか

slug はグループの共有URLに使われています。

/g/hanchu

このURLはLINEやチャットで参加者へ共有されるため、グループ名を変更するたびにURLまで変わると、参加者が古いURLへアクセスする可能性があります。

URL変更に対応するには、次のような設計が必要になります。

  • 旧slugから新slugへのリダイレクト
  • 変更履歴の管理
  • 共有済みURLの扱い
  • slugの再利用防止

これは今回のMVPには大きすぎるため、名前と説明文だけを編集可能にしました。

既存グループの所有者設定

新しく作成するグループには、作成時のユーザーIDが ownerUserId として保存されます。

一方、認証機能より前に作られた既存グループには、所有者が紐付いていない場合があります。

たとえば hanchu の所有者を設定する場合は、D1で次のように更新します。

UPDATE groups
SET owner_user_id = ?
WHERE slug = 'hanchu';

? には、幹事として使うLINEログインユーザーの users.id を指定します。

所有者が未設定のグループは、誰でも編集できてしまう状態を避けるため、管理対象にはなりません。

今後の拡張

今回のMVPを土台に、必要になった段階で次の機能を追加できます。

  • 複数幹事の設定
  • 幹事の招待・引き継ぎ
  • 参加状況の可視化
  • 未投稿者の確認
  • 投稿・コメントの管理
  • ルールや開催日時などの案内情報

複数幹事を導入する場合は、単一の ownerUserId だけでなく、グループとユーザーの関係を管理するmembershipテーブルが必要になります。

まずは現在の小規模な利用形態に合わせて、グループ作成者1人の管理権限から始めました。

まとめ

今回、近況ノートにグループ作成者向けの情報編集機能を追加しました。

  • ownerUserId を使ってグループ作成者を識別する
  • 編集リンクは作成者にだけ表示する
  • API側でも所有者を必ず検証する
  • グループ名と説明文だけを更新する
  • 共有URLの slug は維持する
  • Next.jsではServer ComponentとClient Componentを分ける

グループ管理機能を最初から大きな管理画面として作るのではなく、既存のデータ設計と認証基盤を使って小さく始めました。

今後も少しずつ理解しながら実装していきます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?