From b51424022bfdbe987bd044a6cf85b06397a39daa Mon Sep 17 00:00:00 2001 From: nakaterm <104970808+nakaterm@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:13:31 +0900 Subject: [PATCH 01/17] =?UTF-8?q?fix:=20=E3=82=A4=E3=83=99=E3=83=B3?= =?UTF-8?q?=E3=83=88=E3=81=AE=E6=97=A5=E4=BB=98=E3=83=BB=E6=99=82=E5=88=BB?= =?UTF-8?q?=E3=81=AE=E5=BD=A2=E5=BC=8F=E4=B8=8D=E6=AD=A3=E3=82=92=20400=20?= =?UTF-8?q?=E3=81=A7=E5=BC=BE=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit フォームと API で同じスキーマを使っていたため、API 側で日時の形式を検証できず、 ISO 8601 でない値を送ると toIso() で RangeError になり 500 を返していた。 フォーム用 (YYYY-MM-DD / HH:mm) と API 用 (ISO 8601) にスキーマを分ける。 Co-Authored-By: Claude Opus 5.5 --- client/src/pages/Project.tsx | 4 +- common/validators.ts | 148 +++++++++++++++++++++-------------- server/test/projects.test.ts | 33 ++++++++ 3 files changed, 126 insertions(+), 59 deletions(-) diff --git a/client/src/pages/Project.tsx b/client/src/pages/Project.tsx index 498d491..e0265f7 100644 --- a/client/src/pages/Project.tsx +++ b/client/src/pages/Project.tsx @@ -17,7 +17,7 @@ import { import { NavLink, useNavigate, useParams } from "react-router"; import type { z } from "zod"; import { DEFAULT_PARTICIPATION_OPTION, generateDistinctColor } from "../../../common/colors"; -import { editReqSchema, projectReqSchema } from "../../../common/validators"; +import { editFormSchema, projectFormSchema, type projectReqSchema } from "../../../common/validators"; import type { AppType } from "../../../server/src/main"; import Header from "../components/Header"; import { EXTERNAL_LINKS } from "../constants/links"; @@ -32,7 +32,7 @@ export default function ProjectPage() { const { eventId } = useParams(); const navigate = useNavigate(); - const formSchema = eventId ? editReqSchema : projectReqSchema; + const formSchema = eventId ? editFormSchema : projectFormSchema; type FormSchemaType = z.infer; const [project, setProject] = useState(null); diff --git a/common/validators.ts b/common/validators.ts index 273e187..f121167 100644 --- a/common/validators.ts +++ b/common/validators.ts @@ -31,66 +31,100 @@ export const submitReqSchema = z.object({ ), }); -const isQuarterHour = (time: string): boolean => { +/** フォームの時刻 ("HH:mm") */ +const formTime = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "時刻の形式が正しくありません"); + +/** "HH:mm" の分が 15 の倍数か */ +const isQuarterHourFormTime = (time: string): boolean => { const [, minute] = time.split(":").map(Number); return [0, 15, 30, 45].includes(minute); }; -const baseProjectReqSchema = z.object({ - name: z.string().min(1, "イベント名を入力してください").max(100, "イベント名は100文字以内で入力してください"), - description: z.string().max(1000, "説明は1000文字以内で入力してください"), - startDate: z.string().min(1, "開始日を入力してください"), - // TODO: 新規作成時のみ、過去日付を制限する必要 - // .refine( - // (startDate) => { - // const inputDate = new Date(startDate); - // const today = new Date(); - // today.setHours(0, 0, 0, 0); - // return inputDate >= today; - // }, - // { - // message: "過去の日付は指定できません", - // }, - // ), - endDate: z.string().min(1, "終了日を入力してください"), - allowedRanges: z - .array( - z.object({ - startTime: z.string(), - endTime: z.string(), - }), - ) - .refine((ranges) => ranges.every(({ startTime, endTime }) => startTime < endTime), { - message: "開始時刻は終了時刻より前でなければなりません", - }) - .refine((ranges) => ranges.every(({ startTime, endTime }) => isQuarterHour(startTime) && isQuarterHour(endTime)), { - message: "開始時刻と終了時刻は15分単位で入力してください", - }), - participationOptions: z.array(participationOptionCreateSchema).min(1, "参加形態は最低1つ必要です"), +/** ISO 8601 の日時の分が 15 の倍数で、秒以下が 0 か。Asia/Tokyo のオフセットは 15 分の倍数なので UTC で判定してよい。 */ +const isQuarterHourDateTime = (dateTime: string): boolean => new Date(dateTime).getTime() % (15 * 60 * 1000) === 0; + +/** + * イベントの作成・編集の入力。フォームと API で、日付・時刻の形式だけが異なる。 + * フォームは "YYYY-MM-DD" と "HH:mm"、API は ISO 8601 の日時で受け取る (Web UI が変換して送る)。 + */ +function baseProjectSchema({ + startDate, + endDate, + time, + isQuarterHour, +}: { + startDate: z.ZodString; + endDate: z.ZodString; + time: z.ZodString; + isQuarterHour: (time: string) => boolean; +}) { + return z.object({ + name: z.string().min(1, "イベント名を入力してください").max(100, "イベント名は100文字以内で入力してください"), + description: z.string().max(1000, "説明は1000文字以内で入力してください"), + startDate, + // TODO: 新規作成時のみ、過去日付を制限する必要 + // .refine( + // (startDate) => { + // const inputDate = new Date(startDate); + // const today = new Date(); + // today.setHours(0, 0, 0, 0); + // return inputDate >= today; + // }, + // { + // message: "過去の日付は指定できません", + // }, + // ), + endDate, + allowedRanges: z + .array( + z.object({ + startTime: time, + endTime: time, + }), + ) + .refine((ranges) => ranges.every(({ startTime, endTime }) => startTime < endTime), { + message: "開始時刻は終了時刻より前でなければなりません", + }) + .refine( + (ranges) => ranges.every(({ startTime, endTime }) => isQuarterHour(startTime) && isQuarterHour(endTime)), + { + message: "開始時刻と終了時刻は15分単位で入力してください", + }, + ), + participationOptions: z.array(participationOptionCreateSchema).min(1, "参加形態は最低1つ必要です"), + }); +} + +const baseProjectFormSchema = baseProjectSchema({ + startDate: z.string().min(1, "開始日を入力してください").date("開始日の形式が正しくありません"), + endDate: z.string().min(1, "終了日を入力してください").date("終了日の形式が正しくありません"), + time: formTime, + isQuarterHour: isQuarterHourFormTime, }); -export const projectReqSchema = baseProjectReqSchema.refine( - (data) => { - if (data.startDate && data.endDate) { - return data.startDate <= data.endDate; - } - return true; - }, - { - message: "開始日は終了日より前に設定してください", - path: ["endDate"], - }, -); - -export const editReqSchema = baseProjectReqSchema.partial().refine( - (data) => { - if (data.startDate && data.endDate) { - return data.startDate <= data.endDate; - } - return true; - }, - { - message: "開始日は終了日より前に設定してください", - path: ["endDate"], - }, -); +const baseProjectReqSchema = baseProjectSchema({ + startDate: z.string().datetime(), + endDate: z.string().datetime(), + time: z.string().datetime(), + isQuarterHour: isQuarterHourDateTime, +}); + +const isDateOrdered = (data: { startDate?: string; endDate?: string }) => { + if (data.startDate && data.endDate) { + return data.startDate <= data.endDate; + } + return true; +}; + +const dateOrderError = { + message: "開始日は終了日より前に設定してください", + path: ["endDate"], +}; + +export const projectFormSchema = baseProjectFormSchema.refine(isDateOrdered, dateOrderError); + +export const editFormSchema = baseProjectFormSchema.partial().refine(isDateOrdered, dateOrderError); + +export const projectReqSchema = baseProjectReqSchema.refine(isDateOrdered, dateOrderError); + +export const editReqSchema = baseProjectReqSchema.partial().refine(isDateOrdered, dateOrderError); diff --git a/server/test/projects.test.ts b/server/test/projects.test.ts index 5fbd6eb..db6fbc2 100644 --- a/server/test/projects.test.ts +++ b/server/test/projects.test.ts @@ -76,6 +76,30 @@ describe("POST /projects", () => { expect(res.status).toBe(400); }); + + test("日付が ISO 8601 の日時でなければ 400", async () => { + const res = await createBrowser().post("/projects", projectInput({ startDate: "2026-11-02" })); + + expect(res.status).toBe(400); + }); + + test("時間帯が ISO 8601 の日時でなければ 400", async () => { + const res = await createBrowser().post( + "/projects", + projectInput({ allowedRanges: [{ startTime: "09:00", endTime: "18:00" }] }), + ); + + expect(res.status).toBe(400); + }); + + test("時間帯が 15 分単位でなければ 400", async () => { + const res = await createBrowser().post( + "/projects", + projectInput({ allowedRanges: [{ startTime: "2026-11-01T23:10:00.000Z", endTime: "2026-11-04T14:00:00.000Z" }] }), + ); + + expect(res.status).toBe(400); + }); }); describe("GET /projects/mine", () => { @@ -205,6 +229,15 @@ describe("PUT /projects/:projectId", () => { expect(res.status).toBe(403); }); + test("時間帯が ISO 8601 の日時でなければ 400", async () => { + const host = createBrowser(); + const { id } = await createProject(host); + + const res = await host.put(`/projects/${id}`, { allowedRanges: [{ startTime: "09:00", endTime: "18:00" }] }); + + expect(res.status).toBe(400); + }); + test("ゲストがいなければ、名前・説明・日程・時間帯を更新できる", async () => { const host = createBrowser(); const { id } = await createProject(host); From 3a40fa1915c2a7ad8a01ca92fc36766efd5a47ae Mon Sep 17 00:00:00 2001 From: nakaterm <104970808+nakaterm@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:18:26 +0900 Subject: [PATCH 02/17] =?UTF-8?q?test:=20=E3=83=AC=E3=82=B9=E3=83=9D?= =?UTF-8?q?=E3=83=B3=E3=82=B9=E3=81=AE=E5=BD=A2=E3=82=92=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E3=81=99=E3=82=8B=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=B6=B3?= =?UTF-8?q?=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ホストが回答した場合の hosts / guests / mine、Slot の guestId、 PUT のレスポンス全体を検証する。 Co-Authored-By: Claude Opus 5.5 --- server/test/projects.test.ts | 47 ++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/server/test/projects.test.ts b/server/test/projects.test.ts index db6fbc2..e8f8457 100644 --- a/server/test/projects.test.ts +++ b/server/test/projects.test.ts @@ -184,6 +184,7 @@ describe("GET /projects/:projectId", () => { expect(project.isHost).toBe(false); expect(project.meAsGuest).toEqual(expectedGuest); expect(project.guests).toEqual([expectedGuest]); + expect(project.meAsGuest.slots[0].guestId).toBe(project.meAsGuest.id); }); test("回答していない人から見ると meAsGuest は null", async () => { @@ -253,15 +254,22 @@ describe("PUT /projects/:projectId", () => { expect(res.status).toBe(200); // biome-ignore lint/suspicious/noExplicitAny: レスポンスの形そのものを検証するため const { event } = (await res.json()) as any; - expect(event).toMatchObject({ + expect(event).toEqual({ id, name: "新しい名前", description: "新しい説明", startDate: "2026-11-09T15:00:00.000Z", endDate: "2026-11-10T14:59:59.999Z", - allowedRanges: [{ startTime: "2026-11-10T00:00:00.000Z", endTime: "2026-11-10T09:00:00.000Z" }], + allowedRanges: [ + { + id: expect.any(String), + projectId: id, + startTime: "2026-11-10T00:00:00.000Z", + endTime: "2026-11-10T09:00:00.000Z", + }, + ], + participationOptions: [{ id: expect.any(String), label: "参加", color: "#0F82B1", projectId: id }], }); - expect(event.participationOptions).toHaveLength(1); }); test("ゲストがいると、名前と説明だけ更新し、日程と時間帯は変えない", async () => { @@ -420,6 +428,18 @@ describe("POST /projects/:projectId/submissions", () => { const project = await getProject(host, id); expect(project.isHost).toBe(true); expect(project.meAsGuest).not.toBeNull(); + expect(project.hosts).toEqual([{ id: expect.any(String), projectId: id }]); + expect(project.guests).toEqual([project.meAsGuest]); + }); + + test("回答したホストの /projects/mine は、ホストとして 1 件だけ返す", async () => { + const host = createBrowser(); + const { id, optionId } = await createProject(host); + await submit(host, id, optionId); + + const res = await host.get("/projects/mine"); + + expect(await res.json()).toEqual([expect.objectContaining({ id, isHost: true })]); }); test("名前が空なら 400", async () => { @@ -517,9 +537,26 @@ describe("PUT /projects/:projectId/submissions/mine", () => { ); expect(res.status).toBe(200); - expect(await res.json()).toMatchObject({ + // 現状の挙動: GET と違い、自分の browserId を含む + expect(await res.json()).toEqual({ message: "日程が更新されました。", - guest: { name: "新しい名前", comment: null, slots: [{ from: newSlot.start, to: newSlot.end }] }, + guest: { + id: expect.any(String), + name: "新しい名前", + comment: null, + browserId: guest.browserId, + projectId: id, + slots: [ + { + id: expect.any(String), + from: newSlot.start, + to: newSlot.end, + projectId: id, + guestId: expect.any(String), + participationOptionId: optionId, + }, + ], + }, }); const { meAsGuest } = await getProject(guest, id); expect(meAsGuest.slots).toEqual([expect.objectContaining({ from: newSlot.start, to: newSlot.end })]); From defa08e3947c9beaa1115cfc7436bcc3aa27ac5e Mon Sep 17 00:00:00 2001 From: nakaterm <104970808+nakaterm@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:18:26 +0900 Subject: [PATCH 03/17] =?UTF-8?q?fix:=20=E5=9B=9E=E7=AD=94=E3=81=AE?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E3=81=AE=E3=83=AC=E3=82=B9=E3=83=9D=E3=83=B3?= =?UTF-8?q?=E3=82=B9=E3=81=AB=20browserId=20=E3=82=92=E5=90=AB=E3=82=81?= =?UTF-8?q?=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET では browserId を外しているが、PUT /projects/:projectId/submissions/mine は 自分の回答をそのまま返していたため、browserId が含まれていた。 Co-Authored-By: Claude Opus 5.5 --- server/src/services/projects.ts | 2 ++ server/test/projects.test.ts | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 89494f6..2854eb1 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -260,9 +260,11 @@ async function findProjectForSlots(db: Db, projectId: string) { }; } +/** 自分の回答。呼び出し元がそのまま返せるよう、browserId は含めない */ function findGuest(db: Db, browserId: string, projectId: string) { return db.query.guest.findFirst({ where: and(eq(schema.guest.browserId, browserId), eq(schema.guest.projectId, projectId)), + columns: { browserId: false }, with: { slots: true }, }); } diff --git a/server/test/projects.test.ts b/server/test/projects.test.ts index e8f8457..6f5bb36 100644 --- a/server/test/projects.test.ts +++ b/server/test/projects.test.ts @@ -537,14 +537,12 @@ describe("PUT /projects/:projectId/submissions/mine", () => { ); expect(res.status).toBe(200); - // 現状の挙動: GET と違い、自分の browserId を含む expect(await res.json()).toEqual({ message: "日程が更新されました。", guest: { id: expect.any(String), name: "新しい名前", comment: null, - browserId: guest.browserId, projectId: id, slots: [ { From 14e99010b0523a209bd302e6ffe0d66bb6cb2e13 Mon Sep 17 00:00:00 2001 From: nakaterm <104970808+nakaterm@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:22:33 +0900 Subject: [PATCH 04/17] =?UTF-8?q?test:=20=E3=83=9E=E3=82=A4=E3=82=B0?= =?UTF-8?q?=E3=83=AC=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=E3=81=AB=E3=82=88?= =?UTF-8?q?=E3=82=8B=E3=83=87=E3=83=BC=E3=82=BF=E3=81=AE=E5=A4=89=E6=8F=9B?= =?UTF-8?q?=E3=82=92=E7=A2=BA=E3=81=8B=E3=82=81=E3=82=8B=E5=9F=BA=E7=9B=A4?= =?UTF-8?q?=E3=82=92=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 指定したマイグレーションの直前まで当てた DB を用意し、旧スキーマのデータを入れてから 残りを当てられるようにする。共有の DB とは別の D1 を使う。 Co-Authored-By: Claude Opus 5.5 --- server/test/env.d.ts | 1 + server/test/migrations.test.ts | 24 ++++++++++++++++ server/test/migrations.ts | 51 ++++++++++++++++++++++++++++++++++ server/vitest.config.ts | 2 ++ 4 files changed, 78 insertions(+) create mode 100644 server/test/migrations.test.ts create mode 100644 server/test/migrations.ts diff --git a/server/test/env.d.ts b/server/test/env.d.ts index 357a165..2d155b2 100644 --- a/server/test/env.d.ts +++ b/server/test/env.d.ts @@ -5,6 +5,7 @@ declare global { namespace Cloudflare { interface Env extends Bindings { TEST_MIGRATIONS: D1Migration[]; + MIGRATION_TEST_DB: D1Database; } } } diff --git a/server/test/migrations.test.ts b/server/test/migrations.test.ts new file mode 100644 index 0000000..1c3f14d --- /dev/null +++ b/server/test/migrations.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "vitest"; +import { migrateUntil, tableNames } from "./migrations.js"; + +describe("migrateUntil", () => { + test("指定したマイグレーションの直前まで当て、migrate() で残りを当てる", async () => { + const { db, migrate } = await migrateUntil("0000_init.sql"); + expect(await tableNames(db)).toEqual(["d1_migrations"]); + + await migrate(); + + expect(await tableNames(db)).toEqual(expect.arrayContaining(["Project", "Guest", "Slot"])); + }); + + test("前のテストのデータを残さない", async () => { + const first = await migrateUntil("0000_init.sql"); + await first.migrate(); + await first.db.prepare(`INSERT INTO "Project" VALUES ('p', 'name', NULL, 's', 'e')`).run(); + + const second = await migrateUntil("0000_init.sql"); + await second.migrate(); + + expect(await second.db.prepare(`SELECT * FROM "Project"`).all()).toMatchObject({ results: [] }); + }); +}); diff --git a/server/test/migrations.ts b/server/test/migrations.ts new file mode 100644 index 0000000..fca3dca --- /dev/null +++ b/server/test/migrations.ts @@ -0,0 +1,51 @@ +import { applyD1Migrations } from "cloudflare:test"; +import { env } from "cloudflare:workers"; + +/** + * マイグレーションのテスト用に、`name` の直前までのマイグレーションを当てた DB を用意する。 + * 返り値の `db` に旧スキーマのデータを入れてから `migrate()` で残りを当て、データの変換を確かめる。 + * @param name マイグレーションのファイル名 (例: "0001_add_timezone.sql") + */ +export async function migrateUntil(name: string) { + const db = env.MIGRATION_TEST_DB; + const index = env.TEST_MIGRATIONS.findIndex((m) => m.name === name); + if (index === -1) throw new Error(`マイグレーションが見つかりません: ${name}`); + + await dropAllTables(db); + await applyD1Migrations(db, env.TEST_MIGRATIONS.slice(0, index)); + + return { + db, + /** 残りのマイグレーションをすべて当てる */ + migrate: () => applyD1Migrations(db, env.TEST_MIGRATIONS), + }; +} + +export async function tableNames(db: D1Database) { + const { results } = await db + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_cf_%'", + ) + .all<{ name: string }>(); + return results.map((r) => r.name); +} + +/** 参照されているテーブルを先に消すと、参照する側の DROP が失敗するので、参照する側から消す */ +async function dropAllTables(db: D1Database) { + const remaining = new Map(); + for (const name of await tableNames(db)) { + const { results } = await db.prepare(`PRAGMA foreign_key_list("${name}")`).all<{ table: string }>(); + remaining.set( + name, + results.map((r) => r.table), + ); + } + + while (remaining.size > 0) { + const referenced = new Set([...remaining.entries()].flatMap(([name, refs]) => refs.filter((r) => r !== name))); + const droppable = [...remaining.keys()].filter((name) => !referenced.has(name)); + if (droppable.length === 0) throw new Error(`循環参照があり、テーブルを消せません: ${[...remaining.keys()]}`); + await db.batch(droppable.map((name) => db.prepare(`DROP TABLE "${name}"`))); + for (const name of droppable) remaining.delete(name); + } +} diff --git a/server/vitest.config.ts b/server/vitest.config.ts index a1bf9ec..4b0e52b 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -7,6 +7,8 @@ export default defineConfig(async () => ({ cloudflareTest({ wrangler: { configPath: "./wrangler.toml" }, miniflare: { + // マイグレーションのテスト用。DB は全テストで共有し、beforeEach で最新のスキーマにするので、別に持つ + d1Databases: ["MIGRATION_TEST_DB"], bindings: { TEST_MIGRATIONS: await readD1Migrations("./migrations"), COOKIE_SECRET: "test-secret", From 87ffa078db8288e880befbe47a88030f4e3897a3 Mon Sep 17 00:00:00 2001 From: nakaterm <104970808+nakaterm@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:27:21 +0900 Subject: [PATCH 05/17] =?UTF-8?q?feat(db):=20Project=20=E3=81=AB=E3=82=BF?= =?UTF-8?q?=E3=82=A4=E3=83=A0=E3=82=BE=E3=83=BC=E3=83=B3=E3=81=A8=E4=BD=9C?= =?UTF-8?q?=E6=88=90=E6=97=A5=E6=99=82=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slot の検証で使うタイムゾーンを、固定の Asia/Tokyo からイベントごとの値にする。 既存のイベントは Asia/Tokyo とし、作成日時はマイグレーションを当てた時刻で埋める。 Co-Authored-By: Claude Opus 5.5 --- .../0001_add_project_timezone_created_at.sql | 5 + server/migrations/meta/0001_snapshot.json | 400 ++++++++++++++++++ server/migrations/meta/_journal.json | 7 + server/src/db/schema.ts | 5 + server/src/services/projects.ts | 4 +- server/src/services/slots.ts | 44 +- server/test/migrations.test.ts | 26 +- server/test/slots.test.ts | 11 + 8 files changed, 477 insertions(+), 25 deletions(-) create mode 100644 server/migrations/0001_add_project_timezone_created_at.sql create mode 100644 server/migrations/meta/0001_snapshot.json diff --git a/server/migrations/0001_add_project_timezone_created_at.sql b/server/migrations/0001_add_project_timezone_created_at.sql new file mode 100644 index 0000000..cfdf7cd --- /dev/null +++ b/server/migrations/0001_add_project_timezone_created_at.sql @@ -0,0 +1,5 @@ +ALTER TABLE `Project` ADD `timezone` text DEFAULT 'Asia/Tokyo' NOT NULL;--> statement-breakpoint +-- 既定値の無い NOT NULL の列は既存の行があると追加できないので、空文字で追加してから埋める。 +-- 旧データには作成日時が無いので、マイグレーションを当てた時刻にする。アプリは常に値を入れる +ALTER TABLE `Project` ADD `createdAt` text DEFAULT '' NOT NULL;--> statement-breakpoint +UPDATE `Project` SET `createdAt` = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'); diff --git a/server/migrations/meta/0001_snapshot.json b/server/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..f1efc38 --- /dev/null +++ b/server/migrations/meta/0001_snapshot.json @@ -0,0 +1,400 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d5689769-b695-425f-9414-e867139f6c7d", + "prevId": "90e0c02e-5716-4dc1-8d6a-a677252a917f", + "tables": { + "AllowedRange": { + "name": "AllowedRange", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "startTime": { + "name": "startTime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endTime": { + "name": "endTime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "AllowedRange_projectId_idx": { + "name": "AllowedRange_projectId_idx", + "columns": ["projectId"], + "isUnique": false + } + }, + "foreignKeys": { + "AllowedRange_projectId_Project_id_fk": { + "name": "AllowedRange_projectId_Project_id_fk", + "tableFrom": "AllowedRange", + "tableTo": "Project", + "columnsFrom": ["projectId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Guest": { + "name": "Guest", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "browserId": { + "name": "browserId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Guest_browserId_projectId_key": { + "name": "Guest_browserId_projectId_key", + "columns": ["browserId", "projectId"], + "isUnique": true + }, + "Guest_projectId_idx": { + "name": "Guest_projectId_idx", + "columns": ["projectId"], + "isUnique": false + } + }, + "foreignKeys": { + "Guest_projectId_Project_id_fk": { + "name": "Guest_projectId_Project_id_fk", + "tableFrom": "Guest", + "tableTo": "Project", + "columnsFrom": ["projectId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Host": { + "name": "Host", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "browserId": { + "name": "browserId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Host_browserId_projectId_key": { + "name": "Host_browserId_projectId_key", + "columns": ["browserId", "projectId"], + "isUnique": true + }, + "Host_projectId_idx": { + "name": "Host_projectId_idx", + "columns": ["projectId"], + "isUnique": false + } + }, + "foreignKeys": { + "Host_projectId_Project_id_fk": { + "name": "Host_projectId_Project_id_fk", + "tableFrom": "Host", + "tableTo": "Project", + "columnsFrom": ["projectId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ParticipationOption": { + "name": "ParticipationOption", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ParticipationOption_projectId_label_key": { + "name": "ParticipationOption_projectId_label_key", + "columns": ["projectId", "label"], + "isUnique": true + } + }, + "foreignKeys": { + "ParticipationOption_projectId_Project_id_fk": { + "name": "ParticipationOption_projectId_Project_id_fk", + "tableFrom": "ParticipationOption", + "tableTo": "Project", + "columnsFrom": ["projectId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Project": { + "name": "Project", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startDate": { + "name": "startDate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endDate": { + "name": "endDate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Asia/Tokyo'" + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Slot": { + "name": "Slot", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "from": { + "name": "from", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to": { + "name": "to", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "participationOptionId": { + "name": "participationOptionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Slot_projectId_idx": { + "name": "Slot_projectId_idx", + "columns": ["projectId"], + "isUnique": false + }, + "Slot_guestId_idx": { + "name": "Slot_guestId_idx", + "columns": ["guestId"], + "isUnique": false + }, + "Slot_participationOptionId_idx": { + "name": "Slot_participationOptionId_idx", + "columns": ["participationOptionId"], + "isUnique": false + } + }, + "foreignKeys": { + "Slot_projectId_Project_id_fk": { + "name": "Slot_projectId_Project_id_fk", + "tableFrom": "Slot", + "tableTo": "Project", + "columnsFrom": ["projectId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Slot_guestId_Guest_id_fk": { + "name": "Slot_guestId_Guest_id_fk", + "tableFrom": "Slot", + "tableTo": "Guest", + "columnsFrom": ["guestId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "cascade" + }, + "Slot_participationOptionId_ParticipationOption_id_fk": { + "name": "Slot_participationOptionId_ParticipationOption_id_fk", + "tableFrom": "Slot", + "tableTo": "ParticipationOption", + "columnsFrom": ["participationOptionId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/server/migrations/meta/_journal.json b/server/migrations/meta/_journal.json index 78a592f..651175f 100644 --- a/server/migrations/meta/_journal.json +++ b/server/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1792671045091, "tag": "0000_init", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1792693418605, + "tag": "0001_add_project_timezone_created_at", + "breakpoints": true } ] } diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index f843dc1..cc57499 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -12,6 +12,11 @@ export const project = sqliteTable("Project", { startDate: text().notNull(), /** 注: 日付部分のみ利用。時間は考慮しない。 */ endDate: text().notNull(), + /** 日付と時間帯は、このタイムゾーンの壁時計で解釈する (IANA のタイムゾーン名) */ + timezone: text().notNull().default("Asia/Tokyo"), + createdAt: text() + .notNull() + .$defaultFn(() => new Date().toISOString()), }); /** 日程調整プロジェクトにおいて入力が許可された時間帯。(注: 現在は 1 つの日程調整プロジェクトにつき 1 つのみ設定可能) */ diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 2854eb1..490c3d2 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -220,6 +220,7 @@ export async function updateProject(db: Db, browserId: string, projectId: string return db.query.project.findFirst({ where: eq(schema.project.id, projectId), + columns: { id: true, name: true, description: true, startDate: true, endDate: true }, with: { allowedRanges: true, participationOptions: true }, }); } @@ -240,7 +241,7 @@ export async function deleteProject(db: Db, browserId: string, projectId: string async function findProjectForSlots(db: Db, projectId: string) { const project = await db.query.project.findFirst({ where: eq(schema.project.id, projectId), - columns: { startDate: true, endDate: true }, + columns: { timezone: true, startDate: true, endDate: true }, with: { allowedRanges: { columns: { startTime: true, endTime: true } }, participationOptions: { columns: { id: true } }, @@ -250,6 +251,7 @@ async function findProjectForSlots(db: Db, projectId: string) { throw new ServiceError("project_not_found", "イベントが見つかりません。"); } return { + timezone: project.timezone, startDate: new Date(project.startDate), endDate: new Date(project.endDate), allowedRanges: project.allowedRanges.map((r) => ({ diff --git a/server/src/services/slots.ts b/server/src/services/slots.ts index 2572f66..381868a 100644 --- a/server/src/services/slots.ts +++ b/server/src/services/slots.ts @@ -1,29 +1,24 @@ import { ServiceError } from "./errors.js"; -/** - * イベントの日付と時刻は、このタイムゾーンの壁時計で解釈する (AllowedRange は日付部分を使わず、時刻だけを使う)。 - * M3 で event.timezone に置き換える。 - */ -const TIMEZONE = "Asia/Tokyo"; - const MINUTES_PER_DAY = 24 * 60; -const wallClockFormat = new Intl.DateTimeFormat("en-CA", { - timeZone: TIMEZONE, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hourCycle: "h23", -}); - -/** TIMEZONE での日付 ("YYYY-MM-DD") と、0 時からの経過分 */ -function toWallClock(value: Date): { date: string; minutes: number } { - const parts = Object.fromEntries(wallClockFormat.formatToParts(value).map((p) => [p.type, p.value])); - return { - date: `${parts.year}-${parts.month}-${parts.day}`, - minutes: Number(parts.hour) * 60 + Number(parts.minute), +/** timezone での日付 ("YYYY-MM-DD") と、0 時からの経過分を求める関数を作る */ +function wallClockIn(timezone: string): (value: Date) => { date: string; minutes: number } { + const format = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }); + return (value) => { + const parts = Object.fromEntries(format.formatToParts(value).map((p) => [p.type, p.value])); + return { + date: `${parts.year}-${parts.month}-${parts.day}`, + minutes: Number(parts.hour) * 60 + Number(parts.minute), + }; }; } @@ -31,7 +26,7 @@ function formatMinutes(minutes: number): string { return `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`; } -/** 秒以下が 0 で、分が 15 の倍数か。TIMEZONE のオフセットは 15 分の倍数なので UTC で判定してよい。 */ +/** 秒以下が 0 で、分が 15 の倍数か。タイムゾーンのオフセットはどれも 15 分の倍数なので UTC で判定してよい。 */ function isQuarterHour(value: Date): boolean { return value.getTime() % (15 * 60 * 1000) === 0; } @@ -39,6 +34,8 @@ function isQuarterHour(value: Date): boolean { type SlotInput = { start: Date; end: Date; participationOptionId: string }; type Project = { + /** 日付と時間帯は、このタイムゾーンの壁時計で解釈する (AllowedRange は日付部分を使わず、時刻だけを使う) */ + timezone: string; startDate: Date; endDate: Date; allowedRanges: { startTime: Date; endTime: Date }[]; @@ -50,6 +47,7 @@ type Project = { * Web UI はカレンダーの構造上これを満たすが、API を直接呼ぶと満たさない Slot を作れてしまう (#91)。 */ export function validateSlots(project: Project, slots: SlotInput[]): void { + const toWallClock = wallClockIn(project.timezone); const firstDate = toWallClock(project.startDate).date; const lastDate = toWallClock(project.endDate).date; // 時間帯は現在 1 つだけ。無ければ終日とみなす diff --git a/server/test/migrations.test.ts b/server/test/migrations.test.ts index 1c3f14d..8866a38 100644 --- a/server/test/migrations.test.ts +++ b/server/test/migrations.test.ts @@ -14,7 +14,9 @@ describe("migrateUntil", () => { test("前のテストのデータを残さない", async () => { const first = await migrateUntil("0000_init.sql"); await first.migrate(); - await first.db.prepare(`INSERT INTO "Project" VALUES ('p', 'name', NULL, 's', 'e')`).run(); + await first.db + .prepare(`INSERT INTO "Project" (id, name, startDate, endDate, createdAt) VALUES ('p', 'name', 's', 'e', 'c')`) + .run(); const second = await migrateUntil("0000_init.sql"); await second.migrate(); @@ -22,3 +24,25 @@ describe("migrateUntil", () => { expect(await second.db.prepare(`SELECT * FROM "Project"`).all()).toMatchObject({ results: [] }); }); }); + +describe("0001_add_project_timezone_created_at", () => { + test("既存のイベントは Asia/Tokyo になり、作成日時はマイグレーションを当てた時刻で埋まる", async () => { + const { db, migrate } = await migrateUntil("0001_add_project_timezone_created_at.sql"); + await db.batch([ + db.prepare(`INSERT INTO "Project" VALUES ('p', 'name', NULL, 's', 'e')`), + db.prepare(`INSERT INTO "AllowedRange" VALUES ('a', 's', 'e', 'p')`), + ]); + const before = Date.now(); + + await migrate(); + + const project = await db + .prepare(`SELECT timezone, createdAt FROM "Project"`) + .first<{ timezone: string; createdAt: string }>(); + expect(project?.timezone).toBe("Asia/Tokyo"); + expect(project?.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + expect(new Date(project?.createdAt ?? "").getTime()).toBeGreaterThanOrEqual(Math.floor(before / 1000) * 1000); + // 子のテーブルの行は残る + expect(await db.prepare(`SELECT id FROM "AllowedRange"`).all()).toMatchObject({ results: [{ id: "a" }] }); + }); +}); diff --git a/server/test/slots.test.ts b/server/test/slots.test.ts index 4d10917..5d7932e 100644 --- a/server/test/slots.test.ts +++ b/server/test/slots.test.ts @@ -4,6 +4,7 @@ import { validateSlots } from "../src/services/slots.js"; // 2026/11/02〜11/04 (JST)、08:00〜23:00 のイベント。時刻はコメントに JST で書く const project = { + timezone: "Asia/Tokyo", startDate: new Date("2026-11-01T15:00:00.000Z"), endDate: new Date("2026-11-04T14:59:59.999Z"), allowedRanges: [{ startTime: new Date("2026-11-01T23:00:00.000Z"), endTime: new Date("2026-11-04T14:00:00.000Z") }], @@ -123,6 +124,16 @@ describe("validateSlots", () => { ).not.toThrow(); }); + test("日付はイベントのタイムゾーンで判定する", () => { + // JST では 11/03 08:30〜09:30 だが、UTC では 11/02 23:30〜11/03 00:30 で日をまたぐ + const target = slot("2026-11-02T23:30:00.000Z", "2026-11-03T00:30:00.000Z"); + + expect(() => validateSlots(allDayProject, [target])).not.toThrow(); + expect(validationError(() => validateSlots({ ...allDayProject, timezone: "UTC" }, [target])).message).toBe( + "1 件目の時間帯: 日をまたいでいます。日付ごとに分けてください。", + ); + }); + test("イベントに無い参加形態は弾く", () => { const err = validationError(() => validateSlots(project, [slot("2026-11-02T01:00:00.000Z", "2026-11-02T03:00:00.000Z", "other")]), From 194940cf4ffad51a924b61e4a00bf4a20455c200 Mon Sep 17 00:00:00 2001 From: nakaterm <104970808+nakaterm@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:34:04 +0900 Subject: [PATCH 06/17] =?UTF-8?q?refactor(db):=20=E6=99=82=E9=96=93?= =?UTF-8?q?=E5=B8=AF=E3=81=AE=E6=99=82=E5=88=BB=E3=82=92=20"HH:mm"=20?= =?UTF-8?q?=E3=81=A7=E4=BF=9D=E5=AD=98=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 時間帯は日付を使わないので、イベントのタイムゾーンでの時刻だけを保存する。 既存の値は Asia/Tokyo の時刻に変換する。API は引き続き ISO 8601 で受け取って返し、 返すときの日付は Web UI と同じく、開始を初日、終了を最終日にする。 Co-Authored-By: Claude Opus 5.5 --- .../0002_allowed_range_time_of_day.sql | 5 + server/migrations/meta/0002_snapshot.json | 400 ++++++++++++++++++ server/migrations/meta/_journal.json | 7 + server/src/db/schema.ts | 8 +- server/src/lib/wallClock.ts | 46 ++ server/src/services/projects.ts | 54 ++- server/src/services/slots.ts | 34 +- server/test/migrations.test.ts | 24 +- server/test/projects.test.ts | 17 + server/test/slots.test.ts | 2 +- server/test/wallClock.test.ts | 15 + 11 files changed, 566 insertions(+), 46 deletions(-) create mode 100644 server/migrations/0002_allowed_range_time_of_day.sql create mode 100644 server/migrations/meta/0002_snapshot.json create mode 100644 server/src/lib/wallClock.ts create mode 100644 server/test/wallClock.test.ts diff --git a/server/migrations/0002_allowed_range_time_of_day.sql b/server/migrations/0002_allowed_range_time_of_day.sql new file mode 100644 index 0000000..fd577a2 --- /dev/null +++ b/server/migrations/0002_allowed_range_time_of_day.sql @@ -0,0 +1,5 @@ +-- 時間帯の時刻を ISO 8601 の日時から、イベントのタイムゾーンでの "HH:mm" にする。 +-- 既存のイベントはすべて Asia/Tokyo (0001 で埋めた値) なので、+9 時間で変換する +UPDATE `AllowedRange` SET + `startTime` = strftime('%H:%M', `startTime`, '+9 hours'), + `endTime` = strftime('%H:%M', `endTime`, '+9 hours'); diff --git a/server/migrations/meta/0002_snapshot.json b/server/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..fd25e78 --- /dev/null +++ b/server/migrations/meta/0002_snapshot.json @@ -0,0 +1,400 @@ +{ + "id": "ea2b5fd9-7b9f-4c3e-825e-d63e2309f7cb", + "prevId": "d5689769-b695-425f-9414-e867139f6c7d", + "version": "6", + "dialect": "sqlite", + "tables": { + "AllowedRange": { + "name": "AllowedRange", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "startTime": { + "name": "startTime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endTime": { + "name": "endTime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "AllowedRange_projectId_idx": { + "name": "AllowedRange_projectId_idx", + "columns": ["projectId"], + "isUnique": false + } + }, + "foreignKeys": { + "AllowedRange_projectId_Project_id_fk": { + "name": "AllowedRange_projectId_Project_id_fk", + "tableFrom": "AllowedRange", + "columnsFrom": ["projectId"], + "tableTo": "Project", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Guest": { + "name": "Guest", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "browserId": { + "name": "browserId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Guest_browserId_projectId_key": { + "name": "Guest_browserId_projectId_key", + "columns": ["browserId", "projectId"], + "isUnique": true + }, + "Guest_projectId_idx": { + "name": "Guest_projectId_idx", + "columns": ["projectId"], + "isUnique": false + } + }, + "foreignKeys": { + "Guest_projectId_Project_id_fk": { + "name": "Guest_projectId_Project_id_fk", + "tableFrom": "Guest", + "columnsFrom": ["projectId"], + "tableTo": "Project", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Host": { + "name": "Host", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "browserId": { + "name": "browserId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Host_browserId_projectId_key": { + "name": "Host_browserId_projectId_key", + "columns": ["browserId", "projectId"], + "isUnique": true + }, + "Host_projectId_idx": { + "name": "Host_projectId_idx", + "columns": ["projectId"], + "isUnique": false + } + }, + "foreignKeys": { + "Host_projectId_Project_id_fk": { + "name": "Host_projectId_Project_id_fk", + "tableFrom": "Host", + "columnsFrom": ["projectId"], + "tableTo": "Project", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ParticipationOption": { + "name": "ParticipationOption", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ParticipationOption_projectId_label_key": { + "name": "ParticipationOption_projectId_label_key", + "columns": ["projectId", "label"], + "isUnique": true + } + }, + "foreignKeys": { + "ParticipationOption_projectId_Project_id_fk": { + "name": "ParticipationOption_projectId_Project_id_fk", + "tableFrom": "ParticipationOption", + "columnsFrom": ["projectId"], + "tableTo": "Project", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Project": { + "name": "Project", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startDate": { + "name": "startDate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endDate": { + "name": "endDate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Asia/Tokyo'" + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Slot": { + "name": "Slot", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "from": { + "name": "from", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to": { + "name": "to", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "participationOptionId": { + "name": "participationOptionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Slot_projectId_idx": { + "name": "Slot_projectId_idx", + "columns": ["projectId"], + "isUnique": false + }, + "Slot_guestId_idx": { + "name": "Slot_guestId_idx", + "columns": ["guestId"], + "isUnique": false + }, + "Slot_participationOptionId_idx": { + "name": "Slot_participationOptionId_idx", + "columns": ["participationOptionId"], + "isUnique": false + } + }, + "foreignKeys": { + "Slot_projectId_Project_id_fk": { + "name": "Slot_projectId_Project_id_fk", + "tableFrom": "Slot", + "columnsFrom": ["projectId"], + "tableTo": "Project", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "cascade" + }, + "Slot_guestId_Guest_id_fk": { + "name": "Slot_guestId_Guest_id_fk", + "tableFrom": "Slot", + "columnsFrom": ["guestId"], + "tableTo": "Guest", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "no action" + }, + "Slot_participationOptionId_ParticipationOption_id_fk": { + "name": "Slot_participationOptionId_ParticipationOption_id_fk", + "tableFrom": "Slot", + "columnsFrom": ["participationOptionId"], + "tableTo": "ParticipationOption", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/server/migrations/meta/_journal.json b/server/migrations/meta/_journal.json index 651175f..b7170d6 100644 --- a/server/migrations/meta/_journal.json +++ b/server/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1792693418605, "tag": "0001_add_project_timezone_created_at", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1792693843461, + "tag": "0002_allowed_range_time_of_day", + "breakpoints": true } ] } diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index cc57499..7932866 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -3,6 +3,8 @@ import { index, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; const uuid = () => crypto.randomUUID(); +export const DEFAULT_TIMEZONE = "Asia/Tokyo"; + /** 日程調整プロジェクト。ユーザには「イベント」と表示。 */ export const project = sqliteTable("Project", { id: text().primaryKey(), @@ -13,7 +15,7 @@ export const project = sqliteTable("Project", { /** 注: 日付部分のみ利用。時間は考慮しない。 */ endDate: text().notNull(), /** 日付と時間帯は、このタイムゾーンの壁時計で解釈する (IANA のタイムゾーン名) */ - timezone: text().notNull().default("Asia/Tokyo"), + timezone: text().notNull().default(DEFAULT_TIMEZONE), createdAt: text() .notNull() .$defaultFn(() => new Date().toISOString()), @@ -24,9 +26,9 @@ export const allowedRange = sqliteTable( "AllowedRange", { id: text().primaryKey().$defaultFn(uuid), - /** 注: 時間部分のみ利用。日付は考慮しない。 */ + /** イベントのタイムゾーンでの時刻 ("HH:mm") */ startTime: text().notNull(), - /** 注: 時間部分のみ利用。日付は考慮しない。 */ + /** イベントのタイムゾーンでの時刻 ("HH:mm") */ endTime: text().notNull(), projectId: text() .notNull() diff --git a/server/src/lib/wallClock.ts b/server/src/lib/wallClock.ts new file mode 100644 index 0000000..71052df --- /dev/null +++ b/server/src/lib/wallClock.ts @@ -0,0 +1,46 @@ +const MS_PER_MINUTE = 60 * 1000; + +/** timezone での日付 ("YYYY-MM-DD") と、0 時からの経過分を求める関数を作る */ +export function wallClockIn(timezone: string): (value: Date) => { date: string; minutes: number } { + const format = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }); + return (value) => { + const parts = Object.fromEntries(format.formatToParts(value).map((p) => [p.type, p.value])); + return { + date: `${parts.year}-${parts.month}-${parts.day}`, + minutes: Number(parts.hour) * 60 + Number(parts.minute), + }; + }; +} + +/** 0 時からの経過分を "HH:mm" にする */ +export function formatMinutes(minutes: number): string { + return `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`; +} + +/** "HH:mm" を 0 時からの経過分にする */ +export function parseMinutes(time: string): number { + const [hour, minute] = time.split(":").map(Number); + return hour * 60 + minute; +} + +/** timezone の壁時計での日付 ("YYYY-MM-DD") と時刻 ("HH:mm") が指す瞬間 */ +export function fromWallClock(date: string, time: string, timezone: string): Date { + const toWallClock = wallClockIn(timezone); + const target = Date.parse(`${date}T00:00:00.000Z`) + parseMinutes(time) * MS_PER_MINUTE; + // UTC として読んだ値から、その付近のオフセットを引く。オフセットが変わる境目をまたぐときのために 2 回合わせる + let instant = target; + for (let i = 0; i < 2; i++) { + const wall = toWallClock(new Date(instant)); + const wallAsUtc = Date.parse(`${wall.date}T00:00:00.000Z`) + wall.minutes * MS_PER_MINUTE; + instant += target - wallAsUtc; + } + return new Date(instant); +} diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 490c3d2..0ce271c 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -4,6 +4,7 @@ import type { editReqSchema, projectReqSchema, submitReqSchema } from "../../../ import { type Db, insertMany, runBatch } from "../db/index.js"; import * as schema from "../db/schema.js"; import { nanoid } from "../lib/id.js"; +import { formatMinutes, fromWallClock, wallClockIn } from "../lib/wallClock.js"; import { ServiceError } from "./errors.js"; import { validateSlots } from "./slots.js"; @@ -16,6 +17,28 @@ function toIso(value: string | Date): string { return new Date(value).toISOString(); } +/** 時間帯は日付を持たず、timezone での時刻 ("HH:mm") で入れる */ +function toTimeOfDay(value: string, timezone: string): string { + return formatMinutes(wallClockIn(timezone)(new Date(value)).minutes); +} + +/** + * API では時間帯を ISO 8601 の日時で返す。Web UI が送るのと同じく、開始は初日、終了は最終日の日付にする。 + */ +function allowedRangesResponse( + project: { timezone: string; startDate: string; endDate: string }, + ranges: T[], +): T[] { + const toWallClock = wallClockIn(project.timezone); + const firstDate = toWallClock(new Date(project.startDate)).date; + const lastDate = toWallClock(new Date(project.endDate)).date; + return ranges.map((range) => ({ + ...range, + startTime: fromWallClock(firstDate, range.startTime, project.timezone).toISOString(), + endTime: fromWallClock(lastDate, range.endTime, project.timezone).toISOString(), + })); +} + export async function createProject(db: Db, browserId: string, input: CreateProjectInput) { const project = { id: nanoid(), @@ -23,6 +46,7 @@ export async function createProject(db: Db, browserId: string, input: CreateProj description: input.description.trim() || null, startDate: toIso(input.startDate), endDate: toIso(input.endDate), + timezone: schema.DEFAULT_TIMEZONE, }; await runBatch(db, [ @@ -31,8 +55,8 @@ export async function createProject(db: Db, browserId: string, input: CreateProj db, schema.allowedRange, input.allowedRanges.map((range) => ({ - startTime: toIso(range.startTime), - endTime: toIso(range.endTime), + startTime: toTimeOfDay(range.startTime, project.timezone), + endTime: toTimeOfDay(range.endTime, project.timezone), projectId: project.id, })), ), @@ -110,7 +134,7 @@ export async function getProject(db: Db, browserId: string, projectId: string) { description: project.description ?? "", startDate: project.startDate, endDate: project.endDate, - allowedRanges: project.allowedRanges, + allowedRanges: allowedRangesResponse(project, project.allowedRanges), participationOptions: project.participationOptions, hosts: project.hosts.map(omitBrowserId), guests: project.guests.map(omitBrowserId), @@ -125,16 +149,20 @@ function omitBrowserId(entity: T): Omit ({ - startTime: toIso(r.startTime), - endTime: toIso(r.endTime), + startTime: toTimeOfDay(r.startTime, project.timezone), + endTime: toTimeOfDay(r.endTime, project.timezone), projectId, })), ), @@ -218,11 +246,14 @@ export async function updateProject(db: Db, browserId: string, projectId: string await runBatch(db, statements); - return db.query.project.findFirst({ + const updated = await db.query.project.findFirst({ where: eq(schema.project.id, projectId), - columns: { id: true, name: true, description: true, startDate: true, endDate: true }, + columns: { id: true, name: true, description: true, startDate: true, endDate: true, timezone: true }, with: { allowedRanges: true, participationOptions: true }, }); + if (!updated) return undefined; + const { timezone: _, ...rest } = updated; + return { ...rest, allowedRanges: allowedRangesResponse(updated, updated.allowedRanges) }; } export async function deleteProject(db: Db, browserId: string, projectId: string) { @@ -254,10 +285,7 @@ async function findProjectForSlots(db: Db, projectId: string) { timezone: project.timezone, startDate: new Date(project.startDate), endDate: new Date(project.endDate), - allowedRanges: project.allowedRanges.map((r) => ({ - startTime: new Date(r.startTime), - endTime: new Date(r.endTime), - })), + allowedRanges: project.allowedRanges, participationOptions: project.participationOptions, }; } diff --git a/server/src/services/slots.ts b/server/src/services/slots.ts index 381868a..131310d 100644 --- a/server/src/services/slots.ts +++ b/server/src/services/slots.ts @@ -1,31 +1,8 @@ +import { formatMinutes, parseMinutes, wallClockIn } from "../lib/wallClock.js"; import { ServiceError } from "./errors.js"; const MINUTES_PER_DAY = 24 * 60; -/** timezone での日付 ("YYYY-MM-DD") と、0 時からの経過分を求める関数を作る */ -function wallClockIn(timezone: string): (value: Date) => { date: string; minutes: number } { - const format = new Intl.DateTimeFormat("en-CA", { - timeZone: timezone, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hourCycle: "h23", - }); - return (value) => { - const parts = Object.fromEntries(format.formatToParts(value).map((p) => [p.type, p.value])); - return { - date: `${parts.year}-${parts.month}-${parts.day}`, - minutes: Number(parts.hour) * 60 + Number(parts.minute), - }; - }; -} - -function formatMinutes(minutes: number): string { - return `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`; -} - /** 秒以下が 0 で、分が 15 の倍数か。タイムゾーンのオフセットはどれも 15 分の倍数なので UTC で判定してよい。 */ function isQuarterHour(value: Date): boolean { return value.getTime() % (15 * 60 * 1000) === 0; @@ -34,11 +11,12 @@ function isQuarterHour(value: Date): boolean { type SlotInput = { start: Date; end: Date; participationOptionId: string }; type Project = { - /** 日付と時間帯は、このタイムゾーンの壁時計で解釈する (AllowedRange は日付部分を使わず、時刻だけを使う) */ + /** 日付と時間帯は、このタイムゾーンの壁時計で解釈する */ timezone: string; startDate: Date; endDate: Date; - allowedRanges: { startTime: Date; endTime: Date }[]; + /** 時刻は "HH:mm" */ + allowedRanges: { startTime: string; endTime: string }[]; participationOptions: { id: string }[]; }; @@ -52,8 +30,8 @@ export function validateSlots(project: Project, slots: SlotInput[]): void { const lastDate = toWallClock(project.endDate).date; // 時間帯は現在 1 つだけ。無ければ終日とみなす const range = project.allowedRanges[0]; - const rangeStart = range ? toWallClock(range.startTime).minutes : 0; - const rangeEnd = range ? toWallClock(range.endTime).minutes : MINUTES_PER_DAY; + const rangeStart = range ? parseMinutes(range.startTime) : 0; + const rangeEnd = range ? parseMinutes(range.endTime) : MINUTES_PER_DAY; const optionIds = new Set(project.participationOptions.map((o) => o.id)); slots.forEach((slot, i) => { diff --git a/server/test/migrations.test.ts b/server/test/migrations.test.ts index 8866a38..ba7bae0 100644 --- a/server/test/migrations.test.ts +++ b/server/test/migrations.test.ts @@ -30,7 +30,9 @@ describe("0001_add_project_timezone_created_at", () => { const { db, migrate } = await migrateUntil("0001_add_project_timezone_created_at.sql"); await db.batch([ db.prepare(`INSERT INTO "Project" VALUES ('p', 'name', NULL, 's', 'e')`), - db.prepare(`INSERT INTO "AllowedRange" VALUES ('a', 's', 'e', 'p')`), + db.prepare( + `INSERT INTO "AllowedRange" VALUES ('a', '2026-11-01T23:00:00.000Z', '2026-11-04T14:00:00.000Z', 'p')`, + ), ]); const before = Date.now(); @@ -46,3 +48,23 @@ describe("0001_add_project_timezone_created_at", () => { expect(await db.prepare(`SELECT id FROM "AllowedRange"`).all()).toMatchObject({ results: [{ id: "a" }] }); }); }); + +describe("0002_allowed_range_time_of_day", () => { + test("時間帯の時刻を JST の HH:mm にする", async () => { + const { db, migrate } = await migrateUntil("0002_allowed_range_time_of_day.sql"); + await db.batch([ + db.prepare(`INSERT INTO "Project" (id, name, startDate, endDate) VALUES ('p', 'name', 's', 'e')`), + // 11/02 08:00〜11/04 23:00 (JST)。UTC では日付が変わる時刻も含める + db.prepare( + `INSERT INTO "AllowedRange" VALUES ('a', '2026-11-01T23:00:00.000Z', '2026-11-04T14:00:00.000Z', 'p')`, + ), + ]); + + await migrate(); + + expect(await db.prepare(`SELECT startTime, endTime FROM "AllowedRange"`).first()).toEqual({ + startTime: "08:00", + endTime: "23:00", + }); + }); +}); diff --git a/server/test/projects.test.ts b/server/test/projects.test.ts index 6f5bb36..587ed20 100644 --- a/server/test/projects.test.ts +++ b/server/test/projects.test.ts @@ -1,3 +1,4 @@ +import { env } from "cloudflare:workers"; import { describe, expect, test } from "vitest"; import { type Browser, createBrowser, createProject, projectInput, slotOnNov2, submissionInput } from "./helpers.js"; @@ -92,6 +93,22 @@ describe("POST /projects", () => { expect(res.status).toBe(400); }); + test("時間帯は時刻だけを保存し、開始は初日、終了は最終日の日付で返す", async () => { + const host = createBrowser(); + // 11/03 08:00〜23:00 (JST)。日付は使わない + const { id } = await createProject(host, { + allowedRanges: [{ startTime: "2026-11-02T23:00:00.000Z", endTime: "2026-11-03T14:00:00.000Z" }], + }); + + const stored = await env.DB.prepare(`SELECT startTime, endTime FROM "AllowedRange" WHERE projectId = ?`) + .bind(id) + .first(); + expect(stored).toEqual({ startTime: "08:00", endTime: "23:00" }); + expect((await getProject(host, id)).allowedRanges).toMatchObject([ + { startTime: "2026-11-01T23:00:00.000Z", endTime: "2026-11-04T14:00:00.000Z" }, + ]); + }); + test("時間帯が 15 分単位でなければ 400", async () => { const res = await createBrowser().post( "/projects", diff --git a/server/test/slots.test.ts b/server/test/slots.test.ts index 5d7932e..3441f47 100644 --- a/server/test/slots.test.ts +++ b/server/test/slots.test.ts @@ -7,7 +7,7 @@ const project = { timezone: "Asia/Tokyo", startDate: new Date("2026-11-01T15:00:00.000Z"), endDate: new Date("2026-11-04T14:59:59.999Z"), - allowedRanges: [{ startTime: new Date("2026-11-01T23:00:00.000Z"), endTime: new Date("2026-11-04T14:00:00.000Z") }], + allowedRanges: [{ startTime: "08:00", endTime: "23:00" }], participationOptions: [{ id: "option" }], }; const allDayProject = { ...project, allowedRanges: [] }; diff --git a/server/test/wallClock.test.ts b/server/test/wallClock.test.ts new file mode 100644 index 0000000..c6a78ba --- /dev/null +++ b/server/test/wallClock.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "vitest"; +import { fromWallClock } from "../src/lib/wallClock.js"; + +describe("fromWallClock", () => { + test("timezone の壁時計の日時を UTC の瞬間にする", () => { + expect(fromWallClock("2026-11-02", "08:00", "Asia/Tokyo").toISOString()).toBe("2026-11-01T23:00:00.000Z"); + expect(fromWallClock("2026-11-02", "23:45", "Asia/Tokyo").toISOString()).toBe("2026-11-02T14:45:00.000Z"); + }); + + test("夏時間の有無でオフセットが変わる日も合わせる", () => { + // 2026/03/08 02:00 に EST (-5) から EDT (-4) に変わる + expect(fromWallClock("2026-03-08", "01:00", "America/New_York").toISOString()).toBe("2026-03-08T06:00:00.000Z"); + expect(fromWallClock("2026-03-08", "03:00", "America/New_York").toISOString()).toBe("2026-03-08T07:00:00.000Z"); + }); +}); From 2d2a2575be55b0126bee27b7661f8afe4283b9c0 Mon Sep 17 00:00:00 2001 From: nakaterm <104970808+nakaterm@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:01:48 +0900 Subject: [PATCH 07/17] =?UTF-8?q?refactor:=20API=20=E3=81=AE=E6=99=82?= =?UTF-8?q?=E9=96=93=E5=B8=AF=E3=82=92=20"HH:mm"=20=E3=81=A7=E5=8F=97?= =?UTF-8?q?=E3=81=91=E5=8F=96=E3=81=A3=E3=81=A6=E8=BF=94=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 時間帯は日付を使わないのに、API では ISO 8601 の日時でやりとりしていた。 日付を含めて前後を比べるため、初日 23:00〜最終日 08:00 のように時刻が逆転した 時間帯も通っていた。DB と同じくイベントのタイムゾーンでの "HH:mm" にし、 その日の終わりまでを表す "24:00" も選べるようにする。 Co-Authored-By: Claude Opus 5.5 --- client/src/components/Calendar.tsx | 29 ++++++++------ client/src/pages/Project.tsx | 45 ++++++++-------------- client/src/revivers.ts | 6 +-- client/src/types.ts | 6 ++- common/validators.ts | 37 ++++++------------ server/src/db/schema.ts | 6 +-- server/src/lib/wallClock.ts | 16 -------- server/src/services/projects.ts | 53 ++++--------------------- server/test/helpers.ts | 4 +- server/test/projects.test.ts | 62 +++++++++++++++++------------- server/test/slots.test.ts | 9 +++++ server/test/wallClock.test.ts | 15 -------- 12 files changed, 106 insertions(+), 182 deletions(-) delete mode 100644 server/test/wallClock.test.ts diff --git a/client/src/components/Calendar.tsx b/client/src/components/Calendar.tsx index 9251279..89429a7 100644 --- a/client/src/components/Calendar.tsx +++ b/client/src/components/Calendar.tsx @@ -5,9 +5,10 @@ import { EditingMatrix, ViewingMatrix } from "../lib/CalendarMatrix"; import type { Dayjs } from "../lib/dayjs"; import type { EditingSlot } from "../pages/eventId/Submission"; +/** 時刻は "HH:mm"。終わりは "24:00" もありうる */ type AllowedRange = { - startTime: Dayjs; - endTime: Dayjs; + startTime: string; + endTime: string; }; type ViewingSlot = { @@ -78,6 +79,12 @@ function hexToRgb(hex: string): [number, number, number] { : PRIMARY_RGB; } +/** "HH:mm" を 0 時からの経過分にする */ +function toMinutes(time: string): number { + const [hour, minute] = time.split(":").map(Number); + return hour * 60 + minute; +} + export const Calendar = ({ startDate, endDate, @@ -94,13 +101,11 @@ export const Calendar = ({ }: Props) => { const countDays = endDate.startOf("day").diff(startDate.startOf("day"), "day") + 1; - const allowedRange = allowedRanges[0] ?? { - startTime: startDate.startOf("day"), - endTime: startDate.startOf("day").add(23, "hour").add(59, "minute"), - }; - const slotStartMinutes = allowedRange.startTime.hour() * 60 + allowedRange.startTime.minute(); - const slotEndMinutes = allowedRange.endTime.hour() * 60 + allowedRange.endTime.minute(); - const slotCount = Math.ceil((slotEndMinutes - slotStartMinutes) / 15); + // 時間帯が無ければ終日とみなす + const allowedRange = allowedRanges[0] ?? { startTime: "00:00", endTime: "24:00" }; + const slotStartMinutes = toMinutes(allowedRange.startTime); + const slotEndMinutes = toMinutes(allowedRange.endTime); + const slotCount = (slotEndMinutes - slotStartMinutes) / 15; const gridHeight = slotCount * MIN_SLOT_HEIGHT; const innerWidth = TIME_COL_WIDTH + countDays * MIN_DAY_WIDTH; @@ -411,7 +416,8 @@ export const Calendar = ({ {computedViewingSlots.map((slot) => { const dayIdx = slot.from.startOf("day").diff(startDate.startOf("day"), "day"); const fromSlot = toSlotIdx(slot.from); - const toSlot = toSlotIdx(slot.to); + // to は 24:00(翌日 0:00)になりうるので、from からの経過時間で求める + const toSlot = fromSlot + slot.to.diff(slot.from, "minute") / 15; if (dayIdx < 0 || dayIdx >= countDays || fromSlot < 0 || toSlot <= fromSlot) return null; const optionGroups = new Map(); @@ -509,7 +515,8 @@ export const Calendar = ({ {slots.map((slot) => { const dayIdx = slot.from.startOf("day").diff(startDate.startOf("day"), "day"); const fromSlot = toSlotIdx(slot.from); - const toSlot = toSlotIdx(slot.to); + // to は 24:00(翌日 0:00)になりうるので、from からの経過時間で求める + const toSlot = fromSlot + slot.to.diff(slot.from, "minute") / 15; if (dayIdx < 0 || dayIdx >= countDays) return null; const option = participationOptions.find((o) => o.id === slot.optionId); diff --git a/client/src/pages/Project.tsx b/client/src/pages/Project.tsx index e0265f7..322a7e2 100644 --- a/client/src/pages/Project.tsx +++ b/client/src/pages/Project.tsx @@ -150,16 +150,15 @@ export default function ProjectPage() { useEffect(() => { if (!eventId) return; if (!project) return; + const range = project.allowedRanges[0]; reset({ name: project.name, description: project.description, startDate: project.startDate.tz().format("YYYY-MM-DD"), endDate: project.endDate.tz().format("YYYY-MM-DD"), allowedRanges: [ - { - startTime: project.allowedRanges[0].startTime.tz().format("HH:mm"), - endTime: project.allowedRanges[0].endTime.tz().format("HH:mm"), - }, + // 時間帯が無いイベントは終日とみなす + range ? { startTime: range.startTime, endTime: range.endTime } : { startTime: "00:00", endTime: "24:00" }, ], participationOptions: project.participationOptions.map((opt) => ({ id: opt.id, @@ -176,30 +175,12 @@ export default function ProjectPage() { const startDtISO = dayjs.tz(data.startDate).startOf("day").toISOString(); const endDtISO = dayjs.tz(data.endDate).endOf("day").toISOString(); - const rangeWithDateTime = data.allowedRanges?.map((range) => { - // 注: 現在のところ日付部分は使用していない。 - const startTime = dayjs - .tz(data.startDate) - .hour(Number(range.startTime.split(":")[0])) - .minute(Number(range.startTime.split(":")[1])) - .second(0); - const endTime = dayjs - .tz(data.endDate) - .hour(Number(range.endTime.split(":")[0])) - .minute(Number(range.endTime.split(":")[1])) - .second(0); - return { - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), - }; - }); - const eventData = { name: data.name ?? "", description: data.description ?? "", startDate: startDtISO, endDate: endDtISO, - allowedRanges: rangeWithDateTime ?? [], + allowedRanges: data.allowedRanges ?? [], participationOptions: (data.participationOptions ?? []).map((opt) => ({ id: opt.id, label: opt.label.trim(), @@ -463,10 +444,12 @@ export default function ProjectPage() { className={`flex-1 rounded-lg border px-2 py-2 text-base transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:px-3 sm:py-2.5 ${errors.allowedRanges ? "border-red-500" : "border-base-300"} ${project && project.guests.length > 0 ? "cursor-not-allowed bg-base-200 opacity-60" : ""}`} value={allowedRangeFields[0].endTime.split(":")[0]} onChange={(e) => { + // 24 時は 24:00 だけ + const minute = e.target.value === "24" ? "00" : allowedRangeFields[0].endTime.split(":")[1]; replace([ { startTime: allowedRangeFields[0].startTime, - endTime: `${e.target.value}:${allowedRangeFields[0].endTime.split(":")[1]}`, + endTime: `${e.target.value}:${minute}`, }, ]); }} @@ -476,7 +459,7 @@ export default function ProjectPage() { - {Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, "0")).map((h) => ( + {Array.from({ length: 25 }, (_, i) => i.toString().padStart(2, "0")).map((h) => ( @@ -501,11 +484,13 @@ export default function ProjectPage() { - {["00", "15", "30", "45"].map((h) => ( - - ))} + {(allowedRangeFields[0].endTime.startsWith("24") ? ["00"] : ["00", "15", "30", "45"]).map( + (h) => ( + + ), + )} diff --git a/client/src/revivers.ts b/client/src/revivers.ts index 70f9b39..83be79c 100644 --- a/client/src/revivers.ts +++ b/client/src/revivers.ts @@ -13,11 +13,7 @@ export function projectReviver(project: ISOStringProject): Project { ...project, startDate: dayjs.utc(project.startDate).tz(), endDate: dayjs.utc(project.endDate).tz(), - allowedRanges: project.allowedRanges.map((range) => ({ - ...range, - startTime: dayjs.utc(range.startTime).tz(), - endTime: dayjs.utc(range.endTime).tz(), - })), + allowedRanges: project.allowedRanges.map((range) => ({ ...range })), participationOptions: project.participationOptions.map((opt) => ({ ...opt })), hosts: project.hosts.map((host) => ({ ...host })), guests: project.guests.map((guest) => ({ diff --git a/client/src/types.ts b/client/src/types.ts index abbf1ad..dc83c1d 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -19,8 +19,10 @@ export type Slot = { type AllowedRange = { id: string; projectId: string; - startTime: Dayjs; - endTime: Dayjs; + /** イベントのタイムゾーンでの時刻 ("HH:mm") */ + startTime: string; + /** イベントのタイムゾーンでの時刻 ("HH:mm")。その日の終わりまでなら "24:00" */ + endTime: string; }; type Host = { diff --git a/common/validators.ts b/common/validators.ts index f121167..aaa401e 100644 --- a/common/validators.ts +++ b/common/validators.ts @@ -31,33 +31,24 @@ export const submitReqSchema = z.object({ ), }); -/** フォームの時刻 ("HH:mm") */ -const formTime = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "時刻の形式が正しくありません"); +/** 時間帯の開始時刻 ("HH:mm") */ +const startTime = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "時刻の形式が正しくありません"); + +/** 時間帯の終了時刻 ("HH:mm")。その日の終わりまでを表す "24:00" も許す */ +const endTime = z.string().regex(/^(([01]\d|2[0-3]):[0-5]\d|24:00)$/, "時刻の形式が正しくありません"); /** "HH:mm" の分が 15 の倍数か */ -const isQuarterHourFormTime = (time: string): boolean => { +const isQuarterHour = (time: string): boolean => { const [, minute] = time.split(":").map(Number); return [0, 15, 30, 45].includes(minute); }; -/** ISO 8601 の日時の分が 15 の倍数で、秒以下が 0 か。Asia/Tokyo のオフセットは 15 分の倍数なので UTC で判定してよい。 */ -const isQuarterHourDateTime = (dateTime: string): boolean => new Date(dateTime).getTime() % (15 * 60 * 1000) === 0; - /** - * イベントの作成・編集の入力。フォームと API で、日付・時刻の形式だけが異なる。 - * フォームは "YYYY-MM-DD" と "HH:mm"、API は ISO 8601 の日時で受け取る (Web UI が変換して送る)。 + * イベントの作成・編集の入力。フォームと API で、日付の形式だけが異なる。 + * フォームは "YYYY-MM-DD"、API は ISO 8601 の日時で受け取る (Web UI が変換して送る)。 + * 時間帯はどちらも、イベントのタイムゾーンでの "HH:mm" で受け取る。 */ -function baseProjectSchema({ - startDate, - endDate, - time, - isQuarterHour, -}: { - startDate: z.ZodString; - endDate: z.ZodString; - time: z.ZodString; - isQuarterHour: (time: string) => boolean; -}) { +function baseProjectSchema({ startDate, endDate }: { startDate: z.ZodString; endDate: z.ZodString }) { return z.object({ name: z.string().min(1, "イベント名を入力してください").max(100, "イベント名は100文字以内で入力してください"), description: z.string().max(1000, "説明は1000文字以内で入力してください"), @@ -78,8 +69,8 @@ function baseProjectSchema({ allowedRanges: z .array( z.object({ - startTime: time, - endTime: time, + startTime, + endTime, }), ) .refine((ranges) => ranges.every(({ startTime, endTime }) => startTime < endTime), { @@ -98,15 +89,11 @@ function baseProjectSchema({ const baseProjectFormSchema = baseProjectSchema({ startDate: z.string().min(1, "開始日を入力してください").date("開始日の形式が正しくありません"), endDate: z.string().min(1, "終了日を入力してください").date("終了日の形式が正しくありません"), - time: formTime, - isQuarterHour: isQuarterHourFormTime, }); const baseProjectReqSchema = baseProjectSchema({ startDate: z.string().datetime(), endDate: z.string().datetime(), - time: z.string().datetime(), - isQuarterHour: isQuarterHourDateTime, }); const isDateOrdered = (data: { startDate?: string; endDate?: string }) => { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 7932866..f051feb 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -3,8 +3,6 @@ import { index, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; const uuid = () => crypto.randomUUID(); -export const DEFAULT_TIMEZONE = "Asia/Tokyo"; - /** 日程調整プロジェクト。ユーザには「イベント」と表示。 */ export const project = sqliteTable("Project", { id: text().primaryKey(), @@ -15,7 +13,7 @@ export const project = sqliteTable("Project", { /** 注: 日付部分のみ利用。時間は考慮しない。 */ endDate: text().notNull(), /** 日付と時間帯は、このタイムゾーンの壁時計で解釈する (IANA のタイムゾーン名) */ - timezone: text().notNull().default(DEFAULT_TIMEZONE), + timezone: text().notNull().default("Asia/Tokyo"), createdAt: text() .notNull() .$defaultFn(() => new Date().toISOString()), @@ -28,7 +26,7 @@ export const allowedRange = sqliteTable( id: text().primaryKey().$defaultFn(uuid), /** イベントのタイムゾーンでの時刻 ("HH:mm") */ startTime: text().notNull(), - /** イベントのタイムゾーンでの時刻 ("HH:mm") */ + /** イベントのタイムゾーンでの時刻 ("HH:mm")。その日の終わりまでなら "24:00" */ endTime: text().notNull(), projectId: text() .notNull() diff --git a/server/src/lib/wallClock.ts b/server/src/lib/wallClock.ts index 71052df..64d969b 100644 --- a/server/src/lib/wallClock.ts +++ b/server/src/lib/wallClock.ts @@ -1,5 +1,3 @@ -const MS_PER_MINUTE = 60 * 1000; - /** timezone での日付 ("YYYY-MM-DD") と、0 時からの経過分を求める関数を作る */ export function wallClockIn(timezone: string): (value: Date) => { date: string; minutes: number } { const format = new Intl.DateTimeFormat("en-CA", { @@ -30,17 +28,3 @@ export function parseMinutes(time: string): number { const [hour, minute] = time.split(":").map(Number); return hour * 60 + minute; } - -/** timezone の壁時計での日付 ("YYYY-MM-DD") と時刻 ("HH:mm") が指す瞬間 */ -export function fromWallClock(date: string, time: string, timezone: string): Date { - const toWallClock = wallClockIn(timezone); - const target = Date.parse(`${date}T00:00:00.000Z`) + parseMinutes(time) * MS_PER_MINUTE; - // UTC として読んだ値から、その付近のオフセットを引く。オフセットが変わる境目をまたぐときのために 2 回合わせる - let instant = target; - for (let i = 0; i < 2; i++) { - const wall = toWallClock(new Date(instant)); - const wallAsUtc = Date.parse(`${wall.date}T00:00:00.000Z`) + wall.minutes * MS_PER_MINUTE; - instant += target - wallAsUtc; - } - return new Date(instant); -} diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 0ce271c..d89854d 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -4,7 +4,6 @@ import type { editReqSchema, projectReqSchema, submitReqSchema } from "../../../ import { type Db, insertMany, runBatch } from "../db/index.js"; import * as schema from "../db/schema.js"; import { nanoid } from "../lib/id.js"; -import { formatMinutes, fromWallClock, wallClockIn } from "../lib/wallClock.js"; import { ServiceError } from "./errors.js"; import { validateSlots } from "./slots.js"; @@ -17,28 +16,6 @@ function toIso(value: string | Date): string { return new Date(value).toISOString(); } -/** 時間帯は日付を持たず、timezone での時刻 ("HH:mm") で入れる */ -function toTimeOfDay(value: string, timezone: string): string { - return formatMinutes(wallClockIn(timezone)(new Date(value)).minutes); -} - -/** - * API では時間帯を ISO 8601 の日時で返す。Web UI が送るのと同じく、開始は初日、終了は最終日の日付にする。 - */ -function allowedRangesResponse( - project: { timezone: string; startDate: string; endDate: string }, - ranges: T[], -): T[] { - const toWallClock = wallClockIn(project.timezone); - const firstDate = toWallClock(new Date(project.startDate)).date; - const lastDate = toWallClock(new Date(project.endDate)).date; - return ranges.map((range) => ({ - ...range, - startTime: fromWallClock(firstDate, range.startTime, project.timezone).toISOString(), - endTime: fromWallClock(lastDate, range.endTime, project.timezone).toISOString(), - })); -} - export async function createProject(db: Db, browserId: string, input: CreateProjectInput) { const project = { id: nanoid(), @@ -46,7 +23,6 @@ export async function createProject(db: Db, browserId: string, input: CreateProj description: input.description.trim() || null, startDate: toIso(input.startDate), endDate: toIso(input.endDate), - timezone: schema.DEFAULT_TIMEZONE, }; await runBatch(db, [ @@ -54,11 +30,7 @@ export async function createProject(db: Db, browserId: string, input: CreateProj ...insertMany( db, schema.allowedRange, - input.allowedRanges.map((range) => ({ - startTime: toTimeOfDay(range.startTime, project.timezone), - endTime: toTimeOfDay(range.endTime, project.timezone), - projectId: project.id, - })), + input.allowedRanges.map((range) => ({ ...range, projectId: project.id })), ), db.insert(schema.host).values({ browserId, projectId: project.id }), ...insertMany( @@ -134,7 +106,7 @@ export async function getProject(db: Db, browserId: string, projectId: string) { description: project.description ?? "", startDate: project.startDate, endDate: project.endDate, - allowedRanges: allowedRangesResponse(project, project.allowedRanges), + allowedRanges: project.allowedRanges, participationOptions: project.participationOptions, hosts: project.hosts.map(omitBrowserId), guests: project.guests.map(omitBrowserId), @@ -149,20 +121,16 @@ function omitBrowserId(entity: T): Omit ({ - startTime: toTimeOfDay(r.startTime, project.timezone), - endTime: toTimeOfDay(r.endTime, project.timezone), - projectId, - })), + (input.allowedRanges ?? []).map((range) => ({ ...range, projectId })), ), ); } await runBatch(db, statements); - const updated = await db.query.project.findFirst({ + return db.query.project.findFirst({ where: eq(schema.project.id, projectId), - columns: { id: true, name: true, description: true, startDate: true, endDate: true, timezone: true }, + columns: { id: true, name: true, description: true, startDate: true, endDate: true }, with: { allowedRanges: true, participationOptions: true }, }); - if (!updated) return undefined; - const { timezone: _, ...rest } = updated; - return { ...rest, allowedRanges: allowedRangesResponse(updated, updated.allowedRanges) }; } export async function deleteProject(db: Db, browserId: string, projectId: string) { diff --git a/server/test/helpers.ts b/server/test/helpers.ts index 2f05f87..26f6504 100644 --- a/server/test/helpers.ts +++ b/server/test/helpers.ts @@ -36,7 +36,7 @@ export function createBrowser() { export type Browser = ReturnType; /** - * 2026/11/02〜11/04 (JST)、08:00〜23:00 のイベント。Web UI と同じく、日時は ISO 文字列 (UTC) で送る。 + * 2026/11/02〜11/04 (JST)、08:00〜23:00 のイベント。Web UI と同じく、日程は ISO 文字列 (UTC)、時間帯は "HH:mm" で送る。 */ export function projectInput(overrides: Record = {}) { return { @@ -44,7 +44,7 @@ export function projectInput(overrides: Record = {}) { description: "説明", startDate: "2026-11-01T15:00:00.000Z", endDate: "2026-11-04T14:59:59.999Z", - allowedRanges: [{ startTime: "2026-11-01T23:00:00.000Z", endTime: "2026-11-04T14:00:00.000Z" }], + allowedRanges: [{ startTime: "08:00", endTime: "23:00" }], participationOptions: [{ id: crypto.randomUUID(), label: "参加", color: "#0F82B1" }], ...overrides, }; diff --git a/server/test/projects.test.ts b/server/test/projects.test.ts index 587ed20..20d3c69 100644 --- a/server/test/projects.test.ts +++ b/server/test/projects.test.ts @@ -1,4 +1,3 @@ -import { env } from "cloudflare:workers"; import { describe, expect, test } from "vitest"; import { type Browser, createBrowser, createProject, projectInput, slotOnNov2, submissionInput } from "./helpers.js"; @@ -38,8 +37,8 @@ describe("POST /projects", () => { { id: expect.any(String), projectId: body.id, - startTime: "2026-11-01T23:00:00.000Z", - endTime: "2026-11-04T14:00:00.000Z", + startTime: "08:00", + endTime: "23:00", }, ], participationOptions: [{ ...input.participationOptions[0], projectId: body.id }], @@ -84,35 +83,44 @@ describe("POST /projects", () => { expect(res.status).toBe(400); }); - test("時間帯が ISO 8601 の日時でなければ 400", async () => { + test("時間帯が HH:mm の形式でなければ 400", async () => { const res = await createBrowser().post( "/projects", - projectInput({ allowedRanges: [{ startTime: "09:00", endTime: "18:00" }] }), + projectInput({ allowedRanges: [{ startTime: "2026-11-01T23:00:00.000Z", endTime: "2026-11-04T14:00:00.000Z" }] }), ); expect(res.status).toBe(400); }); - test("時間帯は時刻だけを保存し、開始は初日、終了は最終日の日付で返す", async () => { + test("時間帯が 15 分単位でなければ 400", async () => { + const res = await createBrowser().post( + "/projects", + projectInput({ allowedRanges: [{ startTime: "08:10", endTime: "23:00" }] }), + ); + + expect(res.status).toBe(400); + }); + + test("時間帯の開始が終了より後なら 400", async () => { + const res = await createBrowser().post( + "/projects", + projectInput({ allowedRanges: [{ startTime: "23:00", endTime: "08:00" }] }), + ); + + expect(res.status).toBe(400); + }); + + test("時間帯の終了は 24:00 にできる", async () => { const host = createBrowser(); - // 11/03 08:00〜23:00 (JST)。日付は使わない - const { id } = await createProject(host, { - allowedRanges: [{ startTime: "2026-11-02T23:00:00.000Z", endTime: "2026-11-03T14:00:00.000Z" }], - }); + const { id } = await createProject(host, { allowedRanges: [{ startTime: "00:00", endTime: "24:00" }] }); - const stored = await env.DB.prepare(`SELECT startTime, endTime FROM "AllowedRange" WHERE projectId = ?`) - .bind(id) - .first(); - expect(stored).toEqual({ startTime: "08:00", endTime: "23:00" }); - expect((await getProject(host, id)).allowedRanges).toMatchObject([ - { startTime: "2026-11-01T23:00:00.000Z", endTime: "2026-11-04T14:00:00.000Z" }, - ]); + expect((await getProject(host, id)).allowedRanges).toMatchObject([{ startTime: "00:00", endTime: "24:00" }]); }); - test("時間帯が 15 分単位でなければ 400", async () => { + test("24:00 より後の時刻は 400", async () => { const res = await createBrowser().post( "/projects", - projectInput({ allowedRanges: [{ startTime: "2026-11-01T23:10:00.000Z", endTime: "2026-11-04T14:00:00.000Z" }] }), + projectInput({ allowedRanges: [{ startTime: "08:00", endTime: "24:15" }] }), ); expect(res.status).toBe(400); @@ -247,11 +255,13 @@ describe("PUT /projects/:projectId", () => { expect(res.status).toBe(403); }); - test("時間帯が ISO 8601 の日時でなければ 400", async () => { + test("時間帯が HH:mm の形式でなければ 400", async () => { const host = createBrowser(); const { id } = await createProject(host); - const res = await host.put(`/projects/${id}`, { allowedRanges: [{ startTime: "09:00", endTime: "18:00" }] }); + const res = await host.put(`/projects/${id}`, { + allowedRanges: [{ startTime: "2026-11-10T00:00:00.000Z", endTime: "2026-11-10T09:00:00.000Z" }], + }); expect(res.status).toBe(400); }); @@ -265,7 +275,7 @@ describe("PUT /projects/:projectId", () => { description: " 新しい説明 ", startDate: "2026-11-09T15:00:00.000Z", endDate: "2026-11-10T14:59:59.999Z", - allowedRanges: [{ startTime: "2026-11-10T00:00:00.000Z", endTime: "2026-11-10T09:00:00.000Z" }], + allowedRanges: [{ startTime: "09:00", endTime: "18:00" }], }); expect(res.status).toBe(200); @@ -281,8 +291,8 @@ describe("PUT /projects/:projectId", () => { { id: expect.any(String), projectId: id, - startTime: "2026-11-10T00:00:00.000Z", - endTime: "2026-11-10T09:00:00.000Z", + startTime: "09:00", + endTime: "18:00", }, ], participationOptions: [{ id: expect.any(String), label: "参加", color: "#0F82B1", projectId: id }], @@ -298,7 +308,7 @@ describe("PUT /projects/:projectId", () => { name: "新しい名前", startDate: "2026-11-09T15:00:00.000Z", endDate: "2026-11-10T14:59:59.999Z", - allowedRanges: [{ startTime: "2026-11-10T00:00:00.000Z", endTime: "2026-11-10T09:00:00.000Z" }], + allowedRanges: [{ startTime: "09:00", endTime: "18:00" }], }); expect(res.status).toBe(200); @@ -307,7 +317,7 @@ describe("PUT /projects/:projectId", () => { name: "新しい名前", startDate: "2026-11-01T15:00:00.000Z", endDate: "2026-11-04T14:59:59.999Z", - allowedRanges: [{ startTime: "2026-11-01T23:00:00.000Z", endTime: "2026-11-04T14:00:00.000Z" }], + allowedRanges: [{ startTime: "08:00", endTime: "23:00" }], }); }); diff --git a/server/test/slots.test.ts b/server/test/slots.test.ts index 3441f47..2122a8b 100644 --- a/server/test/slots.test.ts +++ b/server/test/slots.test.ts @@ -124,6 +124,15 @@ describe("validateSlots", () => { ).not.toThrow(); }); + test("時間帯の終わりが 24:00 なら、24:00 に終わる Slot を通す", () => { + const untilMidnight = { ...project, allowedRanges: [{ startTime: "08:00", endTime: "24:00" }] }; + + // 11/02 23:00〜11/03 00:00 + expect(() => + validateSlots(untilMidnight, [slot("2026-11-02T14:00:00.000Z", "2026-11-02T15:00:00.000Z")]), + ).not.toThrow(); + }); + test("日付はイベントのタイムゾーンで判定する", () => { // JST では 11/03 08:30〜09:30 だが、UTC では 11/02 23:30〜11/03 00:30 で日をまたぐ const target = slot("2026-11-02T23:30:00.000Z", "2026-11-03T00:30:00.000Z"); diff --git a/server/test/wallClock.test.ts b/server/test/wallClock.test.ts deleted file mode 100644 index c6a78ba..0000000 --- a/server/test/wallClock.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { fromWallClock } from "../src/lib/wallClock.js"; - -describe("fromWallClock", () => { - test("timezone の壁時計の日時を UTC の瞬間にする", () => { - expect(fromWallClock("2026-11-02", "08:00", "Asia/Tokyo").toISOString()).toBe("2026-11-01T23:00:00.000Z"); - expect(fromWallClock("2026-11-02", "23:45", "Asia/Tokyo").toISOString()).toBe("2026-11-02T14:45:00.000Z"); - }); - - test("夏時間の有無でオフセットが変わる日も合わせる", () => { - // 2026/03/08 02:00 に EST (-5) から EDT (-4) に変わる - expect(fromWallClock("2026-03-08", "01:00", "America/New_York").toISOString()).toBe("2026-03-08T06:00:00.000Z"); - expect(fromWallClock("2026-03-08", "03:00", "America/New_York").toISOString()).toBe("2026-03-08T07:00:00.000Z"); - }); -}); From 87a8d448c3a2e92b13740f1bda5318216da549fa Mon Sep 17 00:00:00 2001 From: nakaterm <104970808+nakaterm@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:09:48 +0900 Subject: [PATCH 08/17] =?UTF-8?q?fix:=20=E3=82=A4=E3=83=99=E3=83=B3?= =?UTF-8?q?=E3=83=88=E3=81=AE=E6=9B=B4=E6=96=B0=E3=82=92=20PATCH=20?= =?UTF-8?q?=E3=81=AB=E3=81=97=E3=80=81=E7=9C=81=E7=95=A5=E3=81=97=E3=81=9F?= =?UTF-8?q?=E3=83=95=E3=82=A3=E3=83=BC=E3=83=AB=E3=83=89=E3=82=92=E5=A4=89?= =?UTF-8?q?=E3=81=88=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PUT /projects/:id は全項目を省略できるのに、省略したときの扱いが項目ごとに 違っていた (説明と時間帯は消え、名前・日程・参加形態は残る)。部分更新として PATCH にし、省略したものは変えず、空文字や空配列を指定したときだけ消す。 Co-Authored-By: Claude Opus 5.5 --- client/src/pages/Project.tsx | 2 +- server/src/routes/projects.ts | 4 +-- server/src/services/projects.ts | 36 +++++++++--------------- server/test/helpers.ts | 1 + server/test/projects.test.ts | 50 ++++++++++++++++++++++----------- 5 files changed, 51 insertions(+), 42 deletions(-) diff --git a/client/src/pages/Project.tsx b/client/src/pages/Project.tsx index 322a7e2..d284183 100644 --- a/client/src/pages/Project.tsx +++ b/client/src/pages/Project.tsx @@ -213,7 +213,7 @@ export default function ProjectPage() { setTimeout(() => setToast(null), 3000); } } else { - const res = await client.projects[":projectId"].$put( + const res = await client.projects[":projectId"].$patch( { param: { projectId: project.id }, json: eventData }, { init: { credentials: "include" } }, ); diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index ebeda19..b1e042f 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -35,8 +35,8 @@ const router = new Hono() return c.json(project, 200); }) - // プロジェクト編集 - .put("/:projectId", zValidator("param", projectIdParamsSchema), zValidator("json", editReqSchema), async (c) => { + // プロジェクト編集。省略したフィールドは変えない + .patch("/:projectId", zValidator("param", projectIdParamsSchema), zValidator("json", editReqSchema), async (c) => { const { projectId } = c.req.valid("param"); const event = await updateProject(c.get("db"), c.get("browserId"), projectId, c.req.valid("json")); return c.json({ event }, 200); diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index d89854d..c3eb32e 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -178,32 +178,24 @@ export async function updateProject(db: Db, browserId: string, projectId: string ); } - // ゲストの回答があれば、日程と時間帯は変えない - if (existingGuest) { - statements.push( - db - .update(schema.project) - .set({ name: input.name, description: input.description?.trim() || null }) - .where(eq(schema.project.id, projectId)), - ); - } else { + // 省略したフィールドは変えない。ゲストの回答があれば、日程と時間帯は変えない + const projectFields = { + name: input.name, + description: input.description === undefined ? undefined : input.description.trim() || null, + startDate: existingGuest || !input.startDate ? undefined : toIso(input.startDate), + endDate: existingGuest || !input.endDate ? undefined : toIso(input.endDate), + }; + // Drizzle は undefined の列を更新しないが、更新する列が 1 つも無いとエラーにする + if (Object.values(projectFields).some((value) => value !== undefined)) { + statements.push(db.update(schema.project).set(projectFields).where(eq(schema.project.id, projectId))); + } + if (!existingGuest && input.allowedRanges) { statements.push( - db - .update(schema.project) - .set({ - name: input.name, - description: input.description?.trim() || null, - startDate: input.startDate ? toIso(input.startDate) : undefined, - endDate: input.endDate ? toIso(input.endDate) : undefined, - }) - .where(eq(schema.project.id, projectId)), - db - .delete(schema.allowedRange) - .where(eq(schema.allowedRange.projectId, projectId)), // 既存削除 + db.delete(schema.allowedRange).where(eq(schema.allowedRange.projectId, projectId)), ...insertMany( db, schema.allowedRange, - (input.allowedRanges ?? []).map((range) => ({ ...range, projectId })), + input.allowedRanges.map((range) => ({ ...range, projectId })), ), ); } diff --git a/server/test/helpers.ts b/server/test/helpers.ts index 26f6504..9d9716c 100644 --- a/server/test/helpers.ts +++ b/server/test/helpers.ts @@ -29,6 +29,7 @@ export function createBrowser() { get: (path: string) => request(path), post: (path: string, body?: unknown) => send("POST", path, body), put: (path: string, body?: unknown) => send("PUT", path, body), + patch: (path: string, body?: unknown) => send("PATCH", path, body), delete: (path: string) => send("DELETE", path), }; } diff --git a/server/test/projects.test.ts b/server/test/projects.test.ts index 20d3c69..f2bf3a8 100644 --- a/server/test/projects.test.ts +++ b/server/test/projects.test.ts @@ -239,18 +239,18 @@ describe("GET /projects/:projectId", () => { }); }); -describe("PUT /projects/:projectId", () => { +describe("PATCH /projects/:projectId", () => { test("ホスト以外は 403", async () => { const { id } = await createProject(createBrowser()); - const res = await createBrowser().put(`/projects/${id}`, { name: "乗っ取り" }); + const res = await createBrowser().patch(`/projects/${id}`, { name: "乗っ取り" }); expect(res.status).toBe(403); expect(await res.json()).toEqual({ message: "アクセス権限がありません。" }); }); test("存在しないイベントは 403", async () => { - const res = await createBrowser().put(`/projects/${missingProjectId}`, { name: "x" }); + const res = await createBrowser().patch(`/projects/${missingProjectId}`, { name: "x" }); expect(res.status).toBe(403); }); @@ -259,7 +259,7 @@ describe("PUT /projects/:projectId", () => { const host = createBrowser(); const { id } = await createProject(host); - const res = await host.put(`/projects/${id}`, { + const res = await host.patch(`/projects/${id}`, { allowedRanges: [{ startTime: "2026-11-10T00:00:00.000Z", endTime: "2026-11-10T09:00:00.000Z" }], }); @@ -270,7 +270,7 @@ describe("PUT /projects/:projectId", () => { const host = createBrowser(); const { id } = await createProject(host); - const res = await host.put(`/projects/${id}`, { + const res = await host.patch(`/projects/${id}`, { name: "新しい名前", description: " 新しい説明 ", startDate: "2026-11-09T15:00:00.000Z", @@ -304,7 +304,7 @@ describe("PUT /projects/:projectId", () => { const { id, optionId } = await createProject(host); await submit(createBrowser(), id, optionId); - const res = await host.put(`/projects/${id}`, { + const res = await host.patch(`/projects/${id}`, { name: "新しい名前", startDate: "2026-11-09T15:00:00.000Z", endDate: "2026-11-10T14:59:59.999Z", @@ -321,24 +321,40 @@ describe("PUT /projects/:projectId", () => { }); }); - // 現状の挙動: 省略したフィールドは「変更しない」ではなく、消える - test("description を省略すると、説明が消える", async () => { + test("省略したフィールドは変えない", async () => { const host = createBrowser(); const { id } = await createProject(host); - await host.put(`/projects/${id}`, { name: "新しい名前" }); + const res = await host.patch(`/projects/${id}`, { name: "新しい名前" }); - expect((await getProject(host, id)).description).toBe(""); + expect(res.status).toBe(200); + expect(await getProject(host, id)).toMatchObject({ + name: "新しい名前", + description: "説明", + startDate: "2026-11-01T15:00:00.000Z", + endDate: "2026-11-04T14:59:59.999Z", + allowedRanges: [{ startTime: "08:00", endTime: "23:00" }], + participationOptions: [{ label: "参加" }], + }); + }); + + test("何も指定しなくても 200 で、何も変えない", async () => { + const host = createBrowser(); + const { id } = await createProject(host); + + const res = await host.patch(`/projects/${id}`, {}); + + expect(res.status).toBe(200); + expect(await getProject(host, id)).toMatchObject({ name: "テストイベント", description: "説明" }); }); - // 現状の挙動: 省略したフィールドは「変更しない」ではなく、消える - test("ゲストがいないときに allowedRanges を省略すると、時間帯が消える", async () => { + test("空文字の説明と空の時間帯を指定すると、消える", async () => { const host = createBrowser(); const { id } = await createProject(host); - await host.put(`/projects/${id}`, { name: "新しい名前" }); + await host.patch(`/projects/${id}`, { description: "", allowedRanges: [] }); - expect((await getProject(host, id)).allowedRanges).toEqual([]); + expect(await getProject(host, id)).toMatchObject({ description: "", allowedRanges: [] }); }); test("参加形態を追加・変更・削除できる", async () => { @@ -354,7 +370,7 @@ describe("PUT /projects/:projectId", () => { ); const added = { id: crypto.randomUUID(), label: "オンライン", color: "#FF0000" }; - const res = await host.put(`/projects/${id}`, { + const res = await host.patch(`/projects/${id}`, { participationOptions: [{ id: kept.id, label: "対面", color: "#00FF00" }, added], }); @@ -371,7 +387,7 @@ describe("PUT /projects/:projectId", () => { const { id, optionId } = await createProject(host); await submit(createBrowser(), id, optionId); - const res = await host.put(`/projects/${id}`, { + const res = await host.patch(`/projects/${id}`, { participationOptions: [{ id: crypto.randomUUID(), label: "別", color: "#000000" }], }); @@ -384,7 +400,7 @@ describe("PUT /projects/:projectId", () => { const host = createBrowser(); const { id } = await createProject(host); - const res = await host.put(`/projects/${id}`, { participationOptions: [] }); + const res = await host.patch(`/projects/${id}`, { participationOptions: [] }); expect(res.status).toBe(400); }); From ec79425a50572efe334287f2d353f7b591a00bea Mon Sep 17 00:00:00 2001 From: nakaterm <104970808+nakaterm@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:13:39 +0900 Subject: [PATCH 09/17] =?UTF-8?q?fix:=20=E3=82=A4=E3=83=99=E3=83=B3?= =?UTF-8?q?=E3=83=88=E3=81=AE=E5=89=8A=E9=99=A4=E3=81=AF=20204=20=E3=82=92?= =?UTF-8?q?=E6=9C=AC=E6=96=87=E3=81=AA=E3=81=97=E3=81=A7=E8=BF=94=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit c.json(204) としていたため、ステータスは 200 で本文が 204 という数値になっていた。 Co-Authored-By: Claude Opus 5.5 --- server/src/routes/projects.ts | 2 +- server/test/projects.test.ts | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index b1e042f..dcb6222 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -46,7 +46,7 @@ const router = new Hono() .delete("/:projectId", zValidator("param", projectIdParamsSchema), async (c) => { const { projectId } = c.req.valid("param"); await deleteProject(c.get("db"), c.get("browserId"), projectId); - return c.json(204); + return c.body(null, 204); }) // 日程の提出。 diff --git a/server/test/projects.test.ts b/server/test/projects.test.ts index f2bf3a8..fe6191c 100644 --- a/server/test/projects.test.ts +++ b/server/test/projects.test.ts @@ -416,16 +416,15 @@ describe("DELETE /projects/:projectId", () => { expect(await res.json()).toEqual({ message: "削除権限がありません。" }); }); - // 現状の挙動: `c.json(204)` なので、ステータスは 200 で本文が 204 になる - test("ホストは、ゲストの回答ごと削除できる", async () => { + test("ホストは、ゲストの回答ごと削除できる。204 で本文は無い", async () => { const host = createBrowser(); const { id, optionId } = await createProject(host); await submit(createBrowser(), id, optionId); const res = await host.delete(`/projects/${id}`); - expect(res.status).toBe(200); - expect(await res.json()).toBe(204); + expect(res.status).toBe(204); + expect(await res.text()).toBe(""); expect((await host.get(`/projects/${id}`)).status).toBe(404); }); }); From 73bbed21277160e4865427909bd97cb76d666aff Mon Sep 17 00:00:00 2001 From: nakaterm <104970808+nakaterm@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:18:17 +0900 Subject: [PATCH 10/17] =?UTF-8?q?refactor:=20=E3=82=A4=E3=83=99=E3=83=B3?= =?UTF-8?q?=E3=83=88=E3=81=AE=E6=97=A5=E7=A8=8B=E3=82=92=20"YYYY-MM-DD"=20?= =?UTF-8?q?=E3=81=A7=E4=BF=9D=E5=AD=98=E3=81=97=E3=80=81=E3=81=9D=E3=81=AE?= =?UTF-8?q?=E3=81=BE=E3=81=BE=E5=8F=97=E3=81=91=E5=8F=96=E3=81=A3=E3=81=A6?= =?UTF-8?q?=E8=BF=94=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 日程は日付しか使わないのに、DB と API では開始日の 0:00 と終了日の 23:59:59.999 を UTC の日時にしてやりとりしていた。DB も API もイベントのタイムゾーンでの日付にし、 既存の値は Asia/Tokyo の日付に変換する。どのタイムゾーンでの値かが分かるよう、 レスポンスに timezone を含める。フォームと API で入力の形式が同じになったので、 検証のスキーマも 1 つにまとめる。 Co-Authored-By: Claude Opus 5.5 --- client/src/pages/Project.tsx | 15 +- client/src/revivers.ts | 10 +- client/src/types.ts | 14 +- common/validators.ts | 86 ++--- server/migrations/0003_project_dates.sql | 6 + server/migrations/meta/0003_snapshot.json | 400 ++++++++++++++++++++++ server/migrations/meta/_journal.json | 7 + server/src/db/schema.ts | 4 +- server/src/services/projects.ts | 14 +- server/src/services/slots.ts | 12 +- server/test/helpers.ts | 6 +- server/test/migrations.test.ts | 27 +- server/test/projects.test.ts | 41 ++- server/test/slots.test.ts | 4 +- 14 files changed, 537 insertions(+), 109 deletions(-) create mode 100644 server/migrations/0003_project_dates.sql create mode 100644 server/migrations/meta/0003_snapshot.json diff --git a/client/src/pages/Project.tsx b/client/src/pages/Project.tsx index d284183..a3d2212 100644 --- a/client/src/pages/Project.tsx +++ b/client/src/pages/Project.tsx @@ -17,7 +17,7 @@ import { import { NavLink, useNavigate, useParams } from "react-router"; import type { z } from "zod"; import { DEFAULT_PARTICIPATION_OPTION, generateDistinctColor } from "../../../common/colors"; -import { editFormSchema, projectFormSchema, type projectReqSchema } from "../../../common/validators"; +import { editReqSchema, projectReqSchema } from "../../../common/validators"; import type { AppType } from "../../../server/src/main"; import Header from "../components/Header"; import { EXTERNAL_LINKS } from "../constants/links"; @@ -32,7 +32,7 @@ export default function ProjectPage() { const { eventId } = useParams(); const navigate = useNavigate(); - const formSchema = eventId ? editFormSchema : projectFormSchema; + const formSchema = eventId ? editReqSchema : projectReqSchema; type FormSchemaType = z.infer; const [project, setProject] = useState(null); @@ -154,8 +154,8 @@ export default function ProjectPage() { reset({ name: project.name, description: project.description, - startDate: project.startDate.tz().format("YYYY-MM-DD"), - endDate: project.endDate.tz().format("YYYY-MM-DD"), + startDate: project.startDate.format("YYYY-MM-DD"), + endDate: project.endDate.format("YYYY-MM-DD"), allowedRanges: [ // 時間帯が無いイベントは終日とみなす range ? { startTime: range.startTime, endTime: range.endTime } : { startTime: "00:00", endTime: "24:00" }, @@ -172,14 +172,11 @@ export default function ProjectPage() { const onSubmit = async (data: FormSchemaType) => { setSubmitLoading(true); - const startDtISO = dayjs.tz(data.startDate).startOf("day").toISOString(); - const endDtISO = dayjs.tz(data.endDate).endOf("day").toISOString(); - const eventData = { name: data.name ?? "", description: data.description ?? "", - startDate: startDtISO, - endDate: endDtISO, + startDate: data.startDate ?? "", + endDate: data.endDate ?? "", allowedRanges: data.allowedRanges ?? [], participationOptions: (data.participationOptions ?? []).map((opt) => ({ id: opt.id, diff --git a/client/src/revivers.ts b/client/src/revivers.ts index 83be79c..be8860c 100644 --- a/client/src/revivers.ts +++ b/client/src/revivers.ts @@ -1,4 +1,4 @@ -// レスポンスの ISO 8601 文字列を Date に変換 +// レスポンスの日付・日時の文字列を Dayjs に変換 import dayjs from "./lib/dayjs"; import type { BriefProject, ISOStringBriefProject, ISOStringProject, Project } from "./types"; @@ -11,8 +11,8 @@ import type { BriefProject, ISOStringBriefProject, ISOStringProject, Project } f export function projectReviver(project: ISOStringProject): Project { return { ...project, - startDate: dayjs.utc(project.startDate).tz(), - endDate: dayjs.utc(project.endDate).tz(), + startDate: dayjs.tz(project.startDate), + endDate: dayjs.tz(project.endDate), allowedRanges: project.allowedRanges.map((range) => ({ ...range })), participationOptions: project.participationOptions.map((opt) => ({ ...opt })), hosts: project.hosts.map((host) => ({ ...host })), @@ -47,7 +47,7 @@ export function projectReviver(project: ISOStringProject): Project { export function briefProjectReviver(project: ISOStringBriefProject): BriefProject { return { ...project, - startDate: dayjs.utc(project.startDate).tz(), - endDate: dayjs.utc(project.endDate).tz(), + startDate: dayjs.tz(project.startDate), + endDate: dayjs.tz(project.endDate), }; } diff --git a/client/src/types.ts b/client/src/types.ts index dc83c1d..75701e2 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -42,8 +42,12 @@ export type Project = { id: string; name: string; description: string; + /** その日の 0:00 (イベントのタイムゾーン) */ startDate: Dayjs; + /** その日の 0:00 (イベントのタイムゾーン)。この日を含む */ endDate: Dayjs; + /** IANA のタイムゾーン名 */ + timezone: string; allowedRanges: AllowedRange[]; participationOptions: ParticipationOption[]; hosts: Host[]; @@ -57,8 +61,11 @@ export type ISOStringProject = { id: string; name: string; description: string; + /** "YYYY-MM-DD" */ startDate: string; + /** "YYYY-MM-DD" */ endDate: string; + timezone: string; allowedRanges: { id: string; projectId: string; @@ -103,5 +110,8 @@ export type ISOStringProject = { } | null; }; -export type BriefProject = Pick; -export type ISOStringBriefProject = Pick; +export type BriefProject = Pick; +export type ISOStringBriefProject = Pick< + ISOStringProject, + "id" | "name" | "startDate" | "endDate" | "timezone" | "isHost" +>; diff --git a/common/validators.ts b/common/validators.ts index aaa401e..396f915 100644 --- a/common/validators.ts +++ b/common/validators.ts @@ -44,56 +44,40 @@ const isQuarterHour = (time: string): boolean => { }; /** - * イベントの作成・編集の入力。フォームと API で、日付の形式だけが異なる。 - * フォームは "YYYY-MM-DD"、API は ISO 8601 の日時で受け取る (Web UI が変換して送る)。 - * 時間帯はどちらも、イベントのタイムゾーンでの "HH:mm" で受け取る。 + * イベントの作成・編集の入力。Web UI のフォームと API で共通。 + * 日程は "YYYY-MM-DD"、時間帯は "HH:mm" で、どちらもイベントのタイムゾーンの壁時計で解釈する。 */ -function baseProjectSchema({ startDate, endDate }: { startDate: z.ZodString; endDate: z.ZodString }) { - return z.object({ - name: z.string().min(1, "イベント名を入力してください").max(100, "イベント名は100文字以内で入力してください"), - description: z.string().max(1000, "説明は1000文字以内で入力してください"), - startDate, - // TODO: 新規作成時のみ、過去日付を制限する必要 - // .refine( - // (startDate) => { - // const inputDate = new Date(startDate); - // const today = new Date(); - // today.setHours(0, 0, 0, 0); - // return inputDate >= today; - // }, - // { - // message: "過去の日付は指定できません", - // }, - // ), - endDate, - allowedRanges: z - .array( - z.object({ - startTime, - endTime, - }), - ) - .refine((ranges) => ranges.every(({ startTime, endTime }) => startTime < endTime), { - message: "開始時刻は終了時刻より前でなければなりません", - }) - .refine( - (ranges) => ranges.every(({ startTime, endTime }) => isQuarterHour(startTime) && isQuarterHour(endTime)), - { - message: "開始時刻と終了時刻は15分単位で入力してください", - }, - ), - participationOptions: z.array(participationOptionCreateSchema).min(1, "参加形態は最低1つ必要です"), - }); -} - -const baseProjectFormSchema = baseProjectSchema({ +const baseProjectSchema = z.object({ + name: z.string().min(1, "イベント名を入力してください").max(100, "イベント名は100文字以内で入力してください"), + description: z.string().max(1000, "説明は1000文字以内で入力してください"), startDate: z.string().min(1, "開始日を入力してください").date("開始日の形式が正しくありません"), + // TODO: 新規作成時のみ、過去日付を制限する必要 + // .refine( + // (startDate) => { + // const inputDate = new Date(startDate); + // const today = new Date(); + // today.setHours(0, 0, 0, 0); + // return inputDate >= today; + // }, + // { + // message: "過去の日付は指定できません", + // }, + // ), endDate: z.string().min(1, "終了日を入力してください").date("終了日の形式が正しくありません"), -}); - -const baseProjectReqSchema = baseProjectSchema({ - startDate: z.string().datetime(), - endDate: z.string().datetime(), + allowedRanges: z + .array( + z.object({ + startTime, + endTime, + }), + ) + .refine((ranges) => ranges.every(({ startTime, endTime }) => startTime < endTime), { + message: "開始時刻は終了時刻より前でなければなりません", + }) + .refine((ranges) => ranges.every(({ startTime, endTime }) => isQuarterHour(startTime) && isQuarterHour(endTime)), { + message: "開始時刻と終了時刻は15分単位で入力してください", + }), + participationOptions: z.array(participationOptionCreateSchema).min(1, "参加形態は最低1つ必要です"), }); const isDateOrdered = (data: { startDate?: string; endDate?: string }) => { @@ -108,10 +92,6 @@ const dateOrderError = { path: ["endDate"], }; -export const projectFormSchema = baseProjectFormSchema.refine(isDateOrdered, dateOrderError); - -export const editFormSchema = baseProjectFormSchema.partial().refine(isDateOrdered, dateOrderError); - -export const projectReqSchema = baseProjectReqSchema.refine(isDateOrdered, dateOrderError); +export const projectReqSchema = baseProjectSchema.refine(isDateOrdered, dateOrderError); -export const editReqSchema = baseProjectReqSchema.partial().refine(isDateOrdered, dateOrderError); +export const editReqSchema = baseProjectSchema.partial().refine(isDateOrdered, dateOrderError); diff --git a/server/migrations/0003_project_dates.sql b/server/migrations/0003_project_dates.sql new file mode 100644 index 0000000..5310767 --- /dev/null +++ b/server/migrations/0003_project_dates.sql @@ -0,0 +1,6 @@ +-- 日程を ISO 8601 の日時から、イベントのタイムゾーンでの日付 "YYYY-MM-DD" にする。 +-- 既存のイベントはすべて Asia/Tokyo (0001 で埋めた値) なので、+9 時間で変換する。 +-- 終了日は "T14:59:59.999Z" (JST のその日の終わり) なので、同じ日になる +UPDATE `Project` SET + `startDate` = date(`startDate`, '+9 hours'), + `endDate` = date(`endDate`, '+9 hours'); diff --git a/server/migrations/meta/0003_snapshot.json b/server/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..606cec1 --- /dev/null +++ b/server/migrations/meta/0003_snapshot.json @@ -0,0 +1,400 @@ +{ + "id": "ecc1a63a-5e09-404c-960b-be5e99a01778", + "prevId": "ea2b5fd9-7b9f-4c3e-825e-d63e2309f7cb", + "version": "6", + "dialect": "sqlite", + "tables": { + "AllowedRange": { + "name": "AllowedRange", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "startTime": { + "name": "startTime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endTime": { + "name": "endTime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "AllowedRange_projectId_idx": { + "name": "AllowedRange_projectId_idx", + "columns": ["projectId"], + "isUnique": false + } + }, + "foreignKeys": { + "AllowedRange_projectId_Project_id_fk": { + "name": "AllowedRange_projectId_Project_id_fk", + "tableFrom": "AllowedRange", + "columnsFrom": ["projectId"], + "tableTo": "Project", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Guest": { + "name": "Guest", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "browserId": { + "name": "browserId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Guest_browserId_projectId_key": { + "name": "Guest_browserId_projectId_key", + "columns": ["browserId", "projectId"], + "isUnique": true + }, + "Guest_projectId_idx": { + "name": "Guest_projectId_idx", + "columns": ["projectId"], + "isUnique": false + } + }, + "foreignKeys": { + "Guest_projectId_Project_id_fk": { + "name": "Guest_projectId_Project_id_fk", + "tableFrom": "Guest", + "columnsFrom": ["projectId"], + "tableTo": "Project", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Host": { + "name": "Host", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "browserId": { + "name": "browserId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Host_browserId_projectId_key": { + "name": "Host_browserId_projectId_key", + "columns": ["browserId", "projectId"], + "isUnique": true + }, + "Host_projectId_idx": { + "name": "Host_projectId_idx", + "columns": ["projectId"], + "isUnique": false + } + }, + "foreignKeys": { + "Host_projectId_Project_id_fk": { + "name": "Host_projectId_Project_id_fk", + "tableFrom": "Host", + "columnsFrom": ["projectId"], + "tableTo": "Project", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ParticipationOption": { + "name": "ParticipationOption", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ParticipationOption_projectId_label_key": { + "name": "ParticipationOption_projectId_label_key", + "columns": ["projectId", "label"], + "isUnique": true + } + }, + "foreignKeys": { + "ParticipationOption_projectId_Project_id_fk": { + "name": "ParticipationOption_projectId_Project_id_fk", + "tableFrom": "ParticipationOption", + "columnsFrom": ["projectId"], + "tableTo": "Project", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Project": { + "name": "Project", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startDate": { + "name": "startDate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endDate": { + "name": "endDate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Asia/Tokyo'" + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Slot": { + "name": "Slot", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "from": { + "name": "from", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to": { + "name": "to", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "participationOptionId": { + "name": "participationOptionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Slot_projectId_idx": { + "name": "Slot_projectId_idx", + "columns": ["projectId"], + "isUnique": false + }, + "Slot_guestId_idx": { + "name": "Slot_guestId_idx", + "columns": ["guestId"], + "isUnique": false + }, + "Slot_participationOptionId_idx": { + "name": "Slot_participationOptionId_idx", + "columns": ["participationOptionId"], + "isUnique": false + } + }, + "foreignKeys": { + "Slot_projectId_Project_id_fk": { + "name": "Slot_projectId_Project_id_fk", + "tableFrom": "Slot", + "columnsFrom": ["projectId"], + "tableTo": "Project", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "cascade" + }, + "Slot_guestId_Guest_id_fk": { + "name": "Slot_guestId_Guest_id_fk", + "tableFrom": "Slot", + "columnsFrom": ["guestId"], + "tableTo": "Guest", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "no action" + }, + "Slot_participationOptionId_ParticipationOption_id_fk": { + "name": "Slot_participationOptionId_ParticipationOption_id_fk", + "tableFrom": "Slot", + "columnsFrom": ["participationOptionId"], + "tableTo": "ParticipationOption", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/server/migrations/meta/_journal.json b/server/migrations/meta/_journal.json index b7170d6..f2019a2 100644 --- a/server/migrations/meta/_journal.json +++ b/server/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1792693843461, "tag": "0002_allowed_range_time_of_day", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1792696463507, + "tag": "0003_project_dates", + "breakpoints": true } ] } diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index f051feb..a2b5651 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -8,9 +8,9 @@ export const project = sqliteTable("Project", { id: text().primaryKey(), name: text().notNull(), description: text(), - /** 注: 日付部分のみ利用。時間は考慮しない。 */ + /** イベントのタイムゾーンでの日付 ("YYYY-MM-DD") */ startDate: text().notNull(), - /** 注: 日付部分のみ利用。時間は考慮しない。 */ + /** イベントのタイムゾーンでの日付 ("YYYY-MM-DD")。この日を含む */ endDate: text().notNull(), /** 日付と時間帯は、このタイムゾーンの壁時計で解釈する (IANA のタイムゾーン名) */ timezone: text().notNull().default("Asia/Tokyo"), diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index c3eb32e..9d86300 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -21,8 +21,8 @@ export async function createProject(db: Db, browserId: string, input: CreateProj id: nanoid(), name: input.name, description: input.description.trim() || null, - startDate: toIso(input.startDate), - endDate: toIso(input.endDate), + startDate: input.startDate, + endDate: input.endDate, }; await runBatch(db, [ @@ -74,6 +74,7 @@ export async function listMyProjects(db: Db, browserId: string) { description: p.description ?? "", startDate: p.startDate, endDate: p.endDate, + timezone: p.timezone, isHost: p.hosts.some((host) => host.browserId === browserId), })); } @@ -106,6 +107,7 @@ export async function getProject(db: Db, browserId: string, projectId: string) { description: project.description ?? "", startDate: project.startDate, endDate: project.endDate, + timezone: project.timezone, allowedRanges: project.allowedRanges, participationOptions: project.participationOptions, hosts: project.hosts.map(omitBrowserId), @@ -182,8 +184,8 @@ export async function updateProject(db: Db, browserId: string, projectId: string const projectFields = { name: input.name, description: input.description === undefined ? undefined : input.description.trim() || null, - startDate: existingGuest || !input.startDate ? undefined : toIso(input.startDate), - endDate: existingGuest || !input.endDate ? undefined : toIso(input.endDate), + startDate: existingGuest ? undefined : input.startDate, + endDate: existingGuest ? undefined : input.endDate, }; // Drizzle は undefined の列を更新しないが、更新する列が 1 つも無いとエラーにする if (Object.values(projectFields).some((value) => value !== undefined)) { @@ -236,8 +238,8 @@ async function findProjectForSlots(db: Db, projectId: string) { } return { timezone: project.timezone, - startDate: new Date(project.startDate), - endDate: new Date(project.endDate), + startDate: project.startDate, + endDate: project.endDate, allowedRanges: project.allowedRanges, participationOptions: project.participationOptions, }; diff --git a/server/src/services/slots.ts b/server/src/services/slots.ts index 131310d..63e5595 100644 --- a/server/src/services/slots.ts +++ b/server/src/services/slots.ts @@ -13,8 +13,10 @@ type SlotInput = { start: Date; end: Date; participationOptionId: string }; type Project = { /** 日付と時間帯は、このタイムゾーンの壁時計で解釈する */ timezone: string; - startDate: Date; - endDate: Date; + /** "YYYY-MM-DD" */ + startDate: string; + /** "YYYY-MM-DD"。この日を含む */ + endDate: string; /** 時刻は "HH:mm" */ allowedRanges: { startTime: string; endTime: string }[]; participationOptions: { id: string }[]; @@ -26,8 +28,6 @@ type Project = { */ export function validateSlots(project: Project, slots: SlotInput[]): void { const toWallClock = wallClockIn(project.timezone); - const firstDate = toWallClock(project.startDate).date; - const lastDate = toWallClock(project.endDate).date; // 時間帯は現在 1 つだけ。無ければ終日とみなす const range = project.allowedRanges[0]; const rangeStart = range ? parseMinutes(range.startTime) : 0; @@ -51,8 +51,8 @@ export function validateSlots(project: Project, slots: SlotInput[]): void { if (lastInstantDate !== start.date) { throw invalid("日をまたいでいます。日付ごとに分けてください。"); } - if (start.date < firstDate || start.date > lastDate) { - throw invalid(`イベントの日程の範囲 (${firstDate}〜${lastDate}) の外です。`); + if (start.date < project.startDate || start.date > project.endDate) { + throw invalid(`イベントの日程の範囲 (${project.startDate}〜${project.endDate}) の外です。`); } const endMinutes = end.date === start.date ? end.minutes : MINUTES_PER_DAY; if (start.minutes < rangeStart || endMinutes > rangeEnd) { diff --git a/server/test/helpers.ts b/server/test/helpers.ts index 9d9716c..2a407b0 100644 --- a/server/test/helpers.ts +++ b/server/test/helpers.ts @@ -37,14 +37,14 @@ export function createBrowser() { export type Browser = ReturnType; /** - * 2026/11/02〜11/04 (JST)、08:00〜23:00 のイベント。Web UI と同じく、日程は ISO 文字列 (UTC)、時間帯は "HH:mm" で送る。 + * 2026/11/02〜11/04 (JST)、08:00〜23:00 のイベント。 */ export function projectInput(overrides: Record = {}) { return { name: "テストイベント", description: "説明", - startDate: "2026-11-01T15:00:00.000Z", - endDate: "2026-11-04T14:59:59.999Z", + startDate: "2026-11-02", + endDate: "2026-11-04", allowedRanges: [{ startTime: "08:00", endTime: "23:00" }], participationOptions: [{ id: crypto.randomUUID(), label: "参加", color: "#0F82B1" }], ...overrides, diff --git a/server/test/migrations.test.ts b/server/test/migrations.test.ts index ba7bae0..080f37f 100644 --- a/server/test/migrations.test.ts +++ b/server/test/migrations.test.ts @@ -29,7 +29,9 @@ describe("0001_add_project_timezone_created_at", () => { test("既存のイベントは Asia/Tokyo になり、作成日時はマイグレーションを当てた時刻で埋まる", async () => { const { db, migrate } = await migrateUntil("0001_add_project_timezone_created_at.sql"); await db.batch([ - db.prepare(`INSERT INTO "Project" VALUES ('p', 'name', NULL, 's', 'e')`), + db.prepare( + `INSERT INTO "Project" VALUES ('p', 'name', NULL, '2026-11-01T15:00:00.000Z', '2026-11-04T14:59:59.999Z')`, + ), db.prepare( `INSERT INTO "AllowedRange" VALUES ('a', '2026-11-01T23:00:00.000Z', '2026-11-04T14:00:00.000Z', 'p')`, ), @@ -53,7 +55,9 @@ describe("0002_allowed_range_time_of_day", () => { test("時間帯の時刻を JST の HH:mm にする", async () => { const { db, migrate } = await migrateUntil("0002_allowed_range_time_of_day.sql"); await db.batch([ - db.prepare(`INSERT INTO "Project" (id, name, startDate, endDate) VALUES ('p', 'name', 's', 'e')`), + db.prepare( + `INSERT INTO "Project" (id, name, startDate, endDate) VALUES ('p', 'name', '2026-11-01T15:00:00.000Z', '2026-11-04T14:59:59.999Z')`, + ), // 11/02 08:00〜11/04 23:00 (JST)。UTC では日付が変わる時刻も含める db.prepare( `INSERT INTO "AllowedRange" VALUES ('a', '2026-11-01T23:00:00.000Z', '2026-11-04T14:00:00.000Z', 'p')`, @@ -68,3 +72,22 @@ describe("0002_allowed_range_time_of_day", () => { }); }); }); + +describe("0003_project_dates", () => { + test("日程を JST の YYYY-MM-DD にする", async () => { + const { db, migrate } = await migrateUntil("0003_project_dates.sql"); + // 11/02〜11/04 (JST)。開始は UTC では前日、終了は UTC でも同じ日 + await db + .prepare( + `INSERT INTO "Project" (id, name, startDate, endDate) VALUES ('p', 'name', '2026-11-01T15:00:00.000Z', '2026-11-04T14:59:59.999Z')`, + ) + .run(); + + await migrate(); + + expect(await db.prepare(`SELECT startDate, endDate FROM "Project"`).first()).toEqual({ + startDate: "2026-11-02", + endDate: "2026-11-04", + }); + }); +}); diff --git a/server/test/projects.test.ts b/server/test/projects.test.ts index fe6191c..4f5aaa0 100644 --- a/server/test/projects.test.ts +++ b/server/test/projects.test.ts @@ -31,8 +31,9 @@ describe("POST /projects", () => { id: body.id, name: "テストイベント", description: "説明", - startDate: "2026-11-01T15:00:00.000Z", - endDate: "2026-11-04T14:59:59.999Z", + startDate: "2026-11-02", + endDate: "2026-11-04", + timezone: "Asia/Tokyo", allowedRanges: [ { id: expect.any(String), @@ -65,7 +66,7 @@ describe("POST /projects", () => { test("開始日が終了日より後なら 400", async () => { const res = await createBrowser().post( "/projects", - projectInput({ startDate: "2026-11-05T15:00:00.000Z", endDate: "2026-11-04T14:59:59.999Z" }), + projectInput({ startDate: "2026-11-06", endDate: "2026-11-04" }), ); expect(res.status).toBe(400); @@ -77,8 +78,8 @@ describe("POST /projects", () => { expect(res.status).toBe(400); }); - test("日付が ISO 8601 の日時でなければ 400", async () => { - const res = await createBrowser().post("/projects", projectInput({ startDate: "2026-11-02" })); + test("日付が YYYY-MM-DD の形式でなければ 400", async () => { + const res = await createBrowser().post("/projects", projectInput({ startDate: "2026-11-01T15:00:00.000Z" })); expect(res.status).toBe(400); }); @@ -145,16 +146,18 @@ describe("GET /projects/mine", () => { id: hosted.id, name: "主催", description: "説明", - startDate: "2026-11-01T15:00:00.000Z", - endDate: "2026-11-04T14:59:59.999Z", + startDate: "2026-11-02", + endDate: "2026-11-04", + timezone: "Asia/Tokyo", isHost: true, }, { id: joined.id, name: "参加", description: "説明", - startDate: "2026-11-01T15:00:00.000Z", - endDate: "2026-11-04T14:59:59.999Z", + startDate: "2026-11-02", + endDate: "2026-11-04", + timezone: "Asia/Tokyo", isHost: false, }, ]); @@ -273,8 +276,8 @@ describe("PATCH /projects/:projectId", () => { const res = await host.patch(`/projects/${id}`, { name: "新しい名前", description: " 新しい説明 ", - startDate: "2026-11-09T15:00:00.000Z", - endDate: "2026-11-10T14:59:59.999Z", + startDate: "2026-11-10", + endDate: "2026-11-10", allowedRanges: [{ startTime: "09:00", endTime: "18:00" }], }); @@ -285,8 +288,8 @@ describe("PATCH /projects/:projectId", () => { id, name: "新しい名前", description: "新しい説明", - startDate: "2026-11-09T15:00:00.000Z", - endDate: "2026-11-10T14:59:59.999Z", + startDate: "2026-11-10", + endDate: "2026-11-10", allowedRanges: [ { id: expect.any(String), @@ -306,8 +309,8 @@ describe("PATCH /projects/:projectId", () => { const res = await host.patch(`/projects/${id}`, { name: "新しい名前", - startDate: "2026-11-09T15:00:00.000Z", - endDate: "2026-11-10T14:59:59.999Z", + startDate: "2026-11-10", + endDate: "2026-11-10", allowedRanges: [{ startTime: "09:00", endTime: "18:00" }], }); @@ -315,8 +318,8 @@ describe("PATCH /projects/:projectId", () => { const project = await getProject(host, id); expect(project).toMatchObject({ name: "新しい名前", - startDate: "2026-11-01T15:00:00.000Z", - endDate: "2026-11-04T14:59:59.999Z", + startDate: "2026-11-02", + endDate: "2026-11-04", allowedRanges: [{ startTime: "08:00", endTime: "23:00" }], }); }); @@ -331,8 +334,8 @@ describe("PATCH /projects/:projectId", () => { expect(await getProject(host, id)).toMatchObject({ name: "新しい名前", description: "説明", - startDate: "2026-11-01T15:00:00.000Z", - endDate: "2026-11-04T14:59:59.999Z", + startDate: "2026-11-02", + endDate: "2026-11-04", allowedRanges: [{ startTime: "08:00", endTime: "23:00" }], participationOptions: [{ label: "参加" }], }); diff --git a/server/test/slots.test.ts b/server/test/slots.test.ts index 2122a8b..5e5937b 100644 --- a/server/test/slots.test.ts +++ b/server/test/slots.test.ts @@ -5,8 +5,8 @@ import { validateSlots } from "../src/services/slots.js"; // 2026/11/02〜11/04 (JST)、08:00〜23:00 のイベント。時刻はコメントに JST で書く const project = { timezone: "Asia/Tokyo", - startDate: new Date("2026-11-01T15:00:00.000Z"), - endDate: new Date("2026-11-04T14:59:59.999Z"), + startDate: "2026-11-02", + endDate: "2026-11-04", allowedRanges: [{ startTime: "08:00", endTime: "23:00" }], participationOptions: [{ id: "option" }], }; From 8017cee2ddab4606139cd6253950cc23d8f42c45 Mon Sep 17 00:00:00 2001 From: nakaterm <104970808+nakaterm@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:21:22 +0900 Subject: [PATCH 11/17] =?UTF-8?q?refactor(db):=20=E3=83=86=E3=83=BC?= =?UTF-8?q?=E3=83=96=E3=83=AB=E5=90=8D=E3=82=92=E5=B0=8F=E6=96=87=E5=AD=97?= =?UTF-8?q?=E3=81=AE=E5=8D=98=E6=95=B0=E5=BD=A2=E3=83=BBcamelCase=20?= =?UTF-8?q?=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project → event、AllowedRange → allowedRange、ParticipationOption → option、 Slot → slot。Host と Guest は participant にまとめるときに消すので変えない。 SQLite のテーブル名は大文字小文字を区別せず、大文字小文字だけ違う名前には 直接変えられないので、別の名前を経由する。 Co-Authored-By: Claude Opus 5.5 --- server/migrations/0004_rename_tables.sql | 9 + server/migrations/meta/0004_snapshot.json | 400 ++++++++++++++++++++++ server/migrations/meta/_journal.json | 7 + server/src/db/schema.ts | 8 +- server/test/migrations.test.ts | 51 ++- server/test/setup.ts | 2 +- 6 files changed, 465 insertions(+), 12 deletions(-) create mode 100644 server/migrations/0004_rename_tables.sql create mode 100644 server/migrations/meta/0004_snapshot.json diff --git a/server/migrations/0004_rename_tables.sql b/server/migrations/0004_rename_tables.sql new file mode 100644 index 0000000..b419e29 --- /dev/null +++ b/server/migrations/0004_rename_tables.sql @@ -0,0 +1,9 @@ +-- テーブル名を小文字の単数形・camelCase にする。RENAME は行を消さないので、子のテーブルの行は残り、 +-- 子のテーブルの外部キーの参照先も新しい名前に書き換わる +ALTER TABLE `Project` RENAME TO `event`;--> statement-breakpoint +ALTER TABLE `ParticipationOption` RENAME TO `option`;--> statement-breakpoint +-- SQLite のテーブル名は大文字小文字を区別せず、大文字小文字だけ違う名前には直接変えられないので、別の名前を経由する +ALTER TABLE `AllowedRange` RENAME TO `__renaming_allowedRange`;--> statement-breakpoint +ALTER TABLE `__renaming_allowedRange` RENAME TO `allowedRange`;--> statement-breakpoint +ALTER TABLE `Slot` RENAME TO `__renaming_slot`;--> statement-breakpoint +ALTER TABLE `__renaming_slot` RENAME TO `slot`; diff --git a/server/migrations/meta/0004_snapshot.json b/server/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..94c1ae9 --- /dev/null +++ b/server/migrations/meta/0004_snapshot.json @@ -0,0 +1,400 @@ +{ + "id": "2f574304-0c16-4de2-a604-be7988a01301", + "prevId": "ecc1a63a-5e09-404c-960b-be5e99a01778", + "version": "6", + "dialect": "sqlite", + "tables": { + "Guest": { + "name": "Guest", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "browserId": { + "name": "browserId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Guest_browserId_projectId_key": { + "name": "Guest_browserId_projectId_key", + "columns": ["browserId", "projectId"], + "isUnique": true + }, + "Guest_projectId_idx": { + "name": "Guest_projectId_idx", + "columns": ["projectId"], + "isUnique": false + } + }, + "foreignKeys": { + "Guest_projectId_event_id_fk": { + "name": "Guest_projectId_event_id_fk", + "tableFrom": "Guest", + "columnsFrom": ["projectId"], + "tableTo": "event", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Host": { + "name": "Host", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "browserId": { + "name": "browserId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Host_browserId_projectId_key": { + "name": "Host_browserId_projectId_key", + "columns": ["browserId", "projectId"], + "isUnique": true + }, + "Host_projectId_idx": { + "name": "Host_projectId_idx", + "columns": ["projectId"], + "isUnique": false + } + }, + "foreignKeys": { + "Host_projectId_event_id_fk": { + "name": "Host_projectId_event_id_fk", + "tableFrom": "Host", + "columnsFrom": ["projectId"], + "tableTo": "event", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "allowedRange": { + "name": "allowedRange", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "startTime": { + "name": "startTime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endTime": { + "name": "endTime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "AllowedRange_projectId_idx": { + "name": "AllowedRange_projectId_idx", + "columns": ["projectId"], + "isUnique": false + } + }, + "foreignKeys": { + "allowedRange_projectId_event_id_fk": { + "name": "allowedRange_projectId_event_id_fk", + "tableFrom": "allowedRange", + "columnsFrom": ["projectId"], + "tableTo": "event", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event": { + "name": "event", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startDate": { + "name": "startDate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endDate": { + "name": "endDate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Asia/Tokyo'" + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "option": { + "name": "option", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ParticipationOption_projectId_label_key": { + "name": "ParticipationOption_projectId_label_key", + "columns": ["projectId", "label"], + "isUnique": true + } + }, + "foreignKeys": { + "option_projectId_event_id_fk": { + "name": "option_projectId_event_id_fk", + "tableFrom": "option", + "columnsFrom": ["projectId"], + "tableTo": "event", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "slot": { + "name": "slot", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "from": { + "name": "from", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to": { + "name": "to", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "participationOptionId": { + "name": "participationOptionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Slot_projectId_idx": { + "name": "Slot_projectId_idx", + "columns": ["projectId"], + "isUnique": false + }, + "Slot_guestId_idx": { + "name": "Slot_guestId_idx", + "columns": ["guestId"], + "isUnique": false + }, + "Slot_participationOptionId_idx": { + "name": "Slot_participationOptionId_idx", + "columns": ["participationOptionId"], + "isUnique": false + } + }, + "foreignKeys": { + "slot_projectId_event_id_fk": { + "name": "slot_projectId_event_id_fk", + "tableFrom": "slot", + "columnsFrom": ["projectId"], + "tableTo": "event", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "cascade" + }, + "slot_guestId_Guest_id_fk": { + "name": "slot_guestId_Guest_id_fk", + "tableFrom": "slot", + "columnsFrom": ["guestId"], + "tableTo": "Guest", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "no action" + }, + "slot_participationOptionId_option_id_fk": { + "name": "slot_participationOptionId_option_id_fk", + "tableFrom": "slot", + "columnsFrom": ["participationOptionId"], + "tableTo": "option", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/server/migrations/meta/_journal.json b/server/migrations/meta/_journal.json index f2019a2..f781ce8 100644 --- a/server/migrations/meta/_journal.json +++ b/server/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1792696463507, "tag": "0003_project_dates", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1792696723405, + "tag": "0004_rename_tables", + "breakpoints": true } ] } diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index a2b5651..3aee2a9 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -4,7 +4,7 @@ import { index, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; const uuid = () => crypto.randomUUID(); /** 日程調整プロジェクト。ユーザには「イベント」と表示。 */ -export const project = sqliteTable("Project", { +export const project = sqliteTable("event", { id: text().primaryKey(), name: text().notNull(), description: text(), @@ -21,7 +21,7 @@ export const project = sqliteTable("Project", { /** 日程調整プロジェクトにおいて入力が許可された時間帯。(注: 現在は 1 つの日程調整プロジェクトにつき 1 つのみ設定可能) */ export const allowedRange = sqliteTable( - "AllowedRange", + "allowedRange", { id: text().primaryKey().$defaultFn(uuid), /** イベントのタイムゾーンでの時刻 ("HH:mm") */ @@ -37,7 +37,7 @@ export const allowedRange = sqliteTable( /** あるゲストが参加可能な時間帯の 1 つ。日を跨がない。 */ export const slot = sqliteTable( - "Slot", + "slot", { id: text().primaryKey().$defaultFn(uuid), from: text().notNull(), @@ -97,7 +97,7 @@ export const guest = sqliteTable( /** 参加形態。 */ export const participationOption = sqliteTable( - "ParticipationOption", + "option", { id: text().primaryKey().$defaultFn(uuid), label: text().notNull(), diff --git a/server/test/migrations.test.ts b/server/test/migrations.test.ts index 080f37f..0c6b452 100644 --- a/server/test/migrations.test.ts +++ b/server/test/migrations.test.ts @@ -8,20 +8,20 @@ describe("migrateUntil", () => { await migrate(); - expect(await tableNames(db)).toEqual(expect.arrayContaining(["Project", "Guest", "Slot"])); + expect(await tableNames(db)).toEqual(expect.arrayContaining(["event", "Guest", "slot"])); }); test("前のテストのデータを残さない", async () => { const first = await migrateUntil("0000_init.sql"); await first.migrate(); await first.db - .prepare(`INSERT INTO "Project" (id, name, startDate, endDate, createdAt) VALUES ('p', 'name', 's', 'e', 'c')`) + .prepare(`INSERT INTO "event" (id, name, startDate, endDate, createdAt) VALUES ('p', 'name', 's', 'e', 'c')`) .run(); const second = await migrateUntil("0000_init.sql"); await second.migrate(); - expect(await second.db.prepare(`SELECT * FROM "Project"`).all()).toMatchObject({ results: [] }); + expect(await second.db.prepare(`SELECT * FROM "event"`).all()).toMatchObject({ results: [] }); }); }); @@ -41,13 +41,13 @@ describe("0001_add_project_timezone_created_at", () => { await migrate(); const project = await db - .prepare(`SELECT timezone, createdAt FROM "Project"`) + .prepare(`SELECT timezone, createdAt FROM "event"`) .first<{ timezone: string; createdAt: string }>(); expect(project?.timezone).toBe("Asia/Tokyo"); expect(project?.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); expect(new Date(project?.createdAt ?? "").getTime()).toBeGreaterThanOrEqual(Math.floor(before / 1000) * 1000); // 子のテーブルの行は残る - expect(await db.prepare(`SELECT id FROM "AllowedRange"`).all()).toMatchObject({ results: [{ id: "a" }] }); + expect(await db.prepare(`SELECT id FROM "allowedRange"`).all()).toMatchObject({ results: [{ id: "a" }] }); }); }); @@ -66,7 +66,7 @@ describe("0002_allowed_range_time_of_day", () => { await migrate(); - expect(await db.prepare(`SELECT startTime, endTime FROM "AllowedRange"`).first()).toEqual({ + expect(await db.prepare(`SELECT startTime, endTime FROM "allowedRange"`).first()).toEqual({ startTime: "08:00", endTime: "23:00", }); @@ -85,9 +85,46 @@ describe("0003_project_dates", () => { await migrate(); - expect(await db.prepare(`SELECT startDate, endDate FROM "Project"`).first()).toEqual({ + expect(await db.prepare(`SELECT startDate, endDate FROM "event"`).first()).toEqual({ startDate: "2026-11-02", endDate: "2026-11-04", }); }); }); + +describe("0004_rename_tables", () => { + test("テーブル名を変えても行は残り、外部キーは新しい名前を参照する", async () => { + const { db, migrate } = await migrateUntil("0004_rename_tables.sql"); + await db.batch([ + db.prepare( + `INSERT INTO "Project" (id, name, startDate, endDate) VALUES ('p', 'name', '2026-11-02', '2026-11-04')`, + ), + db.prepare(`INSERT INTO "AllowedRange" VALUES ('a', '08:00', '23:00', 'p')`), + db.prepare(`INSERT INTO "ParticipationOption" VALUES ('o', '参加', '#0F82B1', 'p')`), + db.prepare(`INSERT INTO "Host" VALUES ('h', 'b', 'p')`), + db.prepare(`INSERT INTO "Guest" VALUES ('g', 'ゲスト', NULL, 'b', 'p')`), + db.prepare( + `INSERT INTO "Slot" VALUES ('s', '2026-11-02T00:00:00.000Z', '2026-11-02T01:00:00.000Z', 'p', 'g', 'o')`, + ), + ]); + + await migrate(); + + expect(await tableNames(db)).toEqual( + expect.arrayContaining(["event", "allowedRange", "option", "slot", "Host", "Guest"]), + ); + const counts = await db + .prepare( + `SELECT (SELECT count(*) FROM "event") AS event, (SELECT count(*) FROM "allowedRange") AS allowedRange, + (SELECT count(*) FROM "option") AS option, (SELECT count(*) FROM "slot") AS slot`, + ) + .first(); + expect(counts).toEqual({ event: 1, allowedRange: 1, option: 1, slot: 1 }); + const { results } = await db.prepare(`PRAGMA foreign_key_list("slot")`).all<{ table: string }>(); + expect(results.map((r) => r.table).sort()).toEqual(["Guest", "event", "option"]); + + // 外部キーの CASCADE も新しい名前で効く + await db.batch([db.prepare(`DELETE FROM "slot"`), db.prepare(`DELETE FROM "event"`)]); + expect(await db.prepare(`SELECT count(*) AS n FROM "allowedRange"`).first()).toEqual({ n: 0 }); + }); +}); diff --git a/server/test/setup.ts b/server/test/setup.ts index f621c2a..b17149c 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -3,7 +3,7 @@ import { env } from "cloudflare:workers"; import { beforeEach } from "vitest"; // 外部キーで参照される側を後に消す -const tables = ["Slot", "Guest", "Host", "AllowedRange", "ParticipationOption", "Project"]; +const tables = ["slot", "Guest", "Host", "allowedRange", "option", "event"]; beforeEach(async () => { await applyD1Migrations(env.DB, env.TEST_MIGRATIONS); From 19e42c04d4ba34d1616f9b71dbf16c47bfbe6177 Mon Sep 17 00:00:00 2001 From: nakaterm <104970808+nakaterm@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:26:37 +0900 Subject: [PATCH 12/17] =?UTF-8?q?refactor:=20=E5=88=97=E5=90=8D=E3=82=92?= =?UTF-8?q?=E3=83=86=E3=83=BC=E3=83=96=E3=83=AB=E5=90=8D=E3=81=AB=E5=90=88?= =?UTF-8?q?=E3=82=8F=E3=81=9B=E3=80=81Slot=20=E3=81=AE=20API=20=E3=81=AE?= =?UTF-8?q?=E5=90=8D=E5=89=8D=E3=82=92=E3=81=9D=E3=82=8D=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit allowedRange・option・slot の projectId を eventId に、slot の from / to / participationOptionId を startAt / endAt / optionId にし、インデックス名も 付け直す。slot のインデックスは、イベントの Slot を時刻順に読むので (eventId, startAt) にする。API の Slot も、送信は start / end、受信は from / to と 違っていたので、どちらも startAt / endAt / optionId にそろえる。 Co-Authored-By: Claude Opus 5.5 --- client/src/pages/eventId/Submission.tsx | 6 +- client/src/revivers.ts | 23 +- client/src/types.ts | 31 +- common/validators.ts | 6 +- server/migrations/0005_rename_columns.sql | 19 + server/migrations/meta/0005_snapshot.json | 400 ++++++++++++++++++++++ server/migrations/meta/_journal.json | 7 + server/src/db/schema.ts | 32 +- server/src/services/projects.ts | 22 +- server/src/services/slots.ts | 14 +- server/test/helpers.ts | 4 +- server/test/migrations.test.ts | 48 +++ server/test/projects.test.ts | 55 ++- server/test/slots.test.ts | 4 +- 14 files changed, 576 insertions(+), 95 deletions(-) create mode 100644 server/migrations/0005_rename_columns.sql create mode 100644 server/migrations/meta/0005_snapshot.json diff --git a/client/src/pages/eventId/Submission.tsx b/client/src/pages/eventId/Submission.tsx index ae99d81..ce1369d 100644 --- a/client/src/pages/eventId/Submission.tsx +++ b/client/src/pages/eventId/Submission.tsx @@ -157,9 +157,9 @@ export default function SubmissionPage() { comment: comment.trim() || null, projectId: projectId || "", slots: slots.map((slot) => ({ - start: slot.start.toISOString(), - end: slot.end.toISOString(), - participationOptionId: slot.participationOptionId, + startAt: slot.start.toISOString(), + endAt: slot.end.toISOString(), + optionId: slot.participationOptionId, })), }; if (!myGuestId) { diff --git a/client/src/revivers.ts b/client/src/revivers.ts index be8860c..bd68767 100644 --- a/client/src/revivers.ts +++ b/client/src/revivers.ts @@ -1,7 +1,7 @@ // レスポンスの日付・日時の文字列を Dayjs に変換 import dayjs from "./lib/dayjs"; -import type { BriefProject, ISOStringBriefProject, ISOStringProject, Project } from "./types"; +import type { BriefProject, ISOStringBriefProject, ISOStringProject, Project, Slot } from "./types"; /** * Project の日時のプロパティを ISO 文字列から Date オブジェクトに変換する @@ -19,26 +19,27 @@ export function projectReviver(project: ISOStringProject): Project { guests: project.guests.map((guest) => ({ ...guest, comment: guest.comment ?? undefined, - slots: guest.slots.map((slot) => ({ - ...slot, - from: dayjs.utc(slot.from).tz(), - to: dayjs.utc(slot.to).tz(), - })), + slots: guest.slots.map(slotReviver), })), meAsGuest: project.meAsGuest ? { ...project.meAsGuest, comment: project.meAsGuest.comment ?? undefined, - slots: project.meAsGuest.slots.map((slot) => ({ - ...slot, - from: dayjs.utc(slot.from).tz(), - to: dayjs.utc(slot.to).tz(), - })), + slots: project.meAsGuest.slots.map(slotReviver), } : null, }; } +function slotReviver({ startAt, endAt, optionId, ...slot }: ISOStringProject["guests"][number]["slots"][number]): Slot { + return { + ...slot, + from: dayjs.utc(startAt).tz(), + to: dayjs.utc(endAt).tz(), + participationOptionId: optionId, + }; +} + /** * BriefProject の日時のプロパティを ISO 文字列から Date オブジェクトに変換する * @param project diff --git a/client/src/types.ts b/client/src/types.ts index 75701e2..4daed99 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -4,12 +4,13 @@ type ParticipationOption = { id: string; label: string; color: string; - projectId: string; + eventId: string; }; +/** API の startAt / endAt / optionId を、カレンダーで使う名前にしたもの */ export type Slot = { id: string; - projectId: string; + eventId: string; guestId: string; from: Dayjs; to: Dayjs; @@ -18,7 +19,7 @@ export type Slot = { type AllowedRange = { id: string; - projectId: string; + eventId: string; /** イベントのタイムゾーンでの時刻 ("HH:mm") */ startTime: string; /** イベントのタイムゾーンでの時刻 ("HH:mm")。その日の終わりまでなら "24:00" */ @@ -68,7 +69,7 @@ export type ISOStringProject = { timezone: string; allowedRanges: { id: string; - projectId: string; + eventId: string; startTime: string; endTime: string; }[]; @@ -76,7 +77,7 @@ export type ISOStringProject = { id: string; label: string; color: string; - projectId: string; + eventId: string; }[]; hosts: Host[]; guests: { @@ -86,11 +87,13 @@ export type ISOStringProject = { comment: string | null; slots: { id: string; - projectId: string; + eventId: string; guestId: string; - from: string; - to: string; - participationOptionId: string; + /** UTC の ISO 8601 */ + startAt: string; + /** UTC の ISO 8601 */ + endAt: string; + optionId: string; }[]; }[]; isHost: boolean; @@ -101,11 +104,13 @@ export type ISOStringProject = { comment: string | null; slots: { id: string; - projectId: string; + eventId: string; guestId: string; - from: string; - to: string; - participationOptionId: string; + /** UTC の ISO 8601 */ + startAt: string; + /** UTC の ISO 8601 */ + endAt: string; + optionId: string; }[]; } | null; }; diff --git a/common/validators.ts b/common/validators.ts index 396f915..5952e59 100644 --- a/common/validators.ts +++ b/common/validators.ts @@ -24,9 +24,9 @@ export const submitReqSchema = z.object({ comment: z.string().max(500, "コメントは500文字以内で入力してください").nullable().optional(), slots: z.array( z.object({ - start: isoStrToDate, - end: isoStrToDate, - participationOptionId: z.string().uuid(), + startAt: isoStrToDate, + endAt: isoStrToDate, + optionId: z.string().uuid(), }), ), }); diff --git a/server/migrations/0005_rename_columns.sql b/server/migrations/0005_rename_columns.sql new file mode 100644 index 0000000..5b1916d --- /dev/null +++ b/server/migrations/0005_rename_columns.sql @@ -0,0 +1,19 @@ +-- 列名をテーブル名に合わせる (projectId → eventId など)。RENAME COLUMN は行を消さず、 +-- インデックスと外部キーの定義の列名も書き換わる。インデックスは名前を付け直すために作り直す +ALTER TABLE `allowedRange` RENAME COLUMN `projectId` TO `eventId`;--> statement-breakpoint +DROP INDEX `AllowedRange_projectId_idx`;--> statement-breakpoint +CREATE INDEX `allowedRange_eventId_idx` ON `allowedRange` (`eventId`);--> statement-breakpoint +ALTER TABLE `option` RENAME COLUMN `projectId` TO `eventId`;--> statement-breakpoint +DROP INDEX `ParticipationOption_projectId_label_key`;--> statement-breakpoint +CREATE UNIQUE INDEX `option_eventId_label_key` ON `option` (`eventId`,`label`);--> statement-breakpoint +ALTER TABLE `slot` RENAME COLUMN `projectId` TO `eventId`;--> statement-breakpoint +ALTER TABLE `slot` RENAME COLUMN `from` TO `startAt`;--> statement-breakpoint +ALTER TABLE `slot` RENAME COLUMN `to` TO `endAt`;--> statement-breakpoint +ALTER TABLE `slot` RENAME COLUMN `participationOptionId` TO `optionId`;--> statement-breakpoint +-- イベントの Slot を時刻順に読むので、eventId だけでなく startAt も含める +DROP INDEX `Slot_projectId_idx`;--> statement-breakpoint +CREATE INDEX `slot_eventId_startAt_idx` ON `slot` (`eventId`,`startAt`);--> statement-breakpoint +DROP INDEX `Slot_guestId_idx`;--> statement-breakpoint +CREATE INDEX `slot_guestId_idx` ON `slot` (`guestId`);--> statement-breakpoint +DROP INDEX `Slot_participationOptionId_idx`;--> statement-breakpoint +CREATE INDEX `slot_optionId_idx` ON `slot` (`optionId`); diff --git a/server/migrations/meta/0005_snapshot.json b/server/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..cc18464 --- /dev/null +++ b/server/migrations/meta/0005_snapshot.json @@ -0,0 +1,400 @@ +{ + "id": "c076aff6-3588-4049-98db-e4add579c6f5", + "prevId": "2f574304-0c16-4de2-a604-be7988a01301", + "version": "6", + "dialect": "sqlite", + "tables": { + "Guest": { + "name": "Guest", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "browserId": { + "name": "browserId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Guest_browserId_projectId_key": { + "name": "Guest_browserId_projectId_key", + "columns": ["browserId", "projectId"], + "isUnique": true + }, + "Guest_projectId_idx": { + "name": "Guest_projectId_idx", + "columns": ["projectId"], + "isUnique": false + } + }, + "foreignKeys": { + "Guest_projectId_event_id_fk": { + "name": "Guest_projectId_event_id_fk", + "tableFrom": "Guest", + "columnsFrom": ["projectId"], + "tableTo": "event", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Host": { + "name": "Host", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "browserId": { + "name": "browserId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Host_browserId_projectId_key": { + "name": "Host_browserId_projectId_key", + "columns": ["browserId", "projectId"], + "isUnique": true + }, + "Host_projectId_idx": { + "name": "Host_projectId_idx", + "columns": ["projectId"], + "isUnique": false + } + }, + "foreignKeys": { + "Host_projectId_event_id_fk": { + "name": "Host_projectId_event_id_fk", + "tableFrom": "Host", + "columnsFrom": ["projectId"], + "tableTo": "event", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "allowedRange": { + "name": "allowedRange", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "startTime": { + "name": "startTime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endTime": { + "name": "endTime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "eventId": { + "name": "eventId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "allowedRange_eventId_idx": { + "name": "allowedRange_eventId_idx", + "columns": ["eventId"], + "isUnique": false + } + }, + "foreignKeys": { + "allowedRange_eventId_event_id_fk": { + "name": "allowedRange_eventId_event_id_fk", + "tableFrom": "allowedRange", + "columnsFrom": ["eventId"], + "tableTo": "event", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event": { + "name": "event", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startDate": { + "name": "startDate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endDate": { + "name": "endDate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Asia/Tokyo'" + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "option": { + "name": "option", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "eventId": { + "name": "eventId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "option_eventId_label_key": { + "name": "option_eventId_label_key", + "columns": ["eventId", "label"], + "isUnique": true + } + }, + "foreignKeys": { + "option_eventId_event_id_fk": { + "name": "option_eventId_event_id_fk", + "tableFrom": "option", + "columnsFrom": ["eventId"], + "tableTo": "event", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "slot": { + "name": "slot", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "startAt": { + "name": "startAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endAt": { + "name": "endAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "eventId": { + "name": "eventId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "guestId": { + "name": "guestId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "optionId": { + "name": "optionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "slot_eventId_startAt_idx": { + "name": "slot_eventId_startAt_idx", + "columns": ["eventId", "startAt"], + "isUnique": false + }, + "slot_guestId_idx": { + "name": "slot_guestId_idx", + "columns": ["guestId"], + "isUnique": false + }, + "slot_optionId_idx": { + "name": "slot_optionId_idx", + "columns": ["optionId"], + "isUnique": false + } + }, + "foreignKeys": { + "slot_eventId_event_id_fk": { + "name": "slot_eventId_event_id_fk", + "tableFrom": "slot", + "columnsFrom": ["eventId"], + "tableTo": "event", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "cascade" + }, + "slot_guestId_Guest_id_fk": { + "name": "slot_guestId_Guest_id_fk", + "tableFrom": "slot", + "columnsFrom": ["guestId"], + "tableTo": "Guest", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "no action" + }, + "slot_optionId_option_id_fk": { + "name": "slot_optionId_option_id_fk", + "tableFrom": "slot", + "columnsFrom": ["optionId"], + "tableTo": "option", + "columnsTo": ["id"], + "onUpdate": "cascade", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/server/migrations/meta/_journal.json b/server/migrations/meta/_journal.json index f781ce8..bfab567 100644 --- a/server/migrations/meta/_journal.json +++ b/server/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1792696723405, "tag": "0004_rename_tables", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1792696953969, + "tag": "0005_rename_columns", + "breakpoints": true } ] } diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 3aee2a9..721a6ba 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -28,11 +28,11 @@ export const allowedRange = sqliteTable( startTime: text().notNull(), /** イベントのタイムゾーンでの時刻 ("HH:mm")。その日の終わりまでなら "24:00" */ endTime: text().notNull(), - projectId: text() + eventId: text() .notNull() .references(() => project.id, { onDelete: "cascade", onUpdate: "cascade" }), }, - (t) => [index("AllowedRange_projectId_idx").on(t.projectId)], + (t) => [index("allowedRange_eventId_idx").on(t.eventId)], ); /** あるゲストが参加可能な時間帯の 1 つ。日を跨がない。 */ @@ -40,9 +40,11 @@ export const slot = sqliteTable( "slot", { id: text().primaryKey().$defaultFn(uuid), - from: text().notNull(), - to: text().notNull(), - projectId: text() + /** UTC の ISO 8601 */ + startAt: text().notNull(), + /** UTC の ISO 8601 */ + endAt: text().notNull(), + eventId: text() .notNull() .references(() => project.id, { onDelete: "cascade", onUpdate: "cascade" }), // Postgres では RESTRICT だったが、SQLite の RESTRICT は即時に検査されるため、Project の削除からの CASCADE で @@ -50,14 +52,14 @@ export const slot = sqliteTable( guestId: text() .notNull() .references(() => guest.id, { onUpdate: "cascade" }), - participationOptionId: text() + optionId: text() .notNull() .references(() => participationOption.id, { onUpdate: "cascade" }), }, (t) => [ - index("Slot_projectId_idx").on(t.projectId), - index("Slot_guestId_idx").on(t.guestId), - index("Slot_participationOptionId_idx").on(t.participationOptionId), + index("slot_eventId_startAt_idx").on(t.eventId, t.startAt), + index("slot_guestId_idx").on(t.guestId), + index("slot_optionId_idx").on(t.optionId), ], ); @@ -102,11 +104,11 @@ export const participationOption = sqliteTable( id: text().primaryKey().$defaultFn(uuid), label: text().notNull(), color: text().notNull(), - projectId: text() + eventId: text() .notNull() .references(() => project.id, { onDelete: "cascade", onUpdate: "cascade" }), }, - (t) => [uniqueIndex("ParticipationOption_projectId_label_key").on(t.projectId, t.label)], + (t) => [uniqueIndex("option_eventId_label_key").on(t.eventId, t.label)], ); export const projectRelations = relations(project, ({ many }) => ({ @@ -118,14 +120,14 @@ export const projectRelations = relations(project, ({ many }) => ({ })); export const allowedRangeRelations = relations(allowedRange, ({ one }) => ({ - project: one(project, { fields: [allowedRange.projectId], references: [project.id] }), + project: one(project, { fields: [allowedRange.eventId], references: [project.id] }), })); export const slotRelations = relations(slot, ({ one }) => ({ - project: one(project, { fields: [slot.projectId], references: [project.id] }), + project: one(project, { fields: [slot.eventId], references: [project.id] }), guest: one(guest, { fields: [slot.guestId], references: [guest.id] }), participationOption: one(participationOption, { - fields: [slot.participationOptionId], + fields: [slot.optionId], references: [participationOption.id], }), })); @@ -140,6 +142,6 @@ export const guestRelations = relations(guest, ({ one, many }) => ({ })); export const participationOptionRelations = relations(participationOption, ({ one, many }) => ({ - project: one(project, { fields: [participationOption.projectId], references: [project.id] }), + project: one(project, { fields: [participationOption.eventId], references: [project.id] }), slots: many(slot), })); diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 9d86300..f0468b5 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -30,7 +30,7 @@ export async function createProject(db: Db, browserId: string, input: CreateProj ...insertMany( db, schema.allowedRange, - input.allowedRanges.map((range) => ({ ...range, projectId: project.id })), + input.allowedRanges.map((range) => ({ ...range, eventId: project.id })), ), db.insert(schema.host).values({ browserId, projectId: project.id }), ...insertMany( @@ -40,7 +40,7 @@ export async function createProject(db: Db, browserId: string, input: CreateProj id: opt.id, label: opt.label, color: opt.color, - projectId: project.id, + eventId: project.id, })), ), ]); @@ -146,7 +146,7 @@ export async function updateProject(db: Db, browserId: string, projectId: string // 削除対象の参加形態に Slot が紐づいているかチェック const existingOptions = await db.query.participationOption.findMany({ - where: eq(schema.participationOption.projectId, projectId), + where: eq(schema.participationOption.eventId, projectId), with: { slots: { columns: { id: true } } }, }); const newOptionIds = input.participationOptions.map((o) => o.id); @@ -163,7 +163,7 @@ export async function updateProject(db: Db, browserId: string, projectId: string .delete(schema.participationOption) .where( and( - eq(schema.participationOption.projectId, projectId), + eq(schema.participationOption.eventId, projectId), notInArray(schema.participationOption.id, newOptionIds), ), ), @@ -171,7 +171,7 @@ export async function updateProject(db: Db, browserId: string, projectId: string ...input.participationOptions.map((opt) => db .insert(schema.participationOption) - .values({ id: opt.id, label: opt.label, color: opt.color, projectId }) + .values({ id: opt.id, label: opt.label, color: opt.color, eventId: projectId }) .onConflictDoUpdate({ target: schema.participationOption.id, set: { label: opt.label, color: opt.color }, @@ -193,11 +193,11 @@ export async function updateProject(db: Db, browserId: string, projectId: string } if (!existingGuest && input.allowedRanges) { statements.push( - db.delete(schema.allowedRange).where(eq(schema.allowedRange.projectId, projectId)), + db.delete(schema.allowedRange).where(eq(schema.allowedRange.eventId, projectId)), ...insertMany( db, schema.allowedRange, - input.allowedRanges.map((range) => ({ ...range, projectId })), + input.allowedRanges.map((range) => ({ ...range, eventId: projectId })), ), ); } @@ -256,11 +256,11 @@ function findGuest(db: Db, browserId: string, projectId: string) { function slotRows(input: SubmissionInput, projectId: string, guestId: string) { return input.slots.map((slot) => ({ - from: toIso(slot.start), - to: toIso(slot.end), - projectId, + startAt: toIso(slot.startAt), + endAt: toIso(slot.endAt), + eventId: projectId, guestId, - participationOptionId: slot.participationOptionId, + optionId: slot.optionId, })); } diff --git a/server/src/services/slots.ts b/server/src/services/slots.ts index 63e5595..b93b9bf 100644 --- a/server/src/services/slots.ts +++ b/server/src/services/slots.ts @@ -8,7 +8,7 @@ function isQuarterHour(value: Date): boolean { return value.getTime() % (15 * 60 * 1000) === 0; } -type SlotInput = { start: Date; end: Date; participationOptionId: string }; +type SlotInput = { startAt: Date; endAt: Date; optionId: string }; type Project = { /** 日付と時間帯は、このタイムゾーンの壁時計で解釈する */ @@ -37,17 +37,17 @@ export function validateSlots(project: Project, slots: SlotInput[]): void { slots.forEach((slot, i) => { const invalid = (message: string) => new ServiceError("invalid_slot", `${i + 1} 件目の時間帯: ${message}`); - if (slot.start.getTime() >= slot.end.getTime()) { + if (slot.startAt.getTime() >= slot.endAt.getTime()) { throw invalid("開始時刻は終了時刻より前にしてください。"); } - if (!isQuarterHour(slot.start) || !isQuarterHour(slot.end)) { + if (!isQuarterHour(slot.startAt) || !isQuarterHour(slot.endAt)) { throw invalid("時刻は 15 分単位で指定してください。"); } - const start = toWallClock(slot.start); - const end = toWallClock(slot.end); + const start = toWallClock(slot.startAt); + const end = toWallClock(slot.endAt); // [start, end) の最後の瞬間の日付。24:00 は翌日の 0:00 として届くので、こうしないと日をまたいだことになる - const lastInstantDate = toWallClock(new Date(slot.end.getTime() - 1)).date; + const lastInstantDate = toWallClock(new Date(slot.endAt.getTime() - 1)).date; if (lastInstantDate !== start.date) { throw invalid("日をまたいでいます。日付ごとに分けてください。"); } @@ -59,7 +59,7 @@ export function validateSlots(project: Project, slots: SlotInput[]): void { throw invalid(`イベントの時間帯 (${formatMinutes(rangeStart)}〜${formatMinutes(rangeEnd)}) の外です。`); } - if (!optionIds.has(slot.participationOptionId)) { + if (!optionIds.has(slot.optionId)) { throw invalid("このイベントに無い参加形態です。"); } }); diff --git a/server/test/helpers.ts b/server/test/helpers.ts index 2a407b0..c2b8312 100644 --- a/server/test/helpers.ts +++ b/server/test/helpers.ts @@ -61,14 +61,14 @@ export async function createProject(host: Browser, overrides: Record = {}) { return { name: "ゲスト", projectId, comment: "コメント", - slots: [{ ...slotOnNov2, participationOptionId: optionId }], + slots: [{ ...slotOnNov2, optionId }], ...overrides, }; } diff --git a/server/test/migrations.test.ts b/server/test/migrations.test.ts index 0c6b452..b7cd715 100644 --- a/server/test/migrations.test.ts +++ b/server/test/migrations.test.ts @@ -128,3 +128,51 @@ describe("0004_rename_tables", () => { expect(await db.prepare(`SELECT count(*) AS n FROM "allowedRange"`).first()).toEqual({ n: 0 }); }); }); + +describe("0005_rename_columns", () => { + test("列名とインデックス名を変えても行は残り、外部キーは新しい列名で効く", async () => { + const { db, migrate } = await migrateUntil("0005_rename_columns.sql"); + await db.batch([ + db.prepare(`INSERT INTO "event" (id, name, startDate, endDate) VALUES ('p', 'name', '2026-11-02', '2026-11-04')`), + db.prepare(`INSERT INTO "allowedRange" VALUES ('a', '08:00', '23:00', 'p')`), + db.prepare(`INSERT INTO "option" VALUES ('o', '参加', '#0F82B1', 'p')`), + db.prepare(`INSERT INTO "Guest" VALUES ('g', 'ゲスト', NULL, 'b', 'p')`), + db.prepare( + `INSERT INTO "slot" VALUES ('s', '2026-11-02T00:00:00.000Z', '2026-11-02T01:00:00.000Z', 'p', 'g', 'o')`, + ), + ]); + + await migrate(); + + expect(await db.prepare(`SELECT eventId, startAt, endAt, guestId, optionId FROM "slot"`).first()).toEqual({ + eventId: "p", + startAt: "2026-11-02T00:00:00.000Z", + endAt: "2026-11-02T01:00:00.000Z", + guestId: "g", + optionId: "o", + }); + expect(await db.prepare(`SELECT eventId FROM "allowedRange"`).first()).toEqual({ eventId: "p" }); + expect(await db.prepare(`SELECT eventId FROM "option"`).first()).toEqual({ eventId: "p" }); + const { results: indexes } = await db + .prepare( + `SELECT name FROM sqlite_master + WHERE type = 'index' AND tbl_name IN ('allowedRange', 'option', 'slot') AND name NOT LIKE 'sqlite_%'`, + ) + .all<{ name: string }>(); + expect(indexes.map((i) => i.name).sort()).toEqual([ + "allowedRange_eventId_idx", + "option_eventId_label_key", + "slot_eventId_startAt_idx", + "slot_guestId_idx", + "slot_optionId_idx", + ]); + + // 同じイベントで同じラベルの参加形態は作れない + await expect(db.prepare(`INSERT INTO "option" VALUES ('o2', '参加', '#000000', 'p')`).run()).rejects.toThrow( + /UNIQUE/, + ); + // 外部キーの CASCADE も新しい列名で効く + await db.batch([db.prepare(`DELETE FROM "slot"`), db.prepare(`DELETE FROM "event"`)]); + expect(await db.prepare(`SELECT count(*) AS n FROM "allowedRange"`).first()).toEqual({ n: 0 }); + }); +}); diff --git a/server/test/projects.test.ts b/server/test/projects.test.ts index 4f5aaa0..22416d6 100644 --- a/server/test/projects.test.ts +++ b/server/test/projects.test.ts @@ -37,12 +37,12 @@ describe("POST /projects", () => { allowedRanges: [ { id: expect.any(String), - projectId: body.id, + eventId: body.id, startTime: "08:00", endTime: "23:00", }, ], - participationOptions: [{ ...input.participationOptions[0], projectId: body.id }], + participationOptions: [{ ...input.participationOptions[0], eventId: body.id }], hosts: [{ id: expect.any(String), projectId: body.id }], guests: [], isHost: true, @@ -201,11 +201,11 @@ describe("GET /projects/:projectId", () => { slots: [ { id: expect.any(String), - from: slotOnNov2.start, - to: slotOnNov2.end, - projectId: id, + startAt: slotOnNov2.startAt, + endAt: slotOnNov2.endAt, + eventId: id, guestId: expect.any(String), - participationOptionId: optionId, + optionId, }, ], }; @@ -293,12 +293,12 @@ describe("PATCH /projects/:projectId", () => { allowedRanges: [ { id: expect.any(String), - projectId: id, + eventId: id, startTime: "09:00", endTime: "18:00", }, ], - participationOptions: [{ id: expect.any(String), label: "参加", color: "#0F82B1", projectId: id }], + participationOptions: [{ id: expect.any(String), label: "参加", color: "#0F82B1", eventId: id }], }); }); @@ -380,8 +380,8 @@ describe("PATCH /projects/:projectId", () => { expect(res.status).toBe(200); const options = (await getProject(host, id)).participationOptions; expect(options.sort((a: { label: string }, b: { label: string }) => a.label.localeCompare(b.label))).toEqual([ - { ...added, projectId: id }, - { id: kept.id, label: "対面", color: "#00FF00", projectId: id }, + { ...added, eventId: id }, + { id: kept.id, label: "対面", color: "#00FF00", eventId: id }, ]); }); @@ -515,9 +515,7 @@ describe("日程の提出・更新での Slot の検証", () => { `/projects/${id}/submissions`, submissionInput(id, optionId, { // 11/01 10:00〜12:00 - slots: [ - { start: "2026-11-01T01:00:00.000Z", end: "2026-11-01T03:00:00.000Z", participationOptionId: optionId }, - ], + slots: [{ startAt: "2026-11-01T01:00:00.000Z", endAt: "2026-11-01T03:00:00.000Z", optionId }], }), ); @@ -544,15 +542,16 @@ describe("日程の提出・更新での Slot の検証", () => { submissionInput(id, optionId, { name: "新しい名前", // 11/01 10:00〜12:00 - slots: [ - { start: "2026-11-01T01:00:00.000Z", end: "2026-11-01T03:00:00.000Z", participationOptionId: optionId }, - ], + slots: [{ startAt: "2026-11-01T01:00:00.000Z", endAt: "2026-11-01T03:00:00.000Z", optionId }], }), ); expect(res.status).toBe(400); const { meAsGuest } = await getProject(guest, id); - expect(meAsGuest).toMatchObject({ name: "ゲスト", slots: [{ from: slotOnNov2.start, to: slotOnNov2.end }] }); + expect(meAsGuest).toMatchObject({ + name: "ゲスト", + slots: [{ startAt: slotOnNov2.startAt, endAt: slotOnNov2.endAt }], + }); }); }); @@ -571,9 +570,9 @@ describe("PUT /projects/:projectId/submissions/mine", () => { const guest = createBrowser(); await submit(guest, id, optionId); const newSlot = { - start: "2026-11-03T05:00:00.000Z", - end: "2026-11-03T06:00:00.000Z", - participationOptionId: optionId, + startAt: "2026-11-03T05:00:00.000Z", + endAt: "2026-11-03T06:00:00.000Z", + optionId, }; const res = await guest.put( @@ -592,17 +591,17 @@ describe("PUT /projects/:projectId/submissions/mine", () => { slots: [ { id: expect.any(String), - from: newSlot.start, - to: newSlot.end, - projectId: id, + startAt: newSlot.startAt, + endAt: newSlot.endAt, + eventId: id, guestId: expect.any(String), - participationOptionId: optionId, + optionId, }, ], }, }); const { meAsGuest } = await getProject(guest, id); - expect(meAsGuest.slots).toEqual([expect.objectContaining({ from: newSlot.start, to: newSlot.end })]); + expect(meAsGuest.slots).toEqual([expect.objectContaining({ startAt: newSlot.startAt, endAt: newSlot.endAt })]); }); // D1 は 1 つの文のバインド変数が 100 個まで (Slot 1 行で 6 個)。ローカルの D1 はこの上限を強制しないので、 @@ -616,9 +615,9 @@ describe("PUT /projects/:projectId/submissions/mine", () => { Array.from({ length: 15 }, (_, hour) => { const start = new Date(Date.UTC(2026, 10, day, 23 + hour)); return { - start: start.toISOString(), - end: new Date(start.getTime() + 60 * 60 * 1000).toISOString(), - participationOptionId: optionId, + startAt: start.toISOString(), + endAt: new Date(start.getTime() + 60 * 60 * 1000).toISOString(), + optionId, }; }), ); diff --git a/server/test/slots.test.ts b/server/test/slots.test.ts index 5e5937b..a134571 100644 --- a/server/test/slots.test.ts +++ b/server/test/slots.test.ts @@ -12,8 +12,8 @@ const project = { }; const allDayProject = { ...project, allowedRanges: [] }; -function slot(start: string, end: string, participationOptionId = "option") { - return { start: new Date(start), end: new Date(end), participationOptionId }; +function slot(startAt: string, endAt: string, optionId = "option") { + return { startAt: new Date(startAt), endAt: new Date(endAt), optionId }; } function validationError(fn: () => void): ServiceError { From 30503844a40320d47ef9db6cd74e0e0673ff940f Mon Sep 17 00:00:00 2001 From: nakaterm <104970808+nakaterm@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:32:21 +0900 Subject: [PATCH 13/17] =?UTF-8?q?refactor:=20Host=20=E3=81=A8=20Guest=20?= =?UTF-8?q?=E3=82=92=20participant=20=E3=81=AB=E3=81=BE=E3=81=A8=E3=82=81?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ホストとゲストを participant にまとめ、role で区別する。ホストが自分のイベントに 回答すると、Host と Guest の 2 行に分かれていたのを 1 行にする。name が null なら まだ回答していない。slot は guestId の代わりに participantId で参加者を参照する。 API の hosts / guests / meAsGuest / guestId の形は保ち、projectId を eventId に、 回答の送信本文から重複していた projectId を消し、回答の送信と更新はどちらも { guest } を返すようにそろえる。 移行のテストの migrate() は対象のマイグレーションまでを当てるようにし、 後からマイグレーションを足しても前のテストが変わらないようにする。 Co-Authored-By: Claude Opus 5.5 --- client/src/pages/eventId/Submission.tsx | 1 - client/src/types.ts | 8 +- common/validators.ts | 1 - ...0006_merge_host_guest_into_participant.sql | 63 +++ server/migrations/meta/0006_snapshot.json | 362 ++++++++++++++++++ server/migrations/meta/_journal.json | 7 + server/src/db/schema.ts | 63 ++- server/src/routes/projects.ts | 6 +- server/src/services/projects.ts | 144 ++++--- server/test/helpers.ts | 3 +- server/test/migrations.test.ts | 102 ++++- server/test/migrations.ts | 7 +- server/test/projects.test.ts | 39 +- server/test/setup.ts | 2 +- 14 files changed, 663 insertions(+), 145 deletions(-) create mode 100644 server/migrations/0006_merge_host_guest_into_participant.sql create mode 100644 server/migrations/meta/0006_snapshot.json diff --git a/client/src/pages/eventId/Submission.tsx b/client/src/pages/eventId/Submission.tsx index ce1369d..65d7d8b 100644 --- a/client/src/pages/eventId/Submission.tsx +++ b/client/src/pages/eventId/Submission.tsx @@ -155,7 +155,6 @@ export default function SubmissionPage() { const payload = { name: guestName, comment: comment.trim() || null, - projectId: projectId || "", slots: slots.map((slot) => ({ startAt: slot.start.toISOString(), endAt: slot.end.toISOString(), diff --git a/client/src/types.ts b/client/src/types.ts index 4daed99..9c921f7 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -28,13 +28,13 @@ type AllowedRange = { type Host = { id: string; - projectId: string; + eventId: string; }; type Guest = { id: string; name: string; - projectId: string; + eventId: string; comment?: string; slots: Slot[]; }; @@ -83,7 +83,7 @@ export type ISOStringProject = { guests: { id: string; name: string; - projectId: string; + eventId: string; comment: string | null; slots: { id: string; @@ -100,7 +100,7 @@ export type ISOStringProject = { meAsGuest: { id: string; name: string; - projectId: string; + eventId: string; comment: string | null; slots: { id: string; diff --git a/common/validators.ts b/common/validators.ts index 5952e59..b598dd9 100644 --- a/common/validators.ts +++ b/common/validators.ts @@ -20,7 +20,6 @@ export const participationOptionCreateSchema = z.object({ export const submitReqSchema = z.object({ name: z.string().min(1, "名前を入力してください").max(50, "名前は50文字以内で入力してください"), - projectId: z.string().length(21), comment: z.string().max(500, "コメントは500文字以内で入力してください").nullable().optional(), slots: z.array( z.object({ diff --git a/server/migrations/0006_merge_host_guest_into_participant.sql b/server/migrations/0006_merge_host_guest_into_participant.sql new file mode 100644 index 0000000..48e31e6 --- /dev/null +++ b/server/migrations/0006_merge_host_guest_into_participant.sql @@ -0,0 +1,63 @@ +-- Host と Guest を participant にまとめ、role で区別する。name が null なら未回答 (回答していないホスト)。 +-- 旧データには更新日時が無いので、マイグレーションを当てた時刻で埋める +CREATE TABLE `participant` ( + `id` text PRIMARY KEY NOT NULL, + `eventId` text NOT NULL, + `role` text NOT NULL, + `name` text, + `comment` text, + `browserId` text NOT NULL, + `updatedAt` text NOT NULL, + FOREIGN KEY (`eventId`) REFERENCES `event`(`id`) ON UPDATE cascade ON DELETE cascade, + CONSTRAINT "participant_role_check" CHECK("participant"."role" IN ('host', 'guest')) +); +--> statement-breakpoint +CREATE UNIQUE INDEX `participant_eventId_browserId_key` ON `participant` (`eventId`,`browserId`);--> statement-breakpoint +-- 回答した人。ホストが自分のイベントに回答していれば、1 行にまとめて role を host にする。 +-- id は Guest の id を引き継ぎ、slot の guestId をそのまま participantId にできるようにする +INSERT INTO `participant` (`id`, `eventId`, `role`, `name`, `comment`, `browserId`, `updatedAt`) +SELECT + g.`id`, + g.`projectId`, + CASE WHEN EXISTS ( + SELECT 1 FROM `Host` h WHERE h.`projectId` = g.`projectId` AND h.`browserId` = g.`browserId` + ) THEN 'host' ELSE 'guest' END, + g.`name`, + g.`comment`, + g.`browserId`, + strftime('%Y-%m-%dT%H:%M:%fZ', 'now') +FROM `Guest` g; +--> statement-breakpoint +-- 回答していないホスト +INSERT INTO `participant` (`id`, `eventId`, `role`, `name`, `comment`, `browserId`, `updatedAt`) +SELECT h.`id`, h.`projectId`, 'host', NULL, NULL, h.`browserId`, strftime('%Y-%m-%dT%H:%M:%fZ', 'now') +FROM `Host` h +WHERE NOT EXISTS ( + SELECT 1 FROM `Guest` g WHERE g.`projectId` = h.`projectId` AND g.`browserId` = h.`browserId` +); +--> statement-breakpoint +-- slot の参照先を Guest から participant に変える。外部キーは ALTER TABLE で変えられないので作り直す。 +-- slot は他のテーブルから参照されないので、DROP しても CASCADE で消える行は無い +CREATE TABLE `__new_slot` ( + `id` text PRIMARY KEY NOT NULL, + `startAt` text NOT NULL, + `endAt` text NOT NULL, + `eventId` text NOT NULL, + `participantId` text NOT NULL, + `optionId` text NOT NULL, + FOREIGN KEY (`eventId`) REFERENCES `event`(`id`) ON UPDATE cascade ON DELETE cascade, + FOREIGN KEY (`participantId`) REFERENCES `participant`(`id`) ON UPDATE cascade ON DELETE cascade, + FOREIGN KEY (`optionId`) REFERENCES `option`(`id`) ON UPDATE cascade ON DELETE no action +); +--> statement-breakpoint +INSERT INTO `__new_slot` (`id`, `startAt`, `endAt`, `eventId`, `participantId`, `optionId`) +SELECT `id`, `startAt`, `endAt`, `eventId`, `guestId`, `optionId` FROM `slot`; +--> statement-breakpoint +DROP TABLE `slot`;--> statement-breakpoint +ALTER TABLE `__new_slot` RENAME TO `slot`;--> statement-breakpoint +CREATE INDEX `slot_eventId_startAt_idx` ON `slot` (`eventId`,`startAt`);--> statement-breakpoint +CREATE INDEX `slot_participantId_idx` ON `slot` (`participantId`);--> statement-breakpoint +CREATE INDEX `slot_optionId_idx` ON `slot` (`optionId`);--> statement-breakpoint +-- Guest を参照していた slot を作り直したので、Guest と Host はどこからも参照されない +DROP TABLE `Guest`;--> statement-breakpoint +DROP TABLE `Host`; diff --git a/server/migrations/meta/0006_snapshot.json b/server/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..1398845 --- /dev/null +++ b/server/migrations/meta/0006_snapshot.json @@ -0,0 +1,362 @@ +{ + "id": "ed302924-f7ff-42e0-9383-6ecebbe5702a", + "prevId": "c076aff6-3588-4049-98db-e4add579c6f5", + "version": "6", + "dialect": "sqlite", + "tables": { + "allowedRange": { + "name": "allowedRange", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "startTime": { + "name": "startTime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endTime": { + "name": "endTime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "eventId": { + "name": "eventId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "allowedRange_eventId_idx": { + "name": "allowedRange_eventId_idx", + "columns": ["eventId"], + "isUnique": false + } + }, + "foreignKeys": { + "allowedRange_eventId_event_id_fk": { + "name": "allowedRange_eventId_event_id_fk", + "tableFrom": "allowedRange", + "tableTo": "event", + "columnsFrom": ["eventId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "participant": { + "name": "participant", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "eventId": { + "name": "eventId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "browserId": { + "name": "browserId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "participant_eventId_browserId_key": { + "name": "participant_eventId_browserId_key", + "columns": ["eventId", "browserId"], + "isUnique": true + } + }, + "foreignKeys": { + "participant_eventId_event_id_fk": { + "name": "participant_eventId_event_id_fk", + "tableFrom": "participant", + "tableTo": "event", + "columnsFrom": ["eventId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "participant_role_check": { + "name": "participant_role_check", + "value": "\"participant\".\"role\" IN ('host', 'guest')" + } + } + }, + "option": { + "name": "option", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "eventId": { + "name": "eventId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "option_eventId_label_key": { + "name": "option_eventId_label_key", + "columns": ["eventId", "label"], + "isUnique": true + } + }, + "foreignKeys": { + "option_eventId_event_id_fk": { + "name": "option_eventId_event_id_fk", + "tableFrom": "option", + "tableTo": "event", + "columnsFrom": ["eventId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event": { + "name": "event", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startDate": { + "name": "startDate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endDate": { + "name": "endDate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Asia/Tokyo'" + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "slot": { + "name": "slot", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "startAt": { + "name": "startAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endAt": { + "name": "endAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "eventId": { + "name": "eventId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "participantId": { + "name": "participantId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "optionId": { + "name": "optionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "slot_eventId_startAt_idx": { + "name": "slot_eventId_startAt_idx", + "columns": ["eventId", "startAt"], + "isUnique": false + }, + "slot_participantId_idx": { + "name": "slot_participantId_idx", + "columns": ["participantId"], + "isUnique": false + }, + "slot_optionId_idx": { + "name": "slot_optionId_idx", + "columns": ["optionId"], + "isUnique": false + } + }, + "foreignKeys": { + "slot_eventId_event_id_fk": { + "name": "slot_eventId_event_id_fk", + "tableFrom": "slot", + "tableTo": "event", + "columnsFrom": ["eventId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "slot_participantId_participant_id_fk": { + "name": "slot_participantId_participant_id_fk", + "tableFrom": "slot", + "tableTo": "participant", + "columnsFrom": ["participantId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "slot_optionId_option_id_fk": { + "name": "slot_optionId_option_id_fk", + "tableFrom": "slot", + "tableTo": "option", + "columnsFrom": ["optionId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/server/migrations/meta/_journal.json b/server/migrations/meta/_journal.json index bfab567..075de98 100644 --- a/server/migrations/meta/_journal.json +++ b/server/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1792696953969, "tag": "0005_rename_columns", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1792697240075, + "tag": "0006_merge_host_guest_into_participant", + "breakpoints": true } ] } diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 721a6ba..1887a88 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -1,5 +1,5 @@ -import { relations } from "drizzle-orm"; -import { index, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { relations, sql } from "drizzle-orm"; +import { check, index, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; const uuid = () => crypto.randomUUID(); @@ -35,7 +35,7 @@ export const allowedRange = sqliteTable( (t) => [index("allowedRange_eventId_idx").on(t.eventId)], ); -/** あるゲストが参加可能な時間帯の 1 つ。日を跨がない。 */ +/** ある参加者が参加可能な時間帯の 1 つ。日を跨がない。 */ export const slot = sqliteTable( "slot", { @@ -47,53 +47,43 @@ export const slot = sqliteTable( eventId: text() .notNull() .references(() => project.id, { onDelete: "cascade", onUpdate: "cascade" }), - // Postgres では RESTRICT だったが、SQLite の RESTRICT は即時に検査されるため、Project の削除からの CASCADE で - // Guest と Slot を同時に消すと失敗しうる。文の終わりに検査する NO ACTION (既定) にする - guestId: text() + participantId: text() .notNull() - .references(() => guest.id, { onUpdate: "cascade" }), + .references(() => participant.id, { onDelete: "cascade", onUpdate: "cascade" }), optionId: text() .notNull() .references(() => participationOption.id, { onUpdate: "cascade" }), }, (t) => [ index("slot_eventId_startAt_idx").on(t.eventId, t.startAt), - index("slot_guestId_idx").on(t.guestId), + index("slot_participantId_idx").on(t.participantId), index("slot_optionId_idx").on(t.optionId), ], ); -/** 日程調整プロジェクトの作成者。 */ -export const host = sqliteTable( - "Host", +/** + * イベントの参加者。ホスト (作成者) とゲストを role で区別する。 + * name が null なら、まだ回答していない (イベントを作っただけのホスト)。 + */ +export const participant = sqliteTable( + "participant", { id: text().primaryKey().$defaultFn(uuid), - browserId: text().notNull(), - projectId: text() + eventId: text() .notNull() .references(() => project.id, { onDelete: "cascade", onUpdate: "cascade" }), - }, - (t) => [ - uniqueIndex("Host_browserId_projectId_key").on(t.browserId, t.projectId), - index("Host_projectId_idx").on(t.projectId), - ], -); - -/** 日程調整プロジェクトの参加者。 */ -export const guest = sqliteTable( - "Guest", - { - id: text().primaryKey().$defaultFn(uuid), - name: text().notNull(), + role: text({ enum: ["host", "guest"] }).notNull(), + name: text(), comment: text(), browserId: text().notNull(), - projectId: text() + updatedAt: text() .notNull() - .references(() => project.id, { onDelete: "cascade", onUpdate: "cascade" }), + .$defaultFn(() => new Date().toISOString()) + .$onUpdateFn(() => new Date().toISOString()), }, (t) => [ - uniqueIndex("Guest_browserId_projectId_key").on(t.browserId, t.projectId), - index("Guest_projectId_idx").on(t.projectId), + uniqueIndex("participant_eventId_browserId_key").on(t.eventId, t.browserId), + check("participant_role_check", sql`${t.role} IN ('host', 'guest')`), ], ); @@ -114,8 +104,7 @@ export const participationOption = sqliteTable( export const projectRelations = relations(project, ({ many }) => ({ allowedRanges: many(allowedRange), slots: many(slot), - hosts: many(host), - guests: many(guest), + participants: many(participant), participationOptions: many(participationOption), })); @@ -125,19 +114,15 @@ export const allowedRangeRelations = relations(allowedRange, ({ one }) => ({ export const slotRelations = relations(slot, ({ one }) => ({ project: one(project, { fields: [slot.eventId], references: [project.id] }), - guest: one(guest, { fields: [slot.guestId], references: [guest.id] }), + participant: one(participant, { fields: [slot.participantId], references: [participant.id] }), participationOption: one(participationOption, { fields: [slot.optionId], references: [participationOption.id], }), })); -export const hostRelations = relations(host, ({ one }) => ({ - project: one(project, { fields: [host.projectId], references: [project.id] }), -})); - -export const guestRelations = relations(guest, ({ one, many }) => ({ - project: one(project, { fields: [guest.projectId], references: [project.id] }), +export const participantRelations = relations(participant, ({ one, many }) => ({ + project: one(project, { fields: [participant.eventId], references: [project.id] }), slots: many(slot), })); diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index dcb6222..8779d2e 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -57,8 +57,8 @@ const router = new Hono() async (c) => { const { projectId } = c.req.valid("param"); const { name, comment, slots } = c.req.valid("json"); - await submitAvailability(c.get("db"), c.get("browserId"), projectId, { name, comment, slots }); - return c.json("日程が提出されました。", 201); + const guest = await submitAvailability(c.get("db"), c.get("browserId"), projectId, { name, comment, slots }); + return c.json({ guest }, 201); }, ) @@ -71,7 +71,7 @@ const router = new Hono() const { projectId } = c.req.valid("param"); const { name, comment, slots } = c.req.valid("json"); const guest = await updateMyAvailability(c.get("db"), c.get("browserId"), projectId, { name, comment, slots }); - return c.json({ message: "日程が更新されました。", guest }, 200); + return c.json({ guest }, 200); }, ); diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index f0468b5..c856682 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray, notInArray, or } from "drizzle-orm"; +import { and, eq, inArray, isNotNull, notInArray } from "drizzle-orm"; import type { z } from "zod"; import type { editReqSchema, projectReqSchema, submitReqSchema } from "../../../common/validators.js"; import { type Db, insertMany, runBatch } from "../db/index.js"; @@ -9,7 +9,7 @@ import { validateSlots } from "./slots.js"; export type CreateProjectInput = z.infer; export type UpdateProjectInput = z.infer; -export type SubmissionInput = Omit, "projectId">; +export type SubmissionInput = z.infer; /** DB には日時を ISO 8601 の文字列で入れる */ function toIso(value: string | Date): string { @@ -32,7 +32,7 @@ export async function createProject(db: Db, browserId: string, input: CreateProj schema.allowedRange, input.allowedRanges.map((range) => ({ ...range, eventId: project.id })), ), - db.insert(schema.host).values({ browserId, projectId: project.id }), + db.insert(schema.participant).values({ eventId: project.id, role: "host", browserId }), ...insertMany( db, schema.participationOption, @@ -51,19 +51,17 @@ export async function createProject(db: Db, browserId: string, input: CreateProj /** ホストまたはゲストとして関わるプロジェクト */ export async function listMyProjects(db: Db, browserId: string) { const projects = await db.query.project.findMany({ - where: or( - inArray( - schema.project.id, - db.select({ id: schema.host.projectId }).from(schema.host).where(eq(schema.host.browserId, browserId)), - ), - inArray( - schema.project.id, - db.select({ id: schema.guest.projectId }).from(schema.guest).where(eq(schema.guest.browserId, browserId)), - ), + where: inArray( + schema.project.id, + db + .select({ id: schema.participant.eventId }) + .from(schema.participant) + .where(eq(schema.participant.browserId, browserId)), ), with: { - hosts: { - columns: { browserId: true }, + participants: { + where: eq(schema.participant.browserId, browserId), + columns: { role: true }, }, }, }); @@ -75,7 +73,7 @@ export async function listMyProjects(db: Db, browserId: string) { startDate: p.startDate, endDate: p.endDate, timezone: p.timezone, - isHost: p.hosts.some((host) => host.browserId === browserId), + isHost: p.participants.some((me) => me.role === "host"), })); } @@ -86,12 +84,11 @@ export async function getProject(db: Db, browserId: string, projectId: string) { with: { allowedRanges: true, participationOptions: true, - guests: { + participants: { with: { slots: true, }, }, - hosts: true, }, }); @@ -99,7 +96,7 @@ export async function getProject(db: Db, browserId: string, projectId: string) { throw new ServiceError("project_not_found", "イベントが見つかりません。"); } - const guest = project.guests.find((g) => g.browserId === browserId); + const me = project.participants.find((p) => p.browserId === browserId); return { id: project.id, @@ -110,25 +107,49 @@ export async function getProject(db: Db, browserId: string, projectId: string) { timezone: project.timezone, allowedRanges: project.allowedRanges, participationOptions: project.participationOptions, - hosts: project.hosts.map(omitBrowserId), - guests: project.guests.map(omitBrowserId), - isHost: project.hosts.some((h) => h.browserId === browserId), - meAsGuest: guest ? omitBrowserId(guest) : null, + hosts: project.participants.filter((p) => p.role === "host").map((p) => ({ id: p.id, eventId: p.eventId })), + guests: project.participants.filter(hasAnswered).map(toGuest), + isHost: me?.role === "host", + meAsGuest: me && hasAnswered(me) ? toGuest(me) : null, }; } -function omitBrowserId(entity: T): Omit { - const { browserId: _, ...rest } = entity; - return rest; +type ParticipantWithSlots = typeof schema.participant.$inferSelect & { slots: (typeof schema.slot.$inferSelect)[] }; + +/** 回答したか。name が null なら、まだ回答していない (イベントを作っただけのホスト) */ +function hasAnswered(participant: T): participant is T & { name: string } { + return participant.name !== null; +} + +/** API では、回答した参加者を guest として返す。Slot の participantId は guestId として返す */ +function toGuest(participant: ParticipantWithSlots & { name: string }) { + return { + id: participant.id, + name: participant.name, + comment: participant.comment, + eventId: participant.eventId, + slots: participant.slots.map(({ participantId, ...slot }) => ({ ...slot, guestId: participantId })), + }; +} + +async function isHost(db: Db, browserId: string, projectId: string) { + const host = await db.query.participant.findFirst({ + where: and( + eq(schema.participant.eventId, projectId), + eq(schema.participant.browserId, browserId), + eq(schema.participant.role, "host"), + ), + columns: { id: true }, + }); + return host !== undefined; } export async function updateProject(db: Db, browserId: string, projectId: string, input: UpdateProjectInput) { const [host, existingGuest] = await Promise.all([ - db.query.host.findFirst({ - where: and(eq(schema.host.browserId, browserId), eq(schema.host.projectId, projectId)), - }), - db.query.guest.findFirst({ - where: eq(schema.guest.projectId, projectId), + isHost(db, browserId, projectId), + db.query.participant.findFirst({ + where: and(eq(schema.participant.eventId, projectId), isNotNull(schema.participant.name)), + columns: { id: true }, }), ]); @@ -212,11 +233,7 @@ export async function updateProject(db: Db, browserId: string, projectId: string } export async function deleteProject(db: Db, browserId: string, projectId: string) { - const host = await db.query.host.findFirst({ - where: and(eq(schema.host.browserId, browserId), eq(schema.host.projectId, projectId)), - }); - - if (!host) { + if (!(await isHost(db, browserId, projectId))) { throw new ServiceError("not_host", "削除権限がありません。"); } @@ -245,66 +262,69 @@ async function findProjectForSlots(db: Db, projectId: string) { }; } -/** 自分の回答。呼び出し元がそのまま返せるよう、browserId は含めない */ -function findGuest(db: Db, browserId: string, projectId: string) { - return db.query.guest.findFirst({ - where: and(eq(schema.guest.browserId, browserId), eq(schema.guest.projectId, projectId)), - columns: { browserId: false }, +function findMyParticipant(db: Db, browserId: string, projectId: string) { + return db.query.participant.findFirst({ + where: and(eq(schema.participant.browserId, browserId), eq(schema.participant.eventId, projectId)), with: { slots: true }, }); } -function slotRows(input: SubmissionInput, projectId: string, guestId: string) { +/** 自分の回答。回答していなければ null */ +async function findMyGuest(db: Db, browserId: string, projectId: string) { + const me = await findMyParticipant(db, browserId, projectId); + return me && hasAnswered(me) ? toGuest(me) : null; +} + +function slotRows(input: SubmissionInput, projectId: string, participantId: string) { return input.slots.map((slot) => ({ startAt: toIso(slot.startAt), endAt: toIso(slot.endAt), eventId: projectId, - guestId, + participantId, optionId: slot.optionId, })); } export async function submitAvailability(db: Db, browserId: string, projectId: string, input: SubmissionInput) { - const existingGuest = await findGuest(db, browserId, projectId); - if (existingGuest) { + // ホストはイベントを作った時点で participant があり、名前を入れると回答したことになる + const me = await findMyParticipant(db, browserId, projectId); + if (me && hasAnswered(me)) { throw new ServiceError("already_submitted", "提出済みです。"); } validateSlots(await findProjectForSlots(db, projectId), input.slots); - const guestId = crypto.randomUUID(); + const answer = { name: input.name, comment: input.comment?.trim() || null }; + const participantId = me?.id ?? crypto.randomUUID(); await runBatch(db, [ - db.insert(schema.guest).values({ - id: guestId, - name: input.name, - comment: input.comment?.trim() || null, - browserId, - projectId, - }), - ...insertMany(db, schema.slot, slotRows(input, projectId, guestId)), + me + ? db.update(schema.participant).set(answer).where(eq(schema.participant.id, me.id)) + : db + .insert(schema.participant) + .values({ id: participantId, eventId: projectId, role: "guest", browserId, ...answer }), + ...insertMany(db, schema.slot, slotRows(input, projectId, participantId)), ]); - return findGuest(db, browserId, projectId); + return findMyGuest(db, browserId, projectId); } export async function updateMyAvailability(db: Db, browserId: string, projectId: string, input: SubmissionInput) { - const existingGuest = await findGuest(db, browserId, projectId); + const myGuest = await findMyGuest(db, browserId, projectId); - if (!existingGuest) { + if (!myGuest) { throw new ServiceError("submission_not_found", "既存の日程が見つかりません。"); } validateSlots(await findProjectForSlots(db, projectId), input.slots); - // Prisma 版では Slot の削除と回答の更新が別のクエリだった。batch で 1 つのトランザクションにまとめる await runBatch(db, [ - db.delete(schema.slot).where(eq(schema.slot.guestId, existingGuest.id)), - ...insertMany(db, schema.slot, slotRows(input, projectId, existingGuest.id)), + db.delete(schema.slot).where(eq(schema.slot.participantId, myGuest.id)), + ...insertMany(db, schema.slot, slotRows(input, projectId, myGuest.id)), db - .update(schema.guest) + .update(schema.participant) .set({ name: input.name, comment: input.comment?.trim() || null }) - .where(eq(schema.guest.id, existingGuest.id)), + .where(eq(schema.participant.id, myGuest.id)), ]); - return findGuest(db, browserId, projectId); + return findMyGuest(db, browserId, projectId); } diff --git a/server/test/helpers.ts b/server/test/helpers.ts index c2b8312..b4f0c27 100644 --- a/server/test/helpers.ts +++ b/server/test/helpers.ts @@ -63,10 +63,9 @@ export async function createProject(host: Browser, overrides: Record = {}) { +export function submissionInput(optionId: string, overrides: Record = {}) { return { name: "ゲスト", - projectId, comment: "コメント", slots: [{ ...slotOnNov2, optionId }], ...overrides, diff --git a/server/test/migrations.test.ts b/server/test/migrations.test.ts index b7cd715..3cfb1e5 100644 --- a/server/test/migrations.test.ts +++ b/server/test/migrations.test.ts @@ -2,26 +2,27 @@ import { describe, expect, test } from "vitest"; import { migrateUntil, tableNames } from "./migrations.js"; describe("migrateUntil", () => { - test("指定したマイグレーションの直前まで当て、migrate() で残りを当てる", async () => { + test("指定したマイグレーションの直前まで当て、migrate() でそのマイグレーションだけを当てる", async () => { const { db, migrate } = await migrateUntil("0000_init.sql"); expect(await tableNames(db)).toEqual(["d1_migrations"]); await migrate(); - expect(await tableNames(db)).toEqual(expect.arrayContaining(["event", "Guest", "slot"])); + const names = await tableNames(db); + expect(names).toEqual(expect.arrayContaining(["Project", "Guest", "Slot"])); + // 後のマイグレーションで作るテーブルは無い + expect(names).not.toContain("participant"); }); test("前のテストのデータを残さない", async () => { const first = await migrateUntil("0000_init.sql"); await first.migrate(); - await first.db - .prepare(`INSERT INTO "event" (id, name, startDate, endDate, createdAt) VALUES ('p', 'name', 's', 'e', 'c')`) - .run(); + await first.db.prepare(`INSERT INTO "Project" (id, name, startDate, endDate) VALUES ('p', 'name', 's', 'e')`).run(); const second = await migrateUntil("0000_init.sql"); await second.migrate(); - expect(await second.db.prepare(`SELECT * FROM "event"`).all()).toMatchObject({ results: [] }); + expect(await second.db.prepare(`SELECT * FROM "Project"`).all()).toMatchObject({ results: [] }); }); }); @@ -41,13 +42,13 @@ describe("0001_add_project_timezone_created_at", () => { await migrate(); const project = await db - .prepare(`SELECT timezone, createdAt FROM "event"`) + .prepare(`SELECT timezone, createdAt FROM "Project"`) .first<{ timezone: string; createdAt: string }>(); expect(project?.timezone).toBe("Asia/Tokyo"); expect(project?.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); expect(new Date(project?.createdAt ?? "").getTime()).toBeGreaterThanOrEqual(Math.floor(before / 1000) * 1000); // 子のテーブルの行は残る - expect(await db.prepare(`SELECT id FROM "allowedRange"`).all()).toMatchObject({ results: [{ id: "a" }] }); + expect(await db.prepare(`SELECT id FROM "AllowedRange"`).all()).toMatchObject({ results: [{ id: "a" }] }); }); }); @@ -66,7 +67,7 @@ describe("0002_allowed_range_time_of_day", () => { await migrate(); - expect(await db.prepare(`SELECT startTime, endTime FROM "allowedRange"`).first()).toEqual({ + expect(await db.prepare(`SELECT startTime, endTime FROM "AllowedRange"`).first()).toEqual({ startTime: "08:00", endTime: "23:00", }); @@ -85,7 +86,7 @@ describe("0003_project_dates", () => { await migrate(); - expect(await db.prepare(`SELECT startDate, endDate FROM "event"`).first()).toEqual({ + expect(await db.prepare(`SELECT startDate, endDate FROM "Project"`).first()).toEqual({ startDate: "2026-11-02", endDate: "2026-11-04", }); @@ -176,3 +177,84 @@ describe("0005_rename_columns", () => { expect(await db.prepare(`SELECT count(*) AS n FROM "allowedRange"`).first()).toEqual({ n: 0 }); }); }); + +describe("0006_merge_host_guest_into_participant", () => { + test("Host と Guest を participant にまとめ、slot を participant に付け替える", async () => { + const { db, migrate } = await migrateUntil("0006_merge_host_guest_into_participant.sql"); + await db.batch([ + db.prepare(`INSERT INTO "event" (id, name, startDate, endDate) VALUES ('p', 'name', '2026-11-02', '2026-11-04')`), + db.prepare(`INSERT INTO "option" VALUES ('o', '参加', '#0F82B1', 'p')`), + // ホストかつゲスト、ホストだけ、ゲストだけ + db.prepare(`INSERT INTO "Host" VALUES ('h1', 'both', 'p')`), + db.prepare(`INSERT INTO "Guest" VALUES ('g1', 'ホスト', 'よろしく', 'both', 'p')`), + db.prepare(`INSERT INTO "Host" VALUES ('h2', 'hostOnly', 'p')`), + db.prepare(`INSERT INTO "Guest" VALUES ('g2', 'ゲスト', NULL, 'guestOnly', 'p')`), + db.prepare( + `INSERT INTO "slot" VALUES ('s1', '2026-11-02T00:00:00.000Z', '2026-11-02T01:00:00.000Z', 'p', 'g1', 'o')`, + ), + db.prepare( + `INSERT INTO "slot" VALUES ('s2', '2026-11-02T01:00:00.000Z', '2026-11-02T02:00:00.000Z', 'p', 'g2', 'o')`, + ), + ]); + const before = Date.now(); + + await migrate(); + + const { results: participants } = await db + .prepare(`SELECT id, eventId, role, name, comment, browserId, updatedAt FROM "participant" ORDER BY browserId`) + .all<{ updatedAt: string }>(); + expect(participants).toEqual([ + { + id: "g1", + eventId: "p", + role: "host", + name: "ホスト", + comment: "よろしく", + browserId: "both", + updatedAt: expect.any(String), + }, + { + id: "g2", + eventId: "p", + role: "guest", + name: "ゲスト", + comment: null, + browserId: "guestOnly", + updatedAt: expect.any(String), + }, + { + id: "h2", + eventId: "p", + role: "host", + name: null, + comment: null, + browserId: "hostOnly", + updatedAt: expect.any(String), + }, + ]); + for (const p of participants) { + expect(new Date(p.updatedAt).getTime()).toBeGreaterThanOrEqual(Math.floor(before / 1000) * 1000); + } + const { results: slots } = await db.prepare(`SELECT id, participantId FROM "slot" ORDER BY id`).all(); + expect(slots).toEqual([ + { id: "s1", participantId: "g1" }, + { id: "s2", participantId: "g2" }, + ]); + const names = await tableNames(db); + expect(names).not.toContain("Host"); + expect(names).not.toContain("Guest"); + const { results: fks } = await db.prepare(`PRAGMA foreign_key_list("slot")`).all<{ table: string }>(); + expect(fks.map((r) => r.table).sort()).toEqual(["event", "option", "participant"]); + + // 同じイベントに同じ browserId の参加者は作れず、role は host か guest だけ + await expect( + db.prepare(`INSERT INTO "participant" VALUES ('x', 'p', 'guest', NULL, NULL, 'both', 'now')`).run(), + ).rejects.toThrow(/UNIQUE/); + await expect( + db.prepare(`INSERT INTO "participant" VALUES ('y', 'p', 'owner', NULL, NULL, 'other', 'now')`).run(), + ).rejects.toThrow(/CHECK/); + // 参加者を消すと、その Slot も消える + await db.prepare(`DELETE FROM "participant" WHERE id = 'g2'`).run(); + expect(await db.prepare(`SELECT id FROM "slot"`).all()).toMatchObject({ results: [{ id: "s1" }] }); + }); +}); diff --git a/server/test/migrations.ts b/server/test/migrations.ts index fca3dca..05822f1 100644 --- a/server/test/migrations.ts +++ b/server/test/migrations.ts @@ -3,7 +3,8 @@ import { env } from "cloudflare:workers"; /** * マイグレーションのテスト用に、`name` の直前までのマイグレーションを当てた DB を用意する。 - * 返り値の `db` に旧スキーマのデータを入れてから `migrate()` で残りを当て、データの変換を確かめる。 + * 返り値の `db` に旧スキーマのデータを入れてから `migrate()` で `name` を当て、データの変換を確かめる。 + * 後のマイグレーションは当てないので、マイグレーションを足しても前のテストは影響を受けない。 * @param name マイグレーションのファイル名 (例: "0001_add_timezone.sql") */ export async function migrateUntil(name: string) { @@ -16,8 +17,8 @@ export async function migrateUntil(name: string) { return { db, - /** 残りのマイグレーションをすべて当てる */ - migrate: () => applyD1Migrations(db, env.TEST_MIGRATIONS), + /** `name` のマイグレーションを当てる */ + migrate: () => applyD1Migrations(db, env.TEST_MIGRATIONS.slice(0, index + 1)), }; } diff --git a/server/test/projects.test.ts b/server/test/projects.test.ts index 22416d6..b59e16a 100644 --- a/server/test/projects.test.ts +++ b/server/test/projects.test.ts @@ -11,7 +11,7 @@ async function getProject(browser: Browser, projectId: string) { } async function submit(guest: Browser, projectId: string, optionId: string, overrides: Record = {}) { - const res = await guest.post(`/projects/${projectId}/submissions`, submissionInput(projectId, optionId, overrides)); + const res = await guest.post(`/projects/${projectId}/submissions`, submissionInput(optionId, overrides)); expect(res.status).toBe(201); } @@ -43,7 +43,7 @@ describe("POST /projects", () => { }, ], participationOptions: [{ ...input.participationOptions[0], eventId: body.id }], - hosts: [{ id: expect.any(String), projectId: body.id }], + hosts: [{ id: expect.any(String), eventId: body.id }], guests: [], isHost: true, meAsGuest: null, @@ -197,7 +197,7 @@ describe("GET /projects/:projectId", () => { id: expect.any(String), name: "ゲスト", comment: "よろしく", - projectId: id, + eventId: id, slots: [ { id: expect.any(String), @@ -437,11 +437,12 @@ describe("POST /projects/:projectId/submissions", () => { const { id, optionId } = await createProject(createBrowser()); const guest = createBrowser(); - const res = await guest.post(`/projects/${id}/submissions`, submissionInput(id, optionId)); + const res = await guest.post(`/projects/${id}/submissions`, submissionInput(optionId)); expect(res.status).toBe(201); - expect(await res.json()).toBe("日程が提出されました。"); - expect((await getProject(guest, id)).meAsGuest).toMatchObject({ name: "ゲスト", comment: "コメント" }); + const { meAsGuest } = await getProject(guest, id); + expect(meAsGuest).toMatchObject({ name: "ゲスト", comment: "コメント" }); + expect(await res.json()).toEqual({ guest: meAsGuest }); }); test("空白だけのコメントは null になる", async () => { @@ -458,7 +459,7 @@ describe("POST /projects/:projectId/submissions", () => { const guest = createBrowser(); await submit(guest, id, optionId); - const res = await guest.post(`/projects/${id}/submissions`, submissionInput(id, optionId)); + const res = await guest.post(`/projects/${id}/submissions`, submissionInput(optionId)); expect(res.status).toBe(409); expect(await res.json()).toEqual({ message: "提出済みです。" }); @@ -473,7 +474,8 @@ describe("POST /projects/:projectId/submissions", () => { const project = await getProject(host, id); expect(project.isHost).toBe(true); expect(project.meAsGuest).not.toBeNull(); - expect(project.hosts).toEqual([{ id: expect.any(String), projectId: id }]); + // ホストとゲストを別々の人として数えない + expect(project.hosts).toEqual([{ id: project.meAsGuest.id, eventId: id }]); expect(project.guests).toEqual([project.meAsGuest]); }); @@ -490,7 +492,7 @@ describe("POST /projects/:projectId/submissions", () => { test("名前が空なら 400", async () => { const { id, optionId } = await createProject(createBrowser()); - const res = await createBrowser().post(`/projects/${id}/submissions`, submissionInput(id, optionId, { name: "" })); + const res = await createBrowser().post(`/projects/${id}/submissions`, submissionInput(optionId, { name: "" })); expect(res.status).toBe(400); }); @@ -498,7 +500,7 @@ describe("POST /projects/:projectId/submissions", () => { test("存在しないイベントに回答すると 404", async () => { const res = await createBrowser().post( `/projects/${missingProjectId}/submissions`, - submissionInput(missingProjectId, crypto.randomUUID(), { slots: [] }), + submissionInput(crypto.randomUUID(), { slots: [] }), ); expect(res.status).toBe(404); @@ -513,7 +515,7 @@ describe("日程の提出・更新での Slot の検証", () => { const res = await createBrowser().post( `/projects/${id}/submissions`, - submissionInput(id, optionId, { + submissionInput(optionId, { // 11/01 10:00〜12:00 slots: [{ startAt: "2026-11-01T01:00:00.000Z", endAt: "2026-11-01T03:00:00.000Z", optionId }], }), @@ -527,7 +529,7 @@ describe("日程の提出・更新での Slot の検証", () => { const { id } = await createProject(createBrowser()); const other = await createProject(createBrowser()); - const res = await createBrowser().post(`/projects/${id}/submissions`, submissionInput(id, other.optionId)); + const res = await createBrowser().post(`/projects/${id}/submissions`, submissionInput(other.optionId)); expect(res.status).toBe(400); }); @@ -539,7 +541,7 @@ describe("日程の提出・更新での Slot の検証", () => { const res = await guest.put( `/projects/${id}/submissions/mine`, - submissionInput(id, optionId, { + submissionInput(optionId, { name: "新しい名前", // 11/01 10:00〜12:00 slots: [{ startAt: "2026-11-01T01:00:00.000Z", endAt: "2026-11-01T03:00:00.000Z", optionId }], @@ -559,7 +561,7 @@ describe("PUT /projects/:projectId/submissions/mine", () => { test("回答していなければ 404", async () => { const { id, optionId } = await createProject(createBrowser()); - const res = await createBrowser().put(`/projects/${id}/submissions/mine`, submissionInput(id, optionId)); + const res = await createBrowser().put(`/projects/${id}/submissions/mine`, submissionInput(optionId)); expect(res.status).toBe(404); expect(await res.json()).toEqual({ message: "既存の日程が見つかりません。" }); @@ -577,17 +579,16 @@ describe("PUT /projects/:projectId/submissions/mine", () => { const res = await guest.put( `/projects/${id}/submissions/mine`, - submissionInput(id, optionId, { name: "新しい名前", comment: "", slots: [newSlot] }), + submissionInput(optionId, { name: "新しい名前", comment: "", slots: [newSlot] }), ); expect(res.status).toBe(200); expect(await res.json()).toEqual({ - message: "日程が更新されました。", guest: { id: expect.any(String), name: "新しい名前", comment: null, - projectId: id, + eventId: id, slots: [ { id: expect.any(String), @@ -622,7 +623,7 @@ describe("PUT /projects/:projectId/submissions/mine", () => { }), ); - const res = await guest.put(`/projects/${id}/submissions/mine`, submissionInput(id, optionId, { slots })); + const res = await guest.put(`/projects/${id}/submissions/mine`, submissionInput(optionId, { slots })); expect(res.status).toBe(200); expect((await getProject(guest, id)).meAsGuest.slots).toHaveLength(45); @@ -635,7 +636,7 @@ describe("PUT /projects/:projectId/submissions/mine", () => { await submit(guest, id, optionId); await submit(other, id, optionId, { name: "他の人" }); - await guest.put(`/projects/${id}/submissions/mine`, submissionInput(id, optionId, { slots: [] })); + await guest.put(`/projects/${id}/submissions/mine`, submissionInput(optionId, { slots: [] })); const { meAsGuest } = await getProject(other, id); expect(meAsGuest).toMatchObject({ name: "他の人", slots: [expect.anything()] }); diff --git a/server/test/setup.ts b/server/test/setup.ts index b17149c..a73471f 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -3,7 +3,7 @@ import { env } from "cloudflare:workers"; import { beforeEach } from "vitest"; // 外部キーで参照される側を後に消す -const tables = ["slot", "Guest", "Host", "allowedRange", "option", "event"]; +const tables = ["slot", "participant", "allowedRange", "option", "event"]; beforeEach(async () => { await applyD1Migrations(env.DB, env.TEST_MIGRATIONS); From a89941aad383b5839d9d6bc0770020112f3d9568 Mon Sep 17 00:00:00 2001 From: nakaterm <104970808+nakaterm@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:39:45 +0900 Subject: [PATCH 14/17] =?UTF-8?q?refactor:=20server=20=E3=81=AE=E5=86=85?= =?UTF-8?q?=E9=83=A8=E3=81=AE=E5=90=8D=E5=89=8D=E3=82=92=20project=20?= =?UTF-8?q?=E3=81=8B=E3=82=89=20event=20=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 画面上の呼び名 (イベント) と DB のテーブル名に合わせ、schema の export 名、 services と routes のファイル名・関数名、ServiceError の理由名を event にする。 participationOption も option にする。API の名前はまだ変えない。 Co-Authored-By: Claude Opus 5.5 --- server/src/app.ts | 6 +- server/src/db/schema.ts | 36 ++-- server/src/routes/{projects.ts => events.ts} | 38 ++-- server/src/services/errors.ts | 2 +- .../src/services/{projects.ts => events.ts} | 180 +++++++++--------- server/src/services/slots.ts | 16 +- server/test/slots.test.ts | 40 ++-- 7 files changed, 157 insertions(+), 161 deletions(-) rename server/src/routes/{projects.ts => events.ts} (68%) rename server/src/services/{projects.ts => events.ts} (58%) diff --git a/server/src/app.ts b/server/src/app.ts index 625c771..3763052 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -3,11 +3,11 @@ import { cors } from "hono/cors"; import { createDb } from "./db/index.js"; import type { AppEnv } from "./env.js"; import { browserIdMiddleware } from "./middleware/browserId.js"; -import projectsRoutes from "./routes/projects.js"; +import eventsRoutes from "./routes/events.js"; import { ServiceError, type ServiceErrorReason } from "./services/errors.js"; const serviceErrorStatus = { - project_not_found: 404, + event_not_found: 404, submission_not_found: 404, not_host: 403, already_submitted: 409, @@ -32,7 +32,7 @@ export const app = new Hono() .get("/", (c) => { return c.json({ message: "Hello! イツヒマ?" }); }) - .route("/projects", projectsRoutes) + .route("/projects", eventsRoutes) .onError((err, c) => { if (err instanceof ServiceError) { return c.json({ message: err.message }, serviceErrorStatus[err.reason]); diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 1887a88..414852c 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -3,8 +3,8 @@ import { check, index, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite const uuid = () => crypto.randomUUID(); -/** 日程調整プロジェクト。ユーザには「イベント」と表示。 */ -export const project = sqliteTable("event", { +/** 日程調整のイベント。 */ +export const event = sqliteTable("event", { id: text().primaryKey(), name: text().notNull(), description: text(), @@ -19,7 +19,7 @@ export const project = sqliteTable("event", { .$defaultFn(() => new Date().toISOString()), }); -/** 日程調整プロジェクトにおいて入力が許可された時間帯。(注: 現在は 1 つの日程調整プロジェクトにつき 1 つのみ設定可能) */ +/** イベントで入力できる時間帯。(注: 現在は 1 つのイベントにつき 1 つのみ設定可能) */ export const allowedRange = sqliteTable( "allowedRange", { @@ -30,7 +30,7 @@ export const allowedRange = sqliteTable( endTime: text().notNull(), eventId: text() .notNull() - .references(() => project.id, { onDelete: "cascade", onUpdate: "cascade" }), + .references(() => event.id, { onDelete: "cascade", onUpdate: "cascade" }), }, (t) => [index("allowedRange_eventId_idx").on(t.eventId)], ); @@ -46,13 +46,13 @@ export const slot = sqliteTable( endAt: text().notNull(), eventId: text() .notNull() - .references(() => project.id, { onDelete: "cascade", onUpdate: "cascade" }), + .references(() => event.id, { onDelete: "cascade", onUpdate: "cascade" }), participantId: text() .notNull() .references(() => participant.id, { onDelete: "cascade", onUpdate: "cascade" }), optionId: text() .notNull() - .references(() => participationOption.id, { onUpdate: "cascade" }), + .references(() => option.id, { onUpdate: "cascade" }), }, (t) => [ index("slot_eventId_startAt_idx").on(t.eventId, t.startAt), @@ -71,7 +71,7 @@ export const participant = sqliteTable( id: text().primaryKey().$defaultFn(uuid), eventId: text() .notNull() - .references(() => project.id, { onDelete: "cascade", onUpdate: "cascade" }), + .references(() => event.id, { onDelete: "cascade", onUpdate: "cascade" }), role: text({ enum: ["host", "guest"] }).notNull(), name: text(), comment: text(), @@ -88,7 +88,7 @@ export const participant = sqliteTable( ); /** 参加形態。 */ -export const participationOption = sqliteTable( +export const option = sqliteTable( "option", { id: text().primaryKey().$defaultFn(uuid), @@ -96,37 +96,37 @@ export const participationOption = sqliteTable( color: text().notNull(), eventId: text() .notNull() - .references(() => project.id, { onDelete: "cascade", onUpdate: "cascade" }), + .references(() => event.id, { onDelete: "cascade", onUpdate: "cascade" }), }, (t) => [uniqueIndex("option_eventId_label_key").on(t.eventId, t.label)], ); -export const projectRelations = relations(project, ({ many }) => ({ +export const eventRelations = relations(event, ({ many }) => ({ allowedRanges: many(allowedRange), slots: many(slot), participants: many(participant), - participationOptions: many(participationOption), + options: many(option), })); export const allowedRangeRelations = relations(allowedRange, ({ one }) => ({ - project: one(project, { fields: [allowedRange.eventId], references: [project.id] }), + event: one(event, { fields: [allowedRange.eventId], references: [event.id] }), })); export const slotRelations = relations(slot, ({ one }) => ({ - project: one(project, { fields: [slot.eventId], references: [project.id] }), + event: one(event, { fields: [slot.eventId], references: [event.id] }), participant: one(participant, { fields: [slot.participantId], references: [participant.id] }), - participationOption: one(participationOption, { + option: one(option, { fields: [slot.optionId], - references: [participationOption.id], + references: [option.id], }), })); export const participantRelations = relations(participant, ({ one, many }) => ({ - project: one(project, { fields: [participant.eventId], references: [project.id] }), + event: one(event, { fields: [participant.eventId], references: [event.id] }), slots: many(slot), })); -export const participationOptionRelations = relations(participationOption, ({ one, many }) => ({ - project: one(project, { fields: [participationOption.eventId], references: [project.id] }), +export const optionRelations = relations(option, ({ one, many }) => ({ + event: one(event, { fields: [option.eventId], references: [event.id] }), slots: many(slot), })); diff --git a/server/src/routes/projects.ts b/server/src/routes/events.ts similarity index 68% rename from server/src/routes/projects.ts rename to server/src/routes/events.ts index 8779d2e..1c562b5 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/events.ts @@ -4,48 +4,48 @@ import { z } from "zod"; import { editReqSchema, projectReqSchema, submitReqSchema } from "../../../common/validators.js"; import type { AppEnv } from "../env.js"; import { - createProject, - deleteProject, - getProject, - listMyProjects, + createEvent, + deleteEvent, + getEvent, + listMyEvents, submitAvailability, + updateEvent, updateMyAvailability, - updateProject, -} from "../services/projects.js"; +} from "../services/events.js"; const projectIdParamsSchema = z.object({ projectId: z.string().length(21) }); const router = new Hono() - // プロジェクト作成 + // イベント作成 .post("/", zValidator("json", projectReqSchema), async (c) => { - const project = await createProject(c.get("db"), c.get("browserId"), c.req.valid("json")); - return c.json({ id: project.id, name: project.name }, 201); + const event = await createEvent(c.get("db"), c.get("browserId"), c.req.valid("json")); + return c.json({ id: event.id, name: event.name }, 201); }) - // 自分が関連するプロジェクト取得 + // 自分が関連するイベント取得 .get("/mine", async (c) => { - const projects = await listMyProjects(c.get("db"), c.get("browserId")); - return c.json(projects, 200); + const events = await listMyEvents(c.get("db"), c.get("browserId")); + return c.json(events, 200); }) - // プロジェクト取得 + // イベント取得 .get("/:projectId", zValidator("param", projectIdParamsSchema), async (c) => { const { projectId } = c.req.valid("param"); - const project = await getProject(c.get("db"), c.get("browserId"), projectId); - return c.json(project, 200); + const event = await getEvent(c.get("db"), c.get("browserId"), projectId); + return c.json(event, 200); }) - // プロジェクト編集。省略したフィールドは変えない + // イベント編集。省略したフィールドは変えない .patch("/:projectId", zValidator("param", projectIdParamsSchema), zValidator("json", editReqSchema), async (c) => { const { projectId } = c.req.valid("param"); - const event = await updateProject(c.get("db"), c.get("browserId"), projectId, c.req.valid("json")); + const event = await updateEvent(c.get("db"), c.get("browserId"), projectId, c.req.valid("json")); return c.json({ event }, 200); }) - // プロジェクト削除 + // イベント削除 .delete("/:projectId", zValidator("param", projectIdParamsSchema), async (c) => { const { projectId } = c.req.valid("param"); - await deleteProject(c.get("db"), c.get("browserId"), projectId); + await deleteEvent(c.get("db"), c.get("browserId"), projectId); return c.body(null, 204); }) diff --git a/server/src/services/errors.ts b/server/src/services/errors.ts index 8d22d5f..ce7d51d 100644 --- a/server/src/services/errors.ts +++ b/server/src/services/errors.ts @@ -2,7 +2,7 @@ * サービス層のエラーの理由。HTTP のステータスへの変換は呼び出す側 (`app.ts` の `onError`) が行う。 */ export type ServiceErrorReason = - | "project_not_found" + | "event_not_found" | "submission_not_found" | "not_host" | "already_submitted" diff --git a/server/src/services/projects.ts b/server/src/services/events.ts similarity index 58% rename from server/src/services/projects.ts rename to server/src/services/events.ts index c856682..3d589d6 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/events.ts @@ -7,8 +7,8 @@ import { nanoid } from "../lib/id.js"; import { ServiceError } from "./errors.js"; import { validateSlots } from "./slots.js"; -export type CreateProjectInput = z.infer; -export type UpdateProjectInput = z.infer; +export type CreateEventInput = z.infer; +export type UpdateEventInput = z.infer; export type SubmissionInput = z.infer; /** DB には日時を ISO 8601 の文字列で入れる */ @@ -16,8 +16,8 @@ function toIso(value: string | Date): string { return new Date(value).toISOString(); } -export async function createProject(db: Db, browserId: string, input: CreateProjectInput) { - const project = { +export async function createEvent(db: Db, browserId: string, input: CreateEventInput) { + const event = { id: nanoid(), name: input.name, description: input.description.trim() || null, @@ -26,33 +26,33 @@ export async function createProject(db: Db, browserId: string, input: CreateProj }; await runBatch(db, [ - db.insert(schema.project).values(project), + db.insert(schema.event).values(event), ...insertMany( db, schema.allowedRange, - input.allowedRanges.map((range) => ({ ...range, eventId: project.id })), + input.allowedRanges.map((range) => ({ ...range, eventId: event.id })), ), - db.insert(schema.participant).values({ eventId: project.id, role: "host", browserId }), + db.insert(schema.participant).values({ eventId: event.id, role: "host", browserId }), ...insertMany( db, - schema.participationOption, + schema.option, input.participationOptions.map((opt) => ({ id: opt.id, label: opt.label, color: opt.color, - eventId: project.id, + eventId: event.id, })), ), ]); - return { id: project.id, name: project.name }; + return { id: event.id, name: event.name }; } -/** ホストまたはゲストとして関わるプロジェクト */ -export async function listMyProjects(db: Db, browserId: string) { - const projects = await db.query.project.findMany({ +/** ホストまたはゲストとして関わるイベント */ +export async function listMyEvents(db: Db, browserId: string) { + const events = await db.query.event.findMany({ where: inArray( - schema.project.id, + schema.event.id, db .select({ id: schema.participant.eventId }) .from(schema.participant) @@ -66,7 +66,7 @@ export async function listMyProjects(db: Db, browserId: string) { }, }); - return projects.map((p) => ({ + return events.map((p) => ({ id: p.id, name: p.name, description: p.description ?? "", @@ -77,13 +77,13 @@ export async function listMyProjects(db: Db, browserId: string) { })); } -/** プロジェクトの詳細。URL (プロジェクト ID) を知っていれば誰でも見られる。 */ -export async function getProject(db: Db, browserId: string, projectId: string) { - const project = await db.query.project.findFirst({ - where: eq(schema.project.id, projectId), +/** イベントの詳細。URL (イベント ID) を知っていれば誰でも見られる。 */ +export async function getEvent(db: Db, browserId: string, eventId: string) { + const event = await db.query.event.findFirst({ + where: eq(schema.event.id, eventId), with: { allowedRanges: true, - participationOptions: true, + options: true, participants: { with: { slots: true, @@ -92,23 +92,23 @@ export async function getProject(db: Db, browserId: string, projectId: string) { }, }); - if (!project) { - throw new ServiceError("project_not_found", "イベントが見つかりません。"); + if (!event) { + throw new ServiceError("event_not_found", "イベントが見つかりません。"); } - const me = project.participants.find((p) => p.browserId === browserId); + const me = event.participants.find((p) => p.browserId === browserId); return { - id: project.id, - name: project.name, - description: project.description ?? "", - startDate: project.startDate, - endDate: project.endDate, - timezone: project.timezone, - allowedRanges: project.allowedRanges, - participationOptions: project.participationOptions, - hosts: project.participants.filter((p) => p.role === "host").map((p) => ({ id: p.id, eventId: p.eventId })), - guests: project.participants.filter(hasAnswered).map(toGuest), + id: event.id, + name: event.name, + description: event.description ?? "", + startDate: event.startDate, + endDate: event.endDate, + timezone: event.timezone, + allowedRanges: event.allowedRanges, + participationOptions: event.options, + hosts: event.participants.filter((p) => p.role === "host").map((p) => ({ id: p.id, eventId: p.eventId })), + guests: event.participants.filter(hasAnswered).map(toGuest), isHost: me?.role === "host", meAsGuest: me && hasAnswered(me) ? toGuest(me) : null, }; @@ -132,10 +132,10 @@ function toGuest(participant: ParticipantWithSlots & { name: string }) { }; } -async function isHost(db: Db, browserId: string, projectId: string) { +async function isHost(db: Db, browserId: string, eventId: string) { const host = await db.query.participant.findFirst({ where: and( - eq(schema.participant.eventId, projectId), + eq(schema.participant.eventId, eventId), eq(schema.participant.browserId, browserId), eq(schema.participant.role, "host"), ), @@ -144,11 +144,11 @@ async function isHost(db: Db, browserId: string, projectId: string) { return host !== undefined; } -export async function updateProject(db: Db, browserId: string, projectId: string, input: UpdateProjectInput) { +export async function updateEvent(db: Db, browserId: string, eventId: string, input: UpdateEventInput) { const [host, existingGuest] = await Promise.all([ - isHost(db, browserId, projectId), + isHost(db, browserId, eventId), db.query.participant.findFirst({ - where: and(eq(schema.participant.eventId, projectId), isNotNull(schema.participant.name)), + where: and(eq(schema.participant.eventId, eventId), isNotNull(schema.participant.name)), columns: { id: true }, }), ]); @@ -166,8 +166,8 @@ export async function updateProject(db: Db, browserId: string, projectId: string } // 削除対象の参加形態に Slot が紐づいているかチェック - const existingOptions = await db.query.participationOption.findMany({ - where: eq(schema.participationOption.eventId, projectId), + const existingOptions = await db.query.option.findMany({ + where: eq(schema.option.eventId, eventId), with: { slots: { columns: { id: true } } }, }); const newOptionIds = input.participationOptions.map((o) => o.id); @@ -181,20 +181,15 @@ export async function updateProject(db: Db, browserId: string, projectId: string statements.push( // 既存の参加形態で、新しいリストにないものを削除 db - .delete(schema.participationOption) - .where( - and( - eq(schema.participationOption.eventId, projectId), - notInArray(schema.participationOption.id, newOptionIds), - ), - ), + .delete(schema.option) + .where(and(eq(schema.option.eventId, eventId), notInArray(schema.option.id, newOptionIds))), // 既存の参加形態を更新または新規作成 ...input.participationOptions.map((opt) => db - .insert(schema.participationOption) - .values({ id: opt.id, label: opt.label, color: opt.color, eventId: projectId }) + .insert(schema.option) + .values({ id: opt.id, label: opt.label, color: opt.color, eventId }) .onConflictDoUpdate({ - target: schema.participationOption.id, + target: schema.option.id, set: { label: opt.label, color: opt.color }, }), ), @@ -202,129 +197,130 @@ export async function updateProject(db: Db, browserId: string, projectId: string } // 省略したフィールドは変えない。ゲストの回答があれば、日程と時間帯は変えない - const projectFields = { + const eventFields = { name: input.name, description: input.description === undefined ? undefined : input.description.trim() || null, startDate: existingGuest ? undefined : input.startDate, endDate: existingGuest ? undefined : input.endDate, }; // Drizzle は undefined の列を更新しないが、更新する列が 1 つも無いとエラーにする - if (Object.values(projectFields).some((value) => value !== undefined)) { - statements.push(db.update(schema.project).set(projectFields).where(eq(schema.project.id, projectId))); + if (Object.values(eventFields).some((value) => value !== undefined)) { + statements.push(db.update(schema.event).set(eventFields).where(eq(schema.event.id, eventId))); } if (!existingGuest && input.allowedRanges) { statements.push( - db.delete(schema.allowedRange).where(eq(schema.allowedRange.eventId, projectId)), + db.delete(schema.allowedRange).where(eq(schema.allowedRange.eventId, eventId)), ...insertMany( db, schema.allowedRange, - input.allowedRanges.map((range) => ({ ...range, eventId: projectId })), + input.allowedRanges.map((range) => ({ ...range, eventId })), ), ); } await runBatch(db, statements); - return db.query.project.findFirst({ - where: eq(schema.project.id, projectId), + const updated = await db.query.event.findFirst({ + where: eq(schema.event.id, eventId), columns: { id: true, name: true, description: true, startDate: true, endDate: true }, - with: { allowedRanges: true, participationOptions: true }, + with: { allowedRanges: true, options: true }, }); + if (!updated) return undefined; + const { options, ...rest } = updated; + return { ...rest, participationOptions: options }; } -export async function deleteProject(db: Db, browserId: string, projectId: string) { - if (!(await isHost(db, browserId, projectId))) { +export async function deleteEvent(db: Db, browserId: string, eventId: string) { + if (!(await isHost(db, browserId, eventId))) { throw new ServiceError("not_host", "削除権限がありません。"); } - await db.delete(schema.project).where(eq(schema.project.id, projectId)); + await db.delete(schema.event).where(eq(schema.event.id, eventId)); } -/** Slot の検証に必要な分だけプロジェクトを読む */ -async function findProjectForSlots(db: Db, projectId: string) { - const project = await db.query.project.findFirst({ - where: eq(schema.project.id, projectId), +/** Slot の検証に必要な分だけイベントを読む */ +async function findEventForSlots(db: Db, eventId: string) { + const event = await db.query.event.findFirst({ + where: eq(schema.event.id, eventId), columns: { timezone: true, startDate: true, endDate: true }, with: { allowedRanges: { columns: { startTime: true, endTime: true } }, - participationOptions: { columns: { id: true } }, + options: { columns: { id: true } }, }, }); - if (!project) { - throw new ServiceError("project_not_found", "イベントが見つかりません。"); + if (!event) { + throw new ServiceError("event_not_found", "イベントが見つかりません。"); } return { - timezone: project.timezone, - startDate: project.startDate, - endDate: project.endDate, - allowedRanges: project.allowedRanges, - participationOptions: project.participationOptions, + timezone: event.timezone, + startDate: event.startDate, + endDate: event.endDate, + allowedRanges: event.allowedRanges, + options: event.options, }; } -function findMyParticipant(db: Db, browserId: string, projectId: string) { +function findMyParticipant(db: Db, browserId: string, eventId: string) { return db.query.participant.findFirst({ - where: and(eq(schema.participant.browserId, browserId), eq(schema.participant.eventId, projectId)), + where: and(eq(schema.participant.browserId, browserId), eq(schema.participant.eventId, eventId)), with: { slots: true }, }); } /** 自分の回答。回答していなければ null */ -async function findMyGuest(db: Db, browserId: string, projectId: string) { - const me = await findMyParticipant(db, browserId, projectId); +async function findMyGuest(db: Db, browserId: string, eventId: string) { + const me = await findMyParticipant(db, browserId, eventId); return me && hasAnswered(me) ? toGuest(me) : null; } -function slotRows(input: SubmissionInput, projectId: string, participantId: string) { +function slotRows(input: SubmissionInput, eventId: string, participantId: string) { return input.slots.map((slot) => ({ startAt: toIso(slot.startAt), endAt: toIso(slot.endAt), - eventId: projectId, + eventId, participantId, optionId: slot.optionId, })); } -export async function submitAvailability(db: Db, browserId: string, projectId: string, input: SubmissionInput) { +export async function submitAvailability(db: Db, browserId: string, eventId: string, input: SubmissionInput) { // ホストはイベントを作った時点で participant があり、名前を入れると回答したことになる - const me = await findMyParticipant(db, browserId, projectId); + const me = await findMyParticipant(db, browserId, eventId); if (me && hasAnswered(me)) { throw new ServiceError("already_submitted", "提出済みです。"); } - validateSlots(await findProjectForSlots(db, projectId), input.slots); + validateSlots(await findEventForSlots(db, eventId), input.slots); const answer = { name: input.name, comment: input.comment?.trim() || null }; const participantId = me?.id ?? crypto.randomUUID(); await runBatch(db, [ me ? db.update(schema.participant).set(answer).where(eq(schema.participant.id, me.id)) - : db - .insert(schema.participant) - .values({ id: participantId, eventId: projectId, role: "guest", browserId, ...answer }), - ...insertMany(db, schema.slot, slotRows(input, projectId, participantId)), + : db.insert(schema.participant).values({ id: participantId, eventId, role: "guest", browserId, ...answer }), + ...insertMany(db, schema.slot, slotRows(input, eventId, participantId)), ]); - return findMyGuest(db, browserId, projectId); + return findMyGuest(db, browserId, eventId); } -export async function updateMyAvailability(db: Db, browserId: string, projectId: string, input: SubmissionInput) { - const myGuest = await findMyGuest(db, browserId, projectId); +export async function updateMyAvailability(db: Db, browserId: string, eventId: string, input: SubmissionInput) { + const myGuest = await findMyGuest(db, browserId, eventId); if (!myGuest) { throw new ServiceError("submission_not_found", "既存の日程が見つかりません。"); } - validateSlots(await findProjectForSlots(db, projectId), input.slots); + validateSlots(await findEventForSlots(db, eventId), input.slots); await runBatch(db, [ db.delete(schema.slot).where(eq(schema.slot.participantId, myGuest.id)), - ...insertMany(db, schema.slot, slotRows(input, projectId, myGuest.id)), + ...insertMany(db, schema.slot, slotRows(input, eventId, myGuest.id)), db .update(schema.participant) .set({ name: input.name, comment: input.comment?.trim() || null }) .where(eq(schema.participant.id, myGuest.id)), ]); - return findMyGuest(db, browserId, projectId); + return findMyGuest(db, browserId, eventId); } diff --git a/server/src/services/slots.ts b/server/src/services/slots.ts index b93b9bf..16e8b6e 100644 --- a/server/src/services/slots.ts +++ b/server/src/services/slots.ts @@ -10,7 +10,7 @@ function isQuarterHour(value: Date): boolean { type SlotInput = { startAt: Date; endAt: Date; optionId: string }; -type Project = { +type EventForSlots = { /** 日付と時間帯は、このタイムゾーンの壁時計で解釈する */ timezone: string; /** "YYYY-MM-DD" */ @@ -19,20 +19,20 @@ type Project = { endDate: string; /** 時刻は "HH:mm" */ allowedRanges: { startTime: string; endTime: string }[]; - participationOptions: { id: string }[]; + options: { id: string }[]; }; /** * Slot がイベントの日程の範囲・時間帯・15 分単位に収まり、イベントの参加形態を使っているかを検証する。 * Web UI はカレンダーの構造上これを満たすが、API を直接呼ぶと満たさない Slot を作れてしまう (#91)。 */ -export function validateSlots(project: Project, slots: SlotInput[]): void { - const toWallClock = wallClockIn(project.timezone); +export function validateSlots(event: EventForSlots, slots: SlotInput[]): void { + const toWallClock = wallClockIn(event.timezone); // 時間帯は現在 1 つだけ。無ければ終日とみなす - const range = project.allowedRanges[0]; + const range = event.allowedRanges[0]; const rangeStart = range ? parseMinutes(range.startTime) : 0; const rangeEnd = range ? parseMinutes(range.endTime) : MINUTES_PER_DAY; - const optionIds = new Set(project.participationOptions.map((o) => o.id)); + const optionIds = new Set(event.options.map((o) => o.id)); slots.forEach((slot, i) => { const invalid = (message: string) => new ServiceError("invalid_slot", `${i + 1} 件目の時間帯: ${message}`); @@ -51,8 +51,8 @@ export function validateSlots(project: Project, slots: SlotInput[]): void { if (lastInstantDate !== start.date) { throw invalid("日をまたいでいます。日付ごとに分けてください。"); } - if (start.date < project.startDate || start.date > project.endDate) { - throw invalid(`イベントの日程の範囲 (${project.startDate}〜${project.endDate}) の外です。`); + if (start.date < event.startDate || start.date > event.endDate) { + throw invalid(`イベントの日程の範囲 (${event.startDate}〜${event.endDate}) の外です。`); } const endMinutes = end.date === start.date ? end.minutes : MINUTES_PER_DAY; if (start.minutes < rangeStart || endMinutes > rangeEnd) { diff --git a/server/test/slots.test.ts b/server/test/slots.test.ts index a134571..0cbd43d 100644 --- a/server/test/slots.test.ts +++ b/server/test/slots.test.ts @@ -3,14 +3,14 @@ import { ServiceError } from "../src/services/errors.js"; import { validateSlots } from "../src/services/slots.js"; // 2026/11/02〜11/04 (JST)、08:00〜23:00 のイベント。時刻はコメントに JST で書く -const project = { +const event = { timezone: "Asia/Tokyo", startDate: "2026-11-02", endDate: "2026-11-04", allowedRanges: [{ startTime: "08:00", endTime: "23:00" }], - participationOptions: [{ id: "option" }], + options: [{ id: "option" }], }; -const allDayProject = { ...project, allowedRanges: [] }; +const allDayEvent = { ...event, allowedRanges: [] }; function slot(startAt: string, endAt: string, optionId = "option") { return { startAt: new Date(startAt), endAt: new Date(endAt), optionId }; @@ -29,7 +29,7 @@ function validationError(fn: () => void): ServiceError { describe("validateSlots", () => { test("期間と時間帯に収まる Slot は通す", () => { expect(() => - validateSlots(project, [ + validateSlots(event, [ // 11/02 08:00〜10:00 (初日、時間帯の始まりちょうどから) slot("2026-11-01T23:00:00.000Z", "2026-11-02T01:00:00.000Z"), // 11/04 21:00〜23:00 (最終日、時間帯の終わりちょうどまで) @@ -39,17 +39,17 @@ describe("validateSlots", () => { }); test("Slot が 0 件なら通す", () => { - expect(() => validateSlots(project, [])).not.toThrow(); + expect(() => validateSlots(event, [])).not.toThrow(); }); test("期間の前日と翌日は弾く", () => { // 11/01 10:00〜12:00 const before = validationError(() => - validateSlots(project, [slot("2026-11-01T01:00:00.000Z", "2026-11-01T03:00:00.000Z")]), + validateSlots(event, [slot("2026-11-01T01:00:00.000Z", "2026-11-01T03:00:00.000Z")]), ); // 11/05 10:00〜12:00 const after = validationError(() => - validateSlots(project, [slot("2026-11-05T01:00:00.000Z", "2026-11-05T03:00:00.000Z")]), + validateSlots(event, [slot("2026-11-05T01:00:00.000Z", "2026-11-05T03:00:00.000Z")]), ); expect(before.reason).toBe("invalid_slot"); @@ -60,7 +60,7 @@ describe("validateSlots", () => { test("時間帯より前に始まる Slot は弾く", () => { // 11/02 07:45〜09:00 const err = validationError(() => - validateSlots(project, [slot("2026-11-01T22:45:00.000Z", "2026-11-02T00:00:00.000Z")]), + validateSlots(event, [slot("2026-11-01T22:45:00.000Z", "2026-11-02T00:00:00.000Z")]), ); expect(err.message).toBe("1 件目の時間帯: イベントの時間帯 (08:00〜23:00) の外です。"); @@ -69,7 +69,7 @@ describe("validateSlots", () => { test("時間帯より後に終わる Slot は弾く", () => { // 11/02 22:00〜23:15 const err = validationError(() => - validateSlots(project, [slot("2026-11-02T13:00:00.000Z", "2026-11-02T14:15:00.000Z")]), + validateSlots(event, [slot("2026-11-02T13:00:00.000Z", "2026-11-02T14:15:00.000Z")]), ); expect(err.reason).toBe("invalid_slot"); @@ -78,11 +78,11 @@ describe("validateSlots", () => { test("15 分単位でない Slot は弾く", () => { // 11/02 10:10〜12:00 const minutes = validationError(() => - validateSlots(project, [slot("2026-11-02T01:10:00.000Z", "2026-11-02T03:00:00.000Z")]), + validateSlots(event, [slot("2026-11-02T01:10:00.000Z", "2026-11-02T03:00:00.000Z")]), ); // 11/02 10:00:30〜12:00 const seconds = validationError(() => - validateSlots(project, [slot("2026-11-02T01:00:30.000Z", "2026-11-02T03:00:00.000Z")]), + validateSlots(event, [slot("2026-11-02T01:00:30.000Z", "2026-11-02T03:00:00.000Z")]), ); expect(minutes.reason).toBe("invalid_slot"); @@ -92,11 +92,11 @@ describe("validateSlots", () => { test("開始が終了より前でない Slot は弾く", () => { // 11/02 12:00〜12:00 const empty = validationError(() => - validateSlots(project, [slot("2026-11-02T03:00:00.000Z", "2026-11-02T03:00:00.000Z")]), + validateSlots(event, [slot("2026-11-02T03:00:00.000Z", "2026-11-02T03:00:00.000Z")]), ); // 11/02 12:00〜10:00 const reversed = validationError(() => - validateSlots(project, [slot("2026-11-02T03:00:00.000Z", "2026-11-02T01:00:00.000Z")]), + validateSlots(event, [slot("2026-11-02T03:00:00.000Z", "2026-11-02T01:00:00.000Z")]), ); expect(empty.reason).toBe("invalid_slot"); @@ -106,7 +106,7 @@ describe("validateSlots", () => { test("日をまたぐ Slot は弾く", () => { // 11/02 23:00〜11/03 01:00 const err = validationError(() => - validateSlots(allDayProject, [slot("2026-11-02T14:00:00.000Z", "2026-11-02T16:00:00.000Z")]), + validateSlots(allDayEvent, [slot("2026-11-02T14:00:00.000Z", "2026-11-02T16:00:00.000Z")]), ); expect(err.message).toBe("1 件目の時間帯: 日をまたいでいます。日付ごとに分けてください。"); @@ -115,7 +115,7 @@ describe("validateSlots", () => { // 時間帯が無いと、カレンダーは 23:45〜24:00 のセルを描画し、終わりを翌日の 0:00 として送る test("時間帯が無ければ終日とみなし、最終日の 23:45〜24:00 も通す", () => { expect(() => - validateSlots(allDayProject, [ + validateSlots(allDayEvent, [ // 11/02 00:00〜01:00 slot("2026-11-01T15:00:00.000Z", "2026-11-01T16:00:00.000Z"), // 11/04 23:45〜11/05 00:00 @@ -125,7 +125,7 @@ describe("validateSlots", () => { }); test("時間帯の終わりが 24:00 なら、24:00 に終わる Slot を通す", () => { - const untilMidnight = { ...project, allowedRanges: [{ startTime: "08:00", endTime: "24:00" }] }; + const untilMidnight = { ...event, allowedRanges: [{ startTime: "08:00", endTime: "24:00" }] }; // 11/02 23:00〜11/03 00:00 expect(() => @@ -137,15 +137,15 @@ describe("validateSlots", () => { // JST では 11/03 08:30〜09:30 だが、UTC では 11/02 23:30〜11/03 00:30 で日をまたぐ const target = slot("2026-11-02T23:30:00.000Z", "2026-11-03T00:30:00.000Z"); - expect(() => validateSlots(allDayProject, [target])).not.toThrow(); - expect(validationError(() => validateSlots({ ...allDayProject, timezone: "UTC" }, [target])).message).toBe( + expect(() => validateSlots(allDayEvent, [target])).not.toThrow(); + expect(validationError(() => validateSlots({ ...allDayEvent, timezone: "UTC" }, [target])).message).toBe( "1 件目の時間帯: 日をまたいでいます。日付ごとに分けてください。", ); }); test("イベントに無い参加形態は弾く", () => { const err = validationError(() => - validateSlots(project, [slot("2026-11-02T01:00:00.000Z", "2026-11-02T03:00:00.000Z", "other")]), + validateSlots(event, [slot("2026-11-02T01:00:00.000Z", "2026-11-02T03:00:00.000Z", "other")]), ); expect(err.message).toBe("1 件目の時間帯: このイベントに無い参加形態です。"); @@ -153,7 +153,7 @@ describe("validateSlots", () => { test("何件目の Slot が違反したかをメッセージに含める", () => { const err = validationError(() => - validateSlots(project, [ + validateSlots(event, [ // 11/02 10:00〜12:00 slot("2026-11-02T01:00:00.000Z", "2026-11-02T03:00:00.000Z"), // 11/01 10:00〜12:00 From efdd84317d532d4eb9b3ddfc4e7271eb84b458f0 Mon Sep 17 00:00:00 2001 From: nakaterm <104970808+nakaterm@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:49:47 +0900 Subject: [PATCH 15/17] =?UTF-8?q?refactor:=20API=20=E3=81=A8=20client=20?= =?UTF-8?q?=E3=81=AE=E5=90=8D=E5=89=8D=E3=82=92=20project=20=E3=81=8B?= =?UTF-8?q?=E3=82=89=20event=20=E3=81=AB=E3=81=9D=E3=82=8D=E3=81=88?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API のパスを /projects から /events に、パスの引数を :projectId から :eventId に、 参加形態のキーを participationOptions から options にする。Web UI の RPC の 呼び出しと、OG を作る Pages Functions の取得先も合わせて直す。 client の Project 型やページ・関数の名前も Event にする。Event は使うファイルで import するので、グローバルの DOM の Event 型とはぶつからない。 Pages Functions は API を /events で呼ぶので、Pages と Workers は同時にデプロイする。 Co-Authored-By: Claude Opus 5.5 --- client/functions/[[path]].ts | 24 +- client/functions/og/[eventId].tsx | 22 +- client/src/App.tsx | 6 +- client/src/components/AddToCalendar.tsx | 18 +- client/src/lib/generateIcs.ts | 8 +- .../src/pages/{Project.tsx => EventForm.tsx} | 174 ++++++------ client/src/pages/Home.tsx | 62 ++-- client/src/pages/eventId/Submission.tsx | 134 ++++----- client/src/revivers.ts | 44 +-- client/src/types.ts | 15 +- common/validators.ts | 12 +- server/src/app.ts | 2 +- server/src/routes/events.ts | 40 +-- server/src/services/events.ts | 23 +- .../test/{projects.test.ts => events.test.ts} | 267 +++++++++--------- server/test/helpers.ts | 12 +- 16 files changed, 425 insertions(+), 438 deletions(-) rename client/src/pages/{Project.tsx => EventForm.tsx} (85%) rename server/test/{projects.test.ts => events.test.ts} (62%) diff --git a/client/functions/[[path]].ts b/client/functions/[[path]].ts index 1d6397c..4031eb4 100644 --- a/client/functions/[[path]].ts +++ b/client/functions/[[path]].ts @@ -4,14 +4,14 @@ interface Env { API_ENDPOINT: string; } -interface ProjectData { +interface EventData { id: string; name: string; startDate: string; endDate: string; allowedRanges: Array<{ id: string; - projectId: string; + eventId: string; startTime: string; endTime: string; }>; @@ -71,16 +71,16 @@ function formatDate(dateString: string): string { .replace(/-/g, "/"); } -// プロジェクト情報を取得 -async function fetchProjectData(eventId: string, apiEndpoint: string): Promise { +// イベント情報を取得 +async function fetchEventData(eventId: string, apiEndpoint: string): Promise { try { - const response = await fetch(`${apiEndpoint}/projects/${eventId}`); + const response = await fetch(`${apiEndpoint}/events/${eventId}`); if (!response.ok) { return null; } return await response.json(); } catch (error) { - console.error("Failed to fetch project data:", error); + console.error("Failed to fetch event data:", error); return null; } } @@ -140,21 +140,21 @@ export async function onRequest(context: EventContext): Promise { +async function fetchEventData(eventId: string, apiEndpoint: string): Promise { try { - const response = await fetch(`${apiEndpoint}/projects/${eventId}`); + const response = await fetch(`${apiEndpoint}/events/${eventId}`); if (!response.ok) { return null; } return await response.json(); } catch (error) { - console.error("Failed to fetch project data:", error); + console.error("Failed to fetch event data:", error); return null; } } @@ -75,10 +75,10 @@ export const onRequestGet: PagesFunction = async (context) => { return new Response("Invalid event ID", { status: 400 }); } - const projectData = await fetchProjectData(eventId, env.API_ENDPOINT); + const eventData = await fetchEventData(eventId, env.API_ENDPOINT); - if (!projectData) { - // プロジェクトがない場合はデフォルト画像にリダイレクト + if (!eventData) { + // イベントがない場合はデフォルト画像にリダイレクト const url = new URL(context.request.url); return Response.redirect(`${url.origin}/og-image.jpg`, 302); } @@ -87,10 +87,10 @@ export const onRequestGet: PagesFunction = async (context) => { "https://unpkg.com/@fontsource/m-plus-1p@5.2.8/files/m-plus-1p-japanese-700-normal.woff", ).then((res) => res.arrayBuffer()); - const projectName = truncateByWidth(projectData.name, 72); + const eventName = truncateByWidth(eventData.name, 72); - const startDate = formatDate(projectData.startDate); - const endDate = formatDate(projectData.endDate); + const startDate = formatDate(eventData.startDate); + const endDate = formatDate(eventData.endDate); const dateRange = startDate === endDate ? startDate : `${startDate} 〜 ${endDate}`; const response = new ImageResponse( @@ -134,7 +134,7 @@ export const onRequestGet: PagesFunction = async (context) => { wordBreak: "break-word", }} > - {projectName} + {eventName} } /> } /> - } /> + } /> }> } /> - } /> + } /> } /> diff --git a/client/src/components/AddToCalendar.tsx b/client/src/components/AddToCalendar.tsx index 03ca72c..e5849ba 100644 --- a/client/src/components/AddToCalendar.tsx +++ b/client/src/components/AddToCalendar.tsx @@ -5,9 +5,9 @@ import { generateIcs } from "../lib/generateIcs"; import type { Slot } from "../types"; type Props = { - projectName: string; - projectId: string; - projectDescription?: string | null; + eventName: string; + eventId: string; + eventDescription?: string | null; slots: Pick[]; participationOptionIdToLabel: Record; // 参加形態がデフォルト値のみ(=実質未設定)かどうかの判定に使う @@ -20,17 +20,17 @@ type Props = { * カレンダーの縦幅を圧迫しないよう、タイトル行に並べて置く想定(モバイルではアイコンのみ)。 */ export function AddToCalendar({ - projectName, - projectId, - projectDescription, + eventName, + eventId, + eventDescription, slots, participationOptionIdToLabel, participationOptionCount, }: Props) { const handleClick = () => { - const eventUrl = `${window.location.origin}/e/${projectId}`; + const eventUrl = `${window.location.origin}/e/${eventId}`; const icsContent = generateIcs( - projectName, + eventName, slots.map((slot) => { const label = participationOptionIdToLabel[slot.participationOptionId] ?? ""; // 参加形態がデフォルト値のみで他に選択肢がない場合、タイトルには含めない @@ -42,7 +42,7 @@ export function AddToCalendar({ }; }), eventUrl, - projectDescription ?? undefined, + eventDescription ?? undefined, ); const blob = new Blob([icsContent], { type: "text/calendar;charset=utf-8" }); diff --git a/client/src/lib/generateIcs.ts b/client/src/lib/generateIcs.ts index ff9fb94..a31e123 100644 --- a/client/src/lib/generateIcs.ts +++ b/client/src/lib/generateIcs.ts @@ -13,22 +13,22 @@ function toUtcDateArray(date: Dayjs): DateArray { } export function generateIcs( - projectName: string, + eventName: string, slots: SlotForIcs[], eventUrl?: string, - projectDescription?: string, + eventDescription?: string, ): string { const description = [ "イツヒマで提出した参加候補日程です。", eventUrl ? `イベントページ: ${eventUrl}` : null, - projectDescription ? `\n${projectDescription}` : null, + eventDescription ? `\n${eventDescription}` : null, ] .filter(Boolean) .join("\n"); const { error, value } = createEvents( slots.map((slot) => ({ - title: `【候補】${projectName}${slot.label ? `(${slot.label})` : ""} - イツヒマ`, + title: `【候補】${eventName}${slot.label ? `(${slot.label})` : ""} - イツヒマ`, description, start: toUtcDateArray(slot.from), startInputType: "utc", diff --git a/client/src/pages/Project.tsx b/client/src/pages/EventForm.tsx similarity index 85% rename from client/src/pages/Project.tsx rename to client/src/pages/EventForm.tsx index a3d2212..4565ab6 100644 --- a/client/src/pages/Project.tsx +++ b/client/src/pages/EventForm.tsx @@ -17,38 +17,38 @@ import { import { NavLink, useNavigate, useParams } from "react-router"; import type { z } from "zod"; import { DEFAULT_PARTICIPATION_OPTION, generateDistinctColor } from "../../../common/colors"; -import { editReqSchema, projectReqSchema } from "../../../common/validators"; +import { editEventReqSchema, eventReqSchema } from "../../../common/validators"; import type { AppType } from "../../../server/src/main"; import Header from "../components/Header"; import { EXTERNAL_LINKS } from "../constants/links"; import dayjs from "../lib/dayjs"; -import { projectReviver } from "../revivers"; -import type { Project } from "../types"; +import { eventReviver } from "../revivers"; +import type { Event } from "../types"; import { API_ENDPOINT, FRONTEND_ORIGIN, readErrorMessage } from "../utils"; const client = hc(API_ENDPOINT); -export default function ProjectPage() { +export default function EventFormPage() { const { eventId } = useParams(); const navigate = useNavigate(); - const formSchema = eventId ? editReqSchema : projectReqSchema; + const formSchema = eventId ? editEventReqSchema : eventReqSchema; type FormSchemaType = z.infer; - const [project, setProject] = useState(null); - const [projectLoading, setProjectLoading] = useState(true); + const [event, setEvent] = useState(null); + const [eventLoading, setEventLoading] = useState(true); - const fetchProject = useCallback(async () => { + const fetchEvent = useCallback(async () => { if (!eventId) { - setProject(null); - setProjectLoading(false); + setEvent(null); + setEventLoading(false); return; } - setProjectLoading(true); + setEventLoading(true); try { - const res = await client.projects[":projectId"].$get( + const res = await client.events[":eventId"].$get( { - param: { projectId: eventId }, + param: { eventId }, }, { init: { credentials: "include" }, @@ -56,10 +56,10 @@ export default function ProjectPage() { ); if (res.status === 200) { const data = await res.json(); - const parsedData = projectReviver(data); - setProject(parsedData); + const parsedData = eventReviver(data); + setEvent(parsedData); } else { - const errorMessage = await readErrorMessage(res, "プロジェクトの取得に失敗しました。"); + const errorMessage = await readErrorMessage(res, "イベントの取得に失敗しました。"); setToast({ message: errorMessage, variant: "error", @@ -74,20 +74,20 @@ export default function ProjectPage() { }); setTimeout(() => setToast(null), 5000); } finally { - setProjectLoading(false); + setEventLoading(false); } }, [eventId]); useEffect(() => { - fetchProject(); - }, [fetchProject]); + fetchEvent(); + }, [fetchEvent]); const [submitLoading, setSubmitLoading] = useState(false); - const loading = projectLoading || submitLoading; + const loading = eventLoading || submitLoading; const [dialogStatus, setDialogStatus] = useState<{ - projectId: string; - projectName: string; + eventId: string; + eventName: string; } | null>(null); // TODO: グローバルにしないと、delete の際は遷移を伴うので表示されない @@ -117,7 +117,7 @@ export default function ProjectPage() { startDate: eventId ? "" : dayjs.tz().format("YYYY-MM-DD"), endDate: eventId ? "" : dayjs.tz().add(6, "day").format("YYYY-MM-DD"), allowedRanges: [{ startTime: "08:00", endTime: "23:00" }], - participationOptions: eventId + options: eventId ? [] : [ { @@ -144,29 +144,29 @@ export default function ProjectPage() { remove: removeParticipation, } = useFieldArray({ control, - keyName: "fieldId", // RHF 内部のキーの名称。デフォルトの id だと participationOptions の id と衝突するため変更 - name: "participationOptions", + keyName: "fieldId", // RHF 内部のキーの名称。デフォルトの id だと options の id と衝突するため変更 + name: "options", }); useEffect(() => { if (!eventId) return; - if (!project) return; - const range = project.allowedRanges[0]; + if (!event) return; + const range = event.allowedRanges[0]; reset({ - name: project.name, - description: project.description, - startDate: project.startDate.format("YYYY-MM-DD"), - endDate: project.endDate.format("YYYY-MM-DD"), + name: event.name, + description: event.description, + startDate: event.startDate.format("YYYY-MM-DD"), + endDate: event.endDate.format("YYYY-MM-DD"), allowedRanges: [ // 時間帯が無いイベントは終日とみなす range ? { startTime: range.startTime, endTime: range.endTime } : { startTime: "00:00", endTime: "24:00" }, ], - participationOptions: project.participationOptions.map((opt) => ({ + options: event.options.map((opt) => ({ id: opt.id, label: opt.label, color: opt.color, })), }); - }, [eventId, project, reset]); + }, [eventId, event, reset]); // 送信処理 const onSubmit = async (data: FormSchemaType) => { @@ -178,15 +178,15 @@ export default function ProjectPage() { startDate: data.startDate ?? "", endDate: data.endDate ?? "", allowedRanges: data.allowedRanges ?? [], - participationOptions: (data.participationOptions ?? []).map((opt) => ({ + options: (data.options ?? []).map((opt) => ({ id: opt.id, label: opt.label.trim(), color: opt.color, })), - } satisfies z.infer; + } satisfies z.infer; - if (!project) { - const res = await client.projects.$post( + if (!event) { + const res = await client.events.$post( { json: eventData, }, @@ -199,8 +199,8 @@ export default function ProjectPage() { if (res.status === 201) { const { id, name } = await res.json(); setDialogStatus({ - projectId: id, - projectName: name, + eventId: id, + eventName: name, }); } else { setToast({ @@ -210,14 +210,14 @@ export default function ProjectPage() { setTimeout(() => setToast(null), 3000); } } else { - const res = await client.projects[":projectId"].$patch( - { param: { projectId: project.id }, json: eventData }, + const res = await client.events[":eventId"].$patch( + { param: { eventId: event.id }, json: eventData }, { init: { credentials: "include" } }, ); setSubmitLoading(false); if (res.ok) { // TODO: PUT のレスポンスでデータを返すことを検討 - await fetchProject(); + await fetchEvent(); setToast({ message: "更新しました。", variant: "success", @@ -237,17 +237,17 @@ export default function ProjectPage() { } }; - const isHost = project?.isHost; + const isHost = event?.isHost; useEffect(() => { - if (!loading && project && !isHost) { + if (!loading && event && !isHost) { if (eventId) { navigate(`/e/${eventId}`); } else { navigate("/"); } } - }, [loading, project, isHost, eventId, navigate]); + }, [loading, event, isHost, eventId, navigate]); return ( <> @@ -257,7 +257,7 @@ export default function ProjectPage() {
- ) : eventId !== undefined && !project ? ( + ) : eventId !== undefined && !event ? (

イベントが見つかりませんでした。

@@ -269,12 +269,10 @@ export default function ProjectPage() { ) : (
-

- {project ? `${project.name} の編集` : "新規作成"} -

+

{event ? `${event.name} の編集` : "新規作成"}

-
+ {/* 基本情報 */}

基本情報

@@ -315,9 +313,9 @@ export default function ProjectPage() {

日程の範囲

0 ? "tooltip tooltip-top" : ""} + className={event && event.guests.length > 0 ? "tooltip tooltip-top" : ""} data-tip={ - project && project.guests.length > 0 + event && event.guests.length > 0 ? "すでに日程を登録したユーザーがいるため、開始日の編集はできません" : "" } @@ -329,16 +327,16 @@ export default function ProjectPage() { type="date" {...register("startDate")} id="input-start" - className={`w-full rounded-lg border px-3 py-2 text-base transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:px-4 sm:py-2.5 ${errors.startDate ? "border-red-500" : "border-base-300"} ${project && project.guests.length > 0 ? "cursor-not-allowed bg-base-200 opacity-60" : ""}`} + className={`w-full rounded-lg border px-3 py-2 text-base transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:px-4 sm:py-2.5 ${errors.startDate ? "border-red-500" : "border-base-300"} ${event && event.guests.length > 0 ? "cursor-not-allowed bg-base-200 opacity-60" : ""}`} onFocus={handleFieldFocus} - disabled={!!(project && project.guests.length > 0)} + disabled={!!(event && event.guests.length > 0)} /> {errors.startDate &&

{errors.startDate.message}

}
0 ? "tooltip tooltip-top" : ""} + className={event && event.guests.length > 0 ? "tooltip tooltip-top" : ""} data-tip={ - project && project.guests.length > 0 + event && event.guests.length > 0 ? "すでに日程を登録したユーザーがいるため、終了日の編集はできません" : "" } @@ -350,9 +348,9 @@ export default function ProjectPage() { type="date" {...register("endDate")} id="input-end" - className={`w-full rounded-lg border px-3 py-2 text-base transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:px-4 sm:py-2.5 ${errors.endDate ? "border-red-500" : "border-base-300"} ${project && project.guests.length > 0 ? "cursor-not-allowed bg-base-200 opacity-60" : ""}`} + className={`w-full rounded-lg border px-3 py-2 text-base transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:px-4 sm:py-2.5 ${errors.endDate ? "border-red-500" : "border-base-300"} ${event && event.guests.length > 0 ? "cursor-not-allowed bg-base-200 opacity-60" : ""}`} onFocus={handleFieldFocus} - disabled={!!(project && project.guests.length > 0)} + disabled={!!(event && event.guests.length > 0)} /> {errors.endDate &&

{errors.endDate.message}

}
@@ -373,9 +371,9 @@ export default function ProjectPage() {
0 ? "tooltip tooltip-top w-full" : "w-full"} + className={event && event.guests.length > 0 ? "tooltip tooltip-top w-full" : "w-full"} data-tip={ - project && project.guests.length > 0 + event && event.guests.length > 0 ? "すでに日程を登録したユーザーがいるため、時間帯の編集はできません" : "" } @@ -385,7 +383,7 @@ export default function ProjectPage() {
0 ? "cursor-not-allowed bg-base-200 opacity-60" : ""}`} + className={`flex-1 rounded-lg border px-2 py-2 text-base transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:px-3 sm:py-2.5 ${errors.allowedRanges ? "border-red-500" : "border-base-300"} ${event && event.guests.length > 0 ? "cursor-not-allowed bg-base-200 opacity-60" : ""}`} value={allowedRangeFields[0].startTime.split(":")[1]} onChange={(e) => { replace([ @@ -421,7 +419,7 @@ export default function ProjectPage() { ]); }} onFocus={handleFieldFocus} - disabled={!!(project && project.guests.length > 0)} + disabled={!!(event && event.guests.length > 0)} >