Skip to content

Design: 管理画面

要件は requirements.md、ビジュアルは ui-design.md 参照。本ドキュメントは管理画面の 技術設計 を定義する。


アーキテクチャ概要

[運営者]

[/admin/login] → 認証 (NextAuth Credentials Provider または別系統)
  ↓ ログイン成功 → セッションCookie
[/admin/candidates] → 候補者一覧 (Server Component)

[/admin/candidates/new] → 新規登録フォーム (Client Component)
[/admin/candidates/[id]/edit] → 編集フォーム (Client Component)
  ↓ Server Action 呼び出し
[Server Action]
  ├ Prisma で DB操作
  ├ 画像BLOBの登録・更新
  └ revalidateTag でキャッシュ無効化

ディレクトリ構成

app/
├ admin/
│  ├ layout.tsx                  # 管理者認証チェック、管理画面共通レイアウト
│  ├ login/
│  │  └ page.tsx                 # 管理者ログイン画面
│  ├ candidates/
│  │  ├ page.tsx                 # 候補者一覧 (Server Component)
│  │  ├ new/
│  │  │  └ page.tsx              # 新規登録フォーム (Client Component)
│  │  └ [id]/
│  │     └ edit/
│  │        └ page.tsx           # 編集フォーム (Client Component)
│  └ _actions/
│     ├ candidates.ts            # Candidate関連の Server Actions
│     └ images.ts                # CandidateImage関連の Server Actions
├ api/
│  └ images/
│     └ [id]/
│        └ route.ts              # 画像配信エンドポイント (公開)

features/
└ admin/
   ├ hooks/
   │  └ useAdminAuth.ts          # 管理者認証チェック用フック
   ├ services/
   │  └ adminCandidateService.ts # 管理画面向けクエリ
   └ types/
      └ index.ts

components/
└ admin/
   ├ CandidateList.tsx           # 候補者一覧テーブル
   ├ CandidateForm.tsx           # 共通の編集フォーム
   ├ ImageUploader.tsx           # 画像アップロード(複数対応)
   └ AdminNavbar.tsx             # 管理画面ナビ

scripts/
└ seed-candidates.ts             # 初期一括投入スクリプト

認証設計

採用方針: NextAuth.js の Credentials Provider

一般ユーザー向けの Facebook / Instagram 認証とは別系統で、管理者向けにメールアドレス + パスワードによる認証を提供する。

ts
// auth.ts (NextAuth設定)
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import Facebook from 'next-auth/providers/facebook';
import Instagram from 'next-auth/providers/instagram';

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    Facebook({ /* ユーザー向け */ }),
    Instagram({ /* ユーザー向け */ }),
    Credentials({
      name: 'admin',
      credentials: {
        email: { label: 'メールアドレス', type: 'email' },
        password: { label: 'パスワード', type: 'password' },
      },
      async authorize(credentials) {
        const admin = await prisma.adminUser.findUnique({
          where: { email: credentials.email as string },
        });
        if (!admin) return null;
        const valid = await bcrypt.compare(credentials.password as string, admin.passwordHash);
        if (!valid) return null;
        return { id: admin.id, email: admin.email, role: 'admin' };
      },
    }),
  ],
  callbacks: {
    async session({ session, token }) {
      session.user.role = token.role as string | undefined;
      return session;
    },
    async jwt({ token, user }) {
      if (user) token.role = (user as any).role;
      return token;
    },
  },
});

管理者認証用テーブル(AdminUser)

データモデルに以下のテーブルを追加(data-model/design.md の Prisma スキーマに追記する想定):

prisma
model AdminUser {
  id             String   @id @default(uuid()) @db.Uuid
  email          String   @unique @db.Text
  passwordHash   String   @db.Text
  name           String   @db.Text
  createdAt      DateTime @default(now()) @db.Timestamptz()
  lastLoginAt    DateTime? @db.Timestamptz()

  @@map("admin_users")
}

管理者の追加は Seedスクリプトで行う(管理画面からは追加しない、運用上の前提)。

ts
// prisma/seed.ts に追記
import bcrypt from 'bcrypt';

const passwordHash = await bcrypt.hash(process.env.ADMIN_INITIAL_PASSWORD!, 10);
await prisma.adminUser.create({
  data: {
    email: process.env.ADMIN_INITIAL_EMAIL!,
    passwordHash,
    name: '運営担当',
  },
});

認証チェック

app/admin/layout.tsx で全管理画面に認証チェックを適用:

tsx
// app/admin/layout.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export default async function AdminLayout({ children }: { children: React.ReactNode }) {
  const session = await auth();
  if (!session?.user || session.user.role !== 'admin') {
    redirect('/admin/login');
  }
  return <AdminLayoutShell>{children}</AdminLayoutShell>;
}

/admin/login だけは認証不要なので、別ルートグループに置くか、layout内で例外処理する。


Server Actions 設計

候補者の作成

ts
// app/admin/_actions/candidates.ts
'use server';

import { z } from 'zod';
import { auth } from '@/auth';
import { prisma } from '@/lib/prisma';
import { revalidateTag } from 'next/cache';

const CandidateInput = z.object({
  name: z.string().min(1),
  region: z.string(),
  age: z.number().int().min(0),
  height: z.number().int().min(0),
  occupation: z.string(),
  hobbies: z.array(z.string()),
  specialties: z.array(z.string()),
  motto: z.string(),
  dream: z.string(),
  message: z.string(),
  displayOrder: z.number().int(),
});

async function ensureAdmin() {
  const session = await auth();
  if (!session?.user || session.user.role !== 'admin') {
    throw new Error('Unauthorized');
  }
}

export async function createCandidate(input: z.infer<typeof CandidateInput>) {
  await ensureAdmin();
  const data = CandidateInput.parse(input);
  const candidate = await prisma.candidate.create({ data });
  revalidateTag('candidates');
  return candidate;
}

export async function updateCandidate(id: string, input: z.infer<typeof CandidateInput>) {
  await ensureAdmin();
  const data = CandidateInput.parse(input);
  const candidate = await prisma.candidate.update({ where: { id }, data });
  revalidateTag('candidates');
  return candidate;
}

export async function deleteCandidate(id: string) {
  await ensureAdmin();
  // 投票が記録されているか確認
  const voteCount = await prisma.vote.count({ where: { candidateId: id } });
  if (voteCount > 0) {
    throw new Error('投票が記録されている候補者は削除できません');
  }
  await prisma.candidate.delete({ where: { id } });  // 画像も連鎖削除される
  revalidateTag('candidates');
}

画像のアップロード

ファイルアップロードは Server Action で FormData を受け取る方式が標準的。

ts
// app/admin/_actions/images.ts
'use server';

const ALLOWED_MIME = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB

export async function uploadCandidateImage(formData: FormData) {
  await ensureAdmin();
  
  const candidateId = formData.get('candidateId') as string;
  const order = Number(formData.get('order'));
  const file = formData.get('file') as File;

  if (!ALLOWED_MIME.includes(file.type)) {
    throw new Error('対応していないファイル形式です');
  }
  if (file.size > MAX_SIZE) {
    throw new Error('ファイルサイズが大きすぎます (上限 5MB)');
  }

  const buffer = Buffer.from(await file.arrayBuffer());

  const image = await prisma.candidateImage.create({
    data: {
      candidateId,
      data: buffer,
      mime: file.type,
      order,
    },
  });

  revalidateTag('candidates');
  return image;
}

export async function replaceCandidateImage(imageId: string, formData: FormData) {
  await ensureAdmin();
  const file = formData.get('file') as File;
  
  if (!ALLOWED_MIME.includes(file.type)) {
    throw new Error('対応していないファイル形式です');
  }
  if (file.size > MAX_SIZE) {
    throw new Error('ファイルサイズが大きすぎます (上限 5MB)');
  }

  const buffer = Buffer.from(await file.arrayBuffer());

  const image = await prisma.candidateImage.update({
    where: { id: imageId },
    data: {
      data: buffer,
      mime: file.type,
      // updatedAt が自動更新される → URLのキャッシュバストが効く
    },
  });

  revalidateTag('candidates');
  return image;
}

export async function deleteCandidateImage(imageId: string) {
  await ensureAdmin();
  await prisma.candidateImage.delete({ where: { id: imageId } });
  revalidateTag('candidates');
}

export async function reorderCandidateImages(
  candidateId: string,
  orderedImageIds: string[],
) {
  await ensureAdmin();
  
  await prisma.$transaction(
    orderedImageIds.map((imageId, order) =>
      prisma.candidateImage.update({
        where: { id: imageId },
        data: { order },
      })
    )
  );
  
  revalidateTag('candidates');
}

画像配信エンドポイント

data-model/design.md で定義済み。再掲:

ts
// app/api/images/[id]/route.ts
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/prisma';

export async function GET(
  req: NextRequest,
  { params }: { params: { id: string } }
) {
  const image = await prisma.candidateImage.findUnique({
    where: { id: params.id },
    select: { data: true, mime: true, updatedAt: true },
  });

  if (!image) {
    return new Response('Not Found', { status: 404 });
  }

  return new Response(image.data, {
    headers: {
      'Content-Type': image.mime,
      'Cache-Control': 'public, max-age=31536000, immutable',
      'ETag': `"${params.id}-${image.updatedAt.getTime()}"`,
    },
  });
}

このエンドポイントは認証不要。画像IDは UUID で推測困難なため、URLが流出しない限り無断アクセスは現実的に困難。


クライアントコンポーネント

CandidateForm(共通フォーム)

新規登録・編集の両方で使用する。

tsx
// components/admin/CandidateForm.tsx
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { createCandidate, updateCandidate } from '@/app/admin/_actions/candidates';

interface Props {
  initial?: CandidateInput & { id?: string };
}

export function CandidateForm({ initial }: Props) {
  const router = useRouter();
  const [isPending, setIsPending] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(formData: FormData) {
    setIsPending(true);
    setError(null);
    try {
      const input = parseFormData(formData);
      if (initial?.id) {
        await updateCandidate(initial.id, input);
      } else {
        await createCandidate(input);
      }
      router.push('/admin/candidates');
    } catch (e: any) {
      setError(e.message);
      setIsPending(false);
    }
  }

  return (
    <form action={handleSubmit}>
      {/* 各フィールド */}
    </form>
  );
}

ImageUploader(画像アップロード)

複数画像のアップロード、並び替え、削除を扱う。

tsx
'use client';

import { useState } from 'react';
import { uploadCandidateImage, replaceCandidateImage, deleteCandidateImage, reorderCandidateImages } from '@/app/admin/_actions/images';

interface Props {
  candidateId: string;
  initialImages: { id: string; order: number; updatedAt: Date }[];
}

export function ImageUploader({ candidateId, initialImages }: Props) {
  const [images, setImages] = useState(initialImages);
  // ドラッグ&ドロップによる並び替え (react-dnd or dnd-kit)
  // ファイル選択 → アップロード処理
  // 各画像の削除ボタン・差し替えボタン
  // ...
}

@dnd-kit/core などの並び替えライブラリを使うと実装が単純になる。


初期データ一括投入スクリプト

data-model/design.md で定義済み。scripts/seed-candidates.ts で CSV と画像ファイル群を読み込んで投入する。

実行方法:

bash
pnpm seed:candidates

既存候補者がある場合は警告を出して中断、--force フラグで強制実行する設計を推奨。


エラーハンドリング

サーバー側

  • ensureAdmin() で未認証なら例外を投げる
  • 入力バリデーション失敗で ZodError
  • 画像形式・サイズエラー
  • DB制約違反(投票がある候補者の削除)

クライアント側

  • Server Action の例外を try/catch で捕捉
  • エラーメッセージをフォーム上部に表示
  • Toast(sonner)でも併用通知

キャッシュ戦略

steering/tech.md の「キャッシュポリシー(全画面共通)」に従う。

操作無効化対象
候補者の作成・編集・削除revalidateTag('candidates')
画像の追加・差し替え・削除revalidateTag('candidates')
画像の並び替えrevalidateTag('candidates')
画像本体のキャッシュURLパラメータ ?v={updatedAt} で自動的にバスト

セキュリティ考慮事項

steering/tech.md の「セキュリティポリシー(全画面共通)」を遵守。本spec固有の追加事項:

  • 管理者ログインのレート制限: ログイン失敗が連続した場合は一時的にロック
  • CSRF対策: NextAuth.js のセッション管理で標準対応
  • アップロードファイルの検証: MIME型・サイズ・実際のファイル内容(マジックナンバー)で多重検証
  • 管理者操作の監査ログ: 重要操作(候補者の作成・削除、画像の差し替え)はログに記録
  • 管理者パスワード: bcrypt でハッシュ化して保存(bcrypt ライブラリ使用)

テスト戦略

対象テスト
認証チェック未認証で /admin/* にアクセス → リダイレクト確認
Server ActionsVitest で各アクションを単体テスト
画像アップロード不正MIME・サイズ超過の拒否を確認
削除制約投票ありの候補者削除拒否
一括投入スクリプトテスト用CSV+画像での投入確認
並び替え順序変更後の order 値の確認

関連ドキュメント

  • requirements.md — 機能要件
  • ui-design.md — ビジュアル仕様
  • ../data-model/design.md — Prismaスキーマ、画像配信エンドポイント
  • ../../steering/tech.md — 技術スタック、キャッシュポリシー、セキュリティポリシー