diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index 258b0f9..0430380 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -31,8 +31,9 @@ export default function HomePage() { let errorMessage = "イベントの取得に失敗しました。"; try { const data = await res.json(); - if (data && typeof data.message === "string" && data.message.trim()) { - errorMessage = data.message.trim(); + const err = data as unknown as { message: string }; // Middleware のレスポンスは Hono RPC の型に乗らない + if (typeof err.message === "string" && err.message.trim()) { + errorMessage = err.message.trim(); } } catch (_) { // レスポンスがJSONでない場合は無視 diff --git a/client/src/pages/Project.tsx b/client/src/pages/Project.tsx index 5b6be0d..b7230a4 100644 --- a/client/src/pages/Project.tsx +++ b/client/src/pages/Project.tsx @@ -93,6 +93,7 @@ export default function ProjectPage() { projectName: string; } | null>(null); + // TODO: グローバルにしないと、delete の際は遷移を伴うので表示されない const [toast, setToast] = useState<{ message: string; variant: "success" | "error"; @@ -215,9 +216,8 @@ export default function ProjectPage() { projectName: name, }); } else { - const { message } = await res.json(); setToast({ - message, + message: "イベントの作成に失敗しました。", variant: "error", }); setTimeout(() => setToast(null), 3000); @@ -604,6 +604,7 @@ export default function ProjectPage() { if (!res.ok) { throw new Error("削除に失敗しました。"); } + // TODO: トーストをグローバルにする navigate("/home"); setToast({ message: "イベントを削除しました。", diff --git a/server/src/main.ts b/server/src/main.ts index 1bf9278..8eb6867 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -3,16 +3,23 @@ import { PrismaClient } from "@prisma/client"; import dotenv from "dotenv"; import { Hono } from "hono"; import { cors } from "hono/cors"; +import { customAlphabet } from "nanoid"; import { browserIdMiddleware } from "./middleware/browserId.js"; import projectsRoutes from "./routes/projects.js"; dotenv.config(); +/** + * ハイフン・アンダースコアを含まない Nano ID 形式。 + */ +export const nanoid = customAlphabet("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", 21); + export const prisma = new PrismaClient(); -const port = process.env.PORT || 3000; + +const port = Number(process.env.PORT) || 3000; const allowedOrigins = process.env.CORS_ALLOW_ORIGINS?.split(",") || []; -type AppVariables = { +export type AppVariables = { browserId: string; }; @@ -28,12 +35,16 @@ const app = new Hono<{ Variables: AppVariables }>() .get("/", (c) => { return c.json({ message: "Hello! イツヒマ?" }); }) - .route("/projects", projectsRoutes); + .route("/projects", projectsRoutes) + .onError((err, c) => { + console.error(err); + return c.json({ message: "Internal Server Error" }, 500); + }); serve( { fetch: app.fetch, - port: Number(port), + port, hostname: "0.0.0.0", }, () => { diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 948ce5d..4938a80 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -1,284 +1,249 @@ import { zValidator } from "@hono/zod-validator"; import dotenv from "dotenv"; import { Hono } from "hono"; -import { customAlphabet } from "nanoid"; import { z } from "zod"; import { editReqSchema, projectReqSchema, submitReqSchema } from "../../../common/validators.js"; -import { prisma } from "../main.js"; +import { type AppVariables, nanoid, prisma } from "../main.js"; dotenv.config(); -/** - * ハイフン・アンダースコアを含まない Nano ID 形式。 - */ -const nanoid = customAlphabet("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", 21); - const projectIdParamsSchema = z.object({ projectId: z.string().length(21) }); -type AppVariables = { - browserId: string; -}; - const router = new Hono<{ Variables: AppVariables }>() // プロジェクト作成 .post("/", zValidator("json", projectReqSchema), async (c) => { const browserId = c.get("browserId"); - try { - const data = c.req.valid("json"); - - const event = await prisma.project.create({ - data: { - id: nanoid(), - name: data.name, - description: data.description.trim() || null, - startDate: new Date(data.startDate), - endDate: new Date(data.endDate), - allowedRanges: { - create: data.allowedRanges.map((range) => ({ - startTime: new Date(range.startTime), - endTime: new Date(range.endTime), - })), - }, - hosts: { - create: { - browserId, - }, - }, - participationOptions: { - create: data.participationOptions.map((opt) => ({ - id: opt.id, - label: opt.label, - color: opt.color, - })), + const input = c.req.valid("json"); + + const project = await prisma.project.create({ + data: { + id: nanoid(), + name: input.name, + description: input.description.trim() || null, + startDate: new Date(input.startDate), + endDate: new Date(input.endDate), + allowedRanges: { + create: input.allowedRanges.map((range) => ({ + startTime: new Date(range.startTime), + endTime: new Date(range.endTime), + })), + }, + hosts: { + create: { + browserId, }, }, - include: { hosts: true, participationOptions: true }, - }); - - return c.json({ id: event.id, name: event.name }, 201); - } catch (_err) { - return c.json({ message: "イベント作成時にエラーが発生しました" }, 500); - } + participationOptions: { + create: input.participationOptions.map((opt) => ({ + id: opt.id, + label: opt.label, + color: opt.color, + })), + }, + }, + select: { + id: true, + name: true, + }, + }); + + return c.json({ id: project.id, name: project.name }, 201); }) // 自分が関連するプロジェクト取得 .get("/mine", async (c) => { const browserId = c.get("browserId"); - try { - const involvedProjects = await prisma.project.findMany({ - where: { - OR: [ - { hosts: { some: { browserId } } }, - { - guests: { - some: { browserId }, - }, - }, - ], - }, - select: { - id: true, - name: true, - description: true, - startDate: true, - endDate: true, - hosts: { - select: { - browserId: true, + const projects = await prisma.project.findMany({ + where: { + OR: [ + { hosts: { some: { browserId } } }, + { + guests: { + some: { browserId }, }, }, + ], + }, + include: { + hosts: { + select: { browserId: true }, }, - }); - - return c.json( - involvedProjects.map((p) => ({ - id: p.id, - name: p.name, - description: p.description ?? "", - startDate: p.startDate, - endDate: p.endDate, - isHost: p.hosts.some((host) => host.browserId === browserId), - })), - 200, - ); - } catch (error) { - console.error(error); - return c.json({ message: "エラーが発生しました。" }, 500); - } + }, + }); + + return c.json( + projects.map((p) => ({ + id: p.id, + name: p.name, + description: p.description ?? "", + startDate: p.startDate, + endDate: p.endDate, + isHost: p.hosts.some((host) => host.browserId === browserId), + })), + 200, + ); }) // プロジェクト取得 .get("/:projectId", zValidator("param", projectIdParamsSchema), async (c) => { const browserId = c.get("browserId"); - - try { - const { projectId } = c.req.valid("param"); - const projectRow = await prisma.project.findUnique({ - where: { id: projectId }, - include: { - allowedRanges: true, - participationOptions: true, - guests: { - include: { - slots: true, // slots 全部欲しいなら select より include - }, + const { projectId } = c.req.valid("param"); + + const project = await prisma.project.findUnique({ + where: { id: projectId }, + include: { + allowedRanges: true, + participationOptions: true, + guests: { + include: { + slots: true, }, - hosts: true, // 全部欲しいなら select 省略 }, - }); - - if (!projectRow) { - return c.json({ message: "イベントが見つかりません。" }, 404); - } + hosts: true, + }, + }); - const data = { - ...projectRow, - description: projectRow.description ?? "", - hosts: projectRow.hosts.map((h) => { - const { browserId: _, ...rest } = h; - return rest; - }), - guests: projectRow.guests.map((g) => { - const { browserId: _, ...rest } = g; - return rest; - }), - isHost: projectRow.hosts.some((h) => h.browserId === browserId), - meAsGuest: projectRow.guests.find((g) => g.browserId === browserId) ?? null, - }; - return c.json(data, 200); - } catch (error) { - console.error("イベント取得エラー:", error); - return c.json({ message: "イベント取得中にエラーが発生しました。" }, 500); + if (!project) { + return c.json({ message: "イベントが見つかりません。" }, 404); } + + const guest = project.guests.find((g) => g.browserId === browserId); + const meAsGuest = guest ? (({ browserId, ...rest }) => rest)(guest) : null; + + return c.json( + { + id: project.id, + name: project.name, + description: project.description ?? "", + startDate: project.startDate, + endDate: project.endDate, + allowedRanges: project.allowedRanges, + participationOptions: project.participationOptions, + hosts: project.hosts.map(({ browserId, ...rest }) => rest), + guests: project.guests.map(({ browserId, ...rest }) => rest), + isHost: project.hosts.some((h) => h.browserId === browserId), + meAsGuest, + }, + 200, + ); }) // プロジェクト編集 .put("/:projectId", zValidator("param", projectIdParamsSchema), zValidator("json", editReqSchema), async (c) => { const browserId = c.get("browserId"); - try { - const { projectId } = c.req.valid("param"); - const data = c.req.valid("json"); + const { projectId } = c.req.valid("param"); + const input = c.req.valid("json"); - // ホスト認証とゲスト存在確認を一括取得 - const [host, existingGuest] = await Promise.all([ - prisma.host.findFirst({ - where: { - browserId, - projectId: projectId, - }, - }), - prisma.guest.findFirst({ - where: { projectId: projectId }, - }), - ]); + const [host, existingGuest] = await Promise.all([ + prisma.host.findFirst({ + where: { + browserId, + projectId: projectId, + }, + }), + prisma.guest.findFirst({ + where: { projectId: projectId }, + }), + ]); + + if (!host) { + return c.json({ message: "アクセス権限がありません。" }, 403); + } - // ホストが存在しなければ403 - if (!host) { - return c.json({ message: "アクセス権限がありません。" }, 403); + // 参加形態の更新 + if (input.participationOptions) { + if (input.participationOptions.length === 0) { + return c.json({ message: "参加形態は最低1つ必要です。" }, 400); } - // 参加形態の更新 - if (data.participationOptions) { - // 最低1つの参加形態が必要 - if (data.participationOptions.length === 0) { - return c.json({ message: "参加形態は最低1つ必要です。" }, 400); - } - - // 削除対象の参加形態に Slot が紐づいているかチェック - const existingOptions = await prisma.participationOption.findMany({ - where: { projectId }, - include: { slots: { select: { id: true } } }, - }); - - const newOptionIds = data.participationOptions.map((o) => o.id); - const optionsToDelete = existingOptions.filter((o) => !newOptionIds.includes(o.id)); - const undeletableOptions = optionsToDelete.filter((o) => o.slots.length > 0); - - if (undeletableOptions.length > 0) { - const labels = undeletableOptions.map((o) => o.label).join(", "); - return c.json( - { - message: `以下の参加形態は日程が登録されているため削除できません: ${labels}`, - }, - 400, - ); - } - - await prisma.$transaction([ - // 既存の参加形態で、新しいリストにないものを削除 - prisma.participationOption.deleteMany({ - where: { - projectId, - id: { - notIn: newOptionIds, - }, + // 削除対象の参加形態に Slot が紐づいているかチェック + const existingOptions = await prisma.participationOption.findMany({ + where: { projectId }, + include: { slots: { select: { id: true } } }, + }); + const newOptionIds = input.participationOptions.map((o) => o.id); + const optionsToDelete = existingOptions.filter((o) => !newOptionIds.includes(o.id)); + const undeletableOptions = optionsToDelete.filter((o) => o.slots.length > 0); + if (undeletableOptions.length > 0) { + const labels = undeletableOptions.map((o) => o.label).join(", "); + return c.json( + { + message: `以下の参加形態は日程が登録されているため削除できません: ${labels}`, + }, + 400, + ); + } + + await prisma.$transaction([ + // 既存の参加形態で、新しいリストにないものを削除 + prisma.participationOption.deleteMany({ + where: { + projectId, + id: { + notIn: newOptionIds, }, + }, + }), + // 既存の参加形態を更新または新規作成 + ...input.participationOptions.map((opt) => + prisma.participationOption.upsert({ + where: { id: opt.id }, + update: { label: opt.label, color: opt.color }, + create: { id: opt.id, label: opt.label, color: opt.color, projectId }, }), - // 既存の参加形態を更新または新規作成 - ...data.participationOptions.map((opt) => - prisma.participationOption.upsert({ - where: { id: opt.id }, - update: { label: opt.label, color: opt.color }, - create: { id: opt.id, label: opt.label, color: opt.color, projectId }, - }), - ), - ]); - } + ), + ]); + } - // 更新処理 - const updatedEvent = await prisma.project.update({ - where: { id: projectId }, - data: existingGuest - ? { - name: data.name, - description: data.description?.trim() || null, - } // ゲストがいれば名前と説明だけ - : { - name: data.name, - description: data.description?.trim() || null, - startDate: data.startDate ? new Date(data.startDate) : undefined, - endDate: data.endDate ? new Date(data.endDate) : undefined, - allowedRanges: { - deleteMany: {}, // 既存削除 - create: data.allowedRanges?.map((r) => ({ - startTime: new Date(r.startTime), - endTime: new Date(r.endTime), - })), - }, + const updatedProject = await prisma.project.update({ + where: { id: projectId }, + data: existingGuest + ? { + name: input.name, + description: input.description?.trim() || null, + } + : { + name: input.name, + description: input.description?.trim() || null, + startDate: input.startDate ? new Date(input.startDate) : undefined, + endDate: input.endDate ? new Date(input.endDate) : undefined, + allowedRanges: { + deleteMany: {}, // 既存削除 + create: input.allowedRanges?.map((r) => ({ + startTime: new Date(r.startTime), + endTime: new Date(r.endTime), + })), }, - include: { allowedRanges: true, participationOptions: true }, - }); + }, + include: { allowedRanges: true, participationOptions: true }, + }); - return c.json({ event: updatedEvent }, 200); - } catch (error) { - console.error("イベント更新エラー:", error); - return c.json({ message: "イベント更新中にエラーが発生しました。" }, 500); - } + return c.json({ event: updatedProject }, 200); }) // プロジェクト削除 .delete("/:projectId", zValidator("param", projectIdParamsSchema), async (c) => { const browserId = c.get("browserId"); - try { - const { projectId } = c.req.valid("param"); - // Host 認証 - const host = await prisma.host.findFirst({ - where: { projectId, browserId }, - }); + const { projectId } = c.req.valid("param"); - if (!host) { - return c.json({ message: "削除権限がありません。" }, 403); - } - // 関連データを削除(Cascade を使っていない場合) - await prisma.project.delete({ - where: { id: projectId }, - }); - return c.json({ message: "イベントを削除しました。" }, 200); - } catch (error) { - console.error("イベント削除エラー:", error); - return c.json({ message: "イベント削除中にエラーが発生しました。" }, 500); + const host = await prisma.host.findUnique({ + where: { + browserId_projectId: { + browserId, + projectId, + }, + }, + }); + + if (!host) { + return c.json({ message: "削除権限がありません。" }, 403); } + + await prisma.project.delete({ + where: { id: projectId }, + }); + return c.json(204); }) // 日程の提出。 @@ -287,40 +252,39 @@ const router = new Hono<{ Variables: AppVariables }>() zValidator("param", projectIdParamsSchema), zValidator("json", submitReqSchema), async (c) => { - const { projectId } = c.req.valid("param"); const browserId = c.get("browserId"); + const { projectId } = c.req.valid("param"); + const { name, slots } = c.req.valid("json"); - const existingGuest = await prisma.guest.findFirst({ - where: { projectId, browserId }, + const existingGuest = await prisma.guest.findUnique({ + where: { + browserId_projectId: { + browserId, + projectId, + }, + }, }); if (existingGuest) { - return c.json({ message: "すでに登録済みです" }, 403); + return c.json({ message: "提出済みです。" }, 403); } - const { name, slots } = c.req.valid("json"); - - try { - await prisma.guest.create({ - data: { - name, - browserId, - project: { connect: { id: projectId } }, - slots: { - create: slots?.map((slot) => ({ - from: slot.start, - to: slot.end, - projectId, - participationOptionId: slot.participationOptionId, - })), - }, + await prisma.guest.create({ + data: { + name, + browserId, + project: { connect: { id: projectId } }, + slots: { + create: slots?.map((slot) => ({ + from: slot.start, + to: slot.end, + projectId, + participationOptionId: slot.participationOptionId, + })), }, - include: { slots: true }, - }); - return c.json("日時が登録されました!", 201); - } catch (error) { - console.error("登録エラー:", error); - return c.json({ message: "サーバーエラーが発生しました" }, 500); - } + }, + include: { slots: true }, + }); + return c.json("日程が提出されました。", 201); }, ) @@ -330,44 +294,37 @@ const router = new Hono<{ Variables: AppVariables }>() zValidator("param", projectIdParamsSchema), zValidator("json", submitReqSchema), async (c) => { - const { projectId } = c.req.valid("param"); const browserId = c.get("browserId"); - + const { projectId } = c.req.valid("param"); const { name, slots } = c.req.valid("json"); - try { - const existingGuest = await prisma.guest.findFirst({ - where: { projectId, browserId }, - include: { slots: true }, - }); - - if (!existingGuest) { - return c.json({ message: "ゲスト情報が見つかりません。" }, 404); - } - const slotData = slots?.map((slot) => ({ - from: slot.start, - to: slot.end, - projectId, - participationOptionId: slot.participationOptionId, - })); + const existingGuest = await prisma.guest.findUnique({ + where: { browserId_projectId: { browserId, projectId } }, + include: { slots: true }, + }); - await prisma.slot.deleteMany({ where: { guestId: existingGuest.id } }); + if (!existingGuest) { + return c.json({ message: "既存の日程が見つかりません。" }, 404); + } + const slotData = slots?.map((slot) => ({ + from: slot.start, + to: slot.end, + projectId, + participationOptionId: slot.participationOptionId, + })); - // ゲスト情報更新 - const guest = await prisma.guest.update({ - where: { id: existingGuest.id }, - data: { - slots: { create: slotData }, - name, - }, - include: { slots: true }, - }); + await prisma.slot.deleteMany({ where: { guestId: existingGuest.id } }); - return c.json({ message: "ゲスト情報が更新されました!", guest }, 200); - } catch (error) { - console.error("処理中のエラー:", error); - return c.json({ message: "サーバーエラーが発生しました" }, 500); - } + const guest = await prisma.guest.update({ + where: { id: existingGuest.id }, + data: { + slots: { create: slotData }, + name, + }, + include: { slots: true }, + }); + + return c.json({ message: "日程が更新されました。", guest }, 200); }, );