diff --git a/docs/adr.md b/docs/adr.md index cb0a3f1..85cdef3 100644 --- a/docs/adr.md +++ b/docs/adr.md @@ -119,7 +119,7 @@ - **文脈**:更新エンドポイントのメソッドを決める必要があった。PUT は「リソース全体の置換」、PATCH は「部分更新」を表す。将来は一部の項目だけを更新する機能(例:本数だけ増やす)も見込まれる。 - **決定**:`PATCH /api/bottles/[id]` にする。フォームは全項目を送るが、メソッドは PATCH を採る。決め手は Prisma の意味論と一致すること、そして**将来の部分更新に同じメソッドで対応できる**こと。 - **検討した代替案**:PUT(全置換)… 未送信項目を既定値/null にリセットする実装が要る。Prisma の `data` は「未指定キーは変更しない」=部分更新の意味論なので噛み合わず、将来の部分更新でも意味論が破れる。GitHub・Stripe 等もリソース更新は PATCH。 -- **結果**:Prisma と HTTP の意味論が一致し、余分な変換が要らない。部分更新の機能はメソッドを変えずに足せる。ただし現状は `bottleSchema`(全項目必須)で再検証するため**部分更新は受け付けず**、通信量も減っていない(許すなら `.partial()` 等が必要)。PATCH は冪等性を保証しないが、本実装は全項目を置くため結果として冪等。 +- **結果**:Prisma と HTTP の意味論が一致し、余分な変換が要らない。部分更新の機能はメソッドを変えずに足せる。ただし現状は `bottleUpdateSchema`(`bottleSchema.required()`)で再検証するため**部分更新は受け付けず**、通信量も減っていない(許すなら `.partial()` 等が必要)。**省略を許すと保存される行と判定キー(ADR-0013)が食い違う**ため、これは通信量とのトレードオフではなく整合性の要件。空欄にするときは `null` を送る(空文字は弾く)。PATCH は冪等性を保証しないが、本実装は全項目を置くため結果として冪等。 ## ADR-0012:ユーザーストーリーを廃止する @@ -128,3 +128,11 @@ - **決定**:削除する。**仕様は機能軸(`requirements.md`「5. スコープ」)、状態は GitHub Issues** に置く。他に記述の無かった受け入れ条件 6 件は、該当する機能の項目へ移した。閉じた Issue・PR の `US-N` は当時の記録なので書き換えない。 - **検討した代替案**:凍結して残す(`CLAUDE.md` から参照され続け、矛盾も残る)/ストーリーだけ残す(1・2 章と重複)。 - **結果**:仕様の正本が 1 つに集まり、履歴を辿らずに現状を読める。`US-N` の解決先は失うが、全参照が文脈で自己説明されていた(「ボトルを編集する(US-5)」等)。 + +## ADR-0013:同一性は導出列に持ち、DB の一意制約で保証する + +- **ステータス**:採用(2026-08-03) +- **文脈**:「同じ物」は銘柄名・年数・樽・限定版の 4 つで決まる。だが 4 列に一意制約を張っても、**Postgres は NULL 同士を別の値として扱う**ため、年数も樽も空の「山崎」が何行でも入る。表記ゆれ(全角・空白・大小)も別行に割れる。 +- **決定**:4 項目を正規化して区切り文字でつないだ `identityKey` 列を持ち、**`userId` との複合で一意制約**を張る。空欄は空文字にして NULL を消す。正規化は `NFKC → toLowerCase() → 空白と不可視文字の除去` の順で固定する。 +- **検討した代替案**:書き込み前に検索して比較(確認と書き込みの間に別リクエストが入ると素通りする)/`userId` を含めない一意(他人が登録済みのボトルを登録できなくなる)/ADR-0004 の言う正規化=商品マスタの分離(同じ物の判定に必要な範囲を超える)。 +- **結果**:競合しても DB が最後の砦になり、アプリ側の順序に依存しない。代償として**キーの文字列表現と適用順を変えるには、全行の再計算と重複の掃除が要る**(行数に比例してコストが上がるので、行が入る前に固めておく)。P2002 は衝突相手の行を持たないので、捕まえた後に判定キーで引き直す。判定キーは 4 項目の連結なので**銘柄数の集計には使えない**(→ #61 は正規化関数だけを共用する)。なお `\p{Cf}` 等の中身は実行エンジンが持つ Unicode のバージョンに依存するため、**「未来永劫まったく同じ」ではない**(実務上は無視できる差)。 diff --git a/package.json b/package.json index 90cf283..13f6f00 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-hook-form": "^7.81.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", "zod": "^4.4.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f87a83d..f9cf4f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: react-hook-form: specifier: ^7.81.0 version: 7.81.0(react@19.2.4) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -4150,6 +4153,12 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -8804,6 +8813,11 @@ snapshots: sisteransi@1.0.5: {} + sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + source-map-js@1.2.1: {} source-map@0.6.1: {} diff --git a/prisma/migrations/20260802154139_add_bottle_identity_key/migration.sql b/prisma/migrations/20260802154139_add_bottle_identity_key/migration.sql new file mode 100644 index 0000000..5604008 --- /dev/null +++ b/prisma/migrations/20260802154139_add_bottle_identity_key/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "bottle" ADD COLUMN "identityKey" TEXT NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "bottle_userId_identityKey_key" ON "bottle"("userId", "identityKey"); + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 622ea05..a19ddad 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -34,30 +34,35 @@ model User { /// 所有しているウイスキー 1 種類。行の粒度の定義は docs/data-model.md を参照。 model Bottle { - id String @id @default(cuid()) + id String @id @default(cuid()) /// 所有者。書き込み時はセッションから設定し、リクエストボディの値は使わない。 - userId String + userId String /// User を削除すると、そのユーザーのボトルも削除される。 - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) /// 銘柄名。唯一の必須項目。 - name String + name String /// 産地。固定リスト選択式(表記ゆれ防止)。選択肢は zod の REGIONS で管理し、追加にマイグレーションは不要。 - region String? + region String? /// 地域(アイラ/スペイサイド等)。region が選ばれている前提の任意項目で、地域だけの入力はしない。 - subRegion String? + subRegion String? /// 年数。空欄は NAS(年数表記なし)として扱う。未入力と NAS は区別しない。 - age Int? + age Int? /// 樽(シェリー、バーボン樽 等)。 - caskType String? + caskType String? /// 限定版フラグ。 - isLimited Boolean @default(false) + isLimited Boolean @default(false) /// 同一物の所持本数(1 以上)。同じ物が増えたら行は増やさず、ここを足す。 - quantity Int @default(1) + quantity Int @default(1) /// メモ。この種類についての記録であって、1 本ごとの記録ではない。同一性の判定には含めない。 - note String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + note String? + /// 「同じ物」の判定キー。銘柄名・年数・樽・限定版を正規化して区切り文字でつないだ値 + /// (組み立ては src/lib/bottle-identity.ts)。4 列に一意制約を張ると Postgres が NULL 同士を + /// 別の値として扱い効かないため、空欄を空文字にしたこの列で代用する。表現は変更不可。 + identityKey String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + @@unique([userId, identityKey]) @@index([userId]) @@map("bottle") } diff --git a/src/app/api/bottles/[id]/route.test.ts b/src/app/api/bottles/[id]/route.test.ts index 4e9a66e..5963cfe 100644 --- a/src/app/api/bottles/[id]/route.test.ts +++ b/src/app/api/bottles/[id]/route.test.ts @@ -1,18 +1,28 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { DELETE, PATCH } from "./route"; +import { Prisma, type Bottle } from "@/generated/prisma/client"; import { getSession } from "@/lib/session"; import { prisma } from "@/lib/prisma"; vi.mock("@/lib/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/prisma", () => ({ - prisma: { bottle: { updateMany: vi.fn(), deleteMany: vi.fn() } }, + prisma: { + bottle: { updateMany: vi.fn(), deleteMany: vi.fn(), findUnique: vi.fn() }, + }, })); type Session = NonNullable>>; const session = { user: { id: "user_me" } } as unknown as Session; +const existing = { id: "bottle_existing", name: "山崎" } as Bottle; + +const duplicateError = new Prisma.PrismaClientKnownRequestError("duplicate", { + code: "P2002", + clientVersion: "test", +}); + function patch(id: string, body: unknown) { return PATCH( new Request(`http://localhost/api/bottles/${id}`, { @@ -39,40 +49,92 @@ beforeEach(() => { vi.mocked(prisma.bottle.deleteMany) .mockReset() .mockResolvedValue({ count: 1 }); + vi.mocked(prisma.bottle.findUnique).mockReset().mockResolvedValue(existing); }); +// 更新は全項目を置き換えるため、省略も空文字も受け付けない(→ ADR-0011)。 +const fullBody = { + name: "山崎", + region: null, + subRegion: null, + age: 12, + caskType: null, + isLimited: false, + quantity: 1, + note: null, +}; + describe("PATCH /api/bottles/[id]", () => { it("未ログインなら 401 で、更新しない", async () => { vi.mocked(getSession).mockResolvedValue(null); - const response = await patch("bottle_1", { name: "山崎" }); + const response = await patch("bottle_1", fullBody); expect(response.status).toBe(401); expect(prisma.bottle.updateMany).not.toHaveBeenCalled(); }); it("銘柄名が無ければ 400 で、更新しない", async () => { - const response = await patch("bottle_1", { name: "" }); + const response = await patch("bottle_1", { ...fullBody, name: "" }); expect(response.status).toBe(400); expect(prisma.bottle.updateMany).not.toHaveBeenCalled(); }); + // 省略すると、Prisma に届かない項目が出て保存される行と判定キーが食い違う。 + // .default() を持つ項目(isLimited・quantity)は逆に既定値で上書きされ、値が消える。 + it.each(Object.keys(fullBody))( + "%s を省くと 400 で、更新しない", + async (key) => { + const partial = Object.fromEntries( + Object.entries(fullBody).filter(([name]) => name !== key), + ); + + const response = await patch("bottle_1", partial); + + expect(response.status).toBe(400); + expect(prisma.bottle.updateMany).not.toHaveBeenCalled(); + }, + ); + + // 空文字は undefined に変換され、省略と同じ食い違いを起こす。消すなら null を送る。 + it.each(["subRegion", "caskType", "note"] as const)( + "%s を空文字で送ると 400 で、更新しない", + async (key) => { + const response = await patch("bottle_1", { ...fullBody, [key]: "" }); + + expect(response.status).toBe(400); + expect(prisma.bottle.updateMany).not.toHaveBeenCalled(); + }, + ); + it("他人の/存在しない id は 404(自分の userId で絞るので該当 0 件)", async () => { vi.mocked(prisma.bottle.updateMany).mockResolvedValue({ count: 0 }); - const response = await patch("bottle_other", { name: "山崎" }); + const response = await patch("bottle_other", fullBody); expect(response.status).toBe(404); }); it("正常な入力なら 200 で、自分の userId で絞って更新する(他人の id は更新できない=認可)", async () => { - const response = await patch("bottle_1", { name: "山崎", quantity: 2 }); + const response = await patch("bottle_1", { ...fullBody, quantity: 2 }); expect(response.status).toBe(200); expect(prisma.bottle.updateMany).toHaveBeenCalledWith({ where: { id: "bottle_1", userId: "user_me" }, - data: { name: "山崎", quantity: 2, isLimited: false }, + data: { ...fullBody, quantity: 2, identityKey: expect.any(String) }, + }); + }); + + it("編集で別のボトルと同じ物になると 409 で、既存のボトルを返す", async () => { + vi.mocked(prisma.bottle.updateMany).mockRejectedValue(duplicateError); + + const response = await patch("bottle_1", fullBody); + + expect(response.status).toBe(409); + // クライアントはこの id で詳細へ辿る。 + expect(await response.json()).toMatchObject({ + bottle: { id: "bottle_existing" }, }); }); diff --git a/src/app/api/bottles/[id]/route.ts b/src/app/api/bottles/[id]/route.ts index 83b1ec9..3fe3cac 100644 --- a/src/app/api/bottles/[id]/route.ts +++ b/src/app/api/bottles/[id]/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server"; import { getSession } from "@/lib/session"; import { prisma } from "@/lib/prisma"; -import { bottleSchema } from "@/lib/schemas/bottle"; +import { updateBottle } from "@/lib/bottles"; +import { bottleUpdateSchema } from "@/lib/schemas/bottle"; export async function PATCH( request: Request, @@ -14,7 +15,8 @@ export async function PATCH( const body = await request.json().catch(() => null); // クライアント側バリデーションは信用せず、共有スキーマでサーバでも再検証する。 - const parsed = bottleSchema.safeParse(body); + // 更新は全項目そろっていることも要求する(空欄は null。→ ADR-0011)。 + const parsed = bottleUpdateSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "入力内容に誤りがあります" }, @@ -23,20 +25,23 @@ export async function PATCH( } const { id } = await params; - // 認可:where に userId を含めることで他人のボトルは更新できない。 - // updateMany は非一意フィルタで userId を AND でき、件数を返すため 404 判定に使える - // (所有権チェックと更新を 1 クエリでアトミックに。id が一意なので一致は最大 1 件)。 - const { count } = await prisma.bottle.updateMany({ - where: { id, userId: session.user.id }, - data: parsed.data, - }); - if (count === 0) { + const result = await updateBottle(session.user.id, id, parsed.data); + + if (result.status === "notFound") { return NextResponse.json( { error: "ボトルが見つかりません" }, { status: 404 }, ); } + // 409:編集で別のボトルと同じ物になる場合。登録と同じ形で既存ボトルを返す。 + if (result.status === "duplicate") { + return NextResponse.json( + { error: "同じボトルが既にあります", bottle: result.bottle }, + { status: 409 }, + ); + } + return NextResponse.json({ ok: true }); } diff --git a/src/app/api/bottles/route.test.ts b/src/app/api/bottles/route.test.ts index 45a6530..5a6fc1f 100644 --- a/src/app/api/bottles/route.test.ts +++ b/src/app/api/bottles/route.test.ts @@ -1,17 +1,26 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { POST } from "./route"; +import { Prisma, type Bottle } from "@/generated/prisma/client"; import { getSession } from "@/lib/session"; import { prisma } from "@/lib/prisma"; vi.mock("@/lib/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/prisma", () => ({ prisma: { bottle: { create: vi.fn() } } })); +vi.mock("@/lib/prisma", () => ({ + prisma: { bottle: { create: vi.fn(), findUnique: vi.fn() } }, +})); type Session = NonNullable>>; -type Bottle = Awaited>; const session = { user: { id: "user_me" } } as unknown as Session; +const existing = { id: "bottle_existing", name: "山崎" } as Bottle; + +const duplicateError = new Prisma.PrismaClientKnownRequestError("duplicate", { + code: "P2002", + clientVersion: "test", +}); + function post(body: unknown) { return POST( new Request("http://localhost/api/bottles", { @@ -26,7 +35,8 @@ beforeEach(() => { vi.mocked(getSession).mockResolvedValue(session); vi.mocked(prisma.bottle.create) .mockReset() - .mockResolvedValue({ id: "bottle_1" } as unknown as Bottle); + .mockResolvedValue({ id: "bottle_1" } as Bottle); + vi.mocked(prisma.bottle.findUnique).mockReset().mockResolvedValue(existing); }); describe("POST /api/bottles", () => { @@ -63,10 +73,23 @@ describe("POST /api/bottles", () => { quantity: 2, isLimited: false, userId: "user_me", + identityKey: expect.any(String), }, }); }); + it("同じ物を登録しようとすると 409 で、既存のボトルを返す", async () => { + vi.mocked(prisma.bottle.create).mockRejectedValue(duplicateError); + + const response = await post({ name: "山崎", age: 12 }); + + expect(response.status).toBe(409); + // クライアントはこの id で詳細へ辿る。 + expect(await response.json()).toMatchObject({ + bottle: { id: "bottle_existing" }, + }); + }); + it("ボディで他人の userId を送っても無視される(所有者はセッションが正)", async () => { await post({ name: "山崎", userId: "user_attacker" }); diff --git a/src/app/api/bottles/route.ts b/src/app/api/bottles/route.ts index f617a56..3f6ca74 100644 --- a/src/app/api/bottles/route.ts +++ b/src/app/api/bottles/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { getSession } from "@/lib/session"; -import { prisma } from "@/lib/prisma"; +import { createBottle } from "@/lib/bottles"; import { bottleSchema } from "@/lib/schemas/bottle"; export async function POST(request: Request) { @@ -19,10 +19,16 @@ export async function POST(request: Request) { ); } - // 認可:所有者はボディではなくセッションから決める(他人の userId を指定しても無視される)。 - const bottle = await prisma.bottle.create({ - data: { ...parsed.data, userId: session.user.id }, - }); + const result = await createBottle(session.user.id, parsed.data); - return NextResponse.json(bottle, { status: 201 }); + // 409:入力の誤りではなく既存の状態との衝突なので 400 と分ける。 + // 既存ボトルを返し、クライアントは詳細へ辿れるようにする。 + if (result.status === "duplicate") { + return NextResponse.json( + { error: "同じボトルが既にあります", bottle: result.bottle }, + { status: 409 }, + ); + } + + return NextResponse.json(result.bottle, { status: 201 }); } diff --git a/src/app/bottles/[id]/edit/edit-bottle-form.tsx b/src/app/bottles/[id]/edit/edit-bottle-form.tsx index f1d11f6..3d2b3e1 100644 --- a/src/app/bottles/[id]/edit/edit-bottle-form.tsx +++ b/src/app/bottles/[id]/edit/edit-bottle-form.tsx @@ -4,6 +4,10 @@ import { useRouter } from "next/navigation"; import type { Bottle } from "@/generated/prisma/client"; import { REGIONS } from "@/lib/schemas/bottle"; import { BottleForm } from "../../bottle-form"; +import { + dismissDuplicateBottleToast, + showDuplicateBottleToast, +} from "../../duplicate-bottle-toast"; // 編集用ラッパー:共有フォームに既存値・文言・送信処理(PATCH)を渡す。 export function EditBottleForm({ bottle }: { bottle: Bottle }) { @@ -26,6 +30,7 @@ export function EditBottleForm({ bottle }: { bottle: Bottle }) { submittingLabel="更新中…" errorLabel="更新に失敗しました。もう一度お試しください。" onSubmit={async (data) => { + dismissDuplicateBottleToast(); // 空欄の任意項目は明示 null で送る(undefined だと JSON から落ち、PATCH で「変更なし」=消せないため)。 const payload = { ...data, @@ -40,10 +45,16 @@ export function EditBottleForm({ bottle }: { bottle: Bottle }) { headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); - if (!response.ok) return false; + // 重複は詳細へ戻さず、衝突した既存のボトルを示して入力内容を残す。 + if (response.status === 409) { + const { bottle: existing } = await response.json(); + showDuplicateBottleToast(existing); + return "duplicate"; + } + if (!response.ok) return "failed"; router.push(`/bottles/${bottle.id}`); router.refresh(); - return true; + return "ok"; }} /> ); diff --git a/src/app/bottles/bottle-form.tsx b/src/app/bottles/bottle-form.tsx index c9b67ce..ee8a1ae 100644 --- a/src/app/bottles/bottle-form.tsx +++ b/src/app/bottles/bottle-form.tsx @@ -3,8 +3,12 @@ import { useState } from "react"; import { Controller, useForm, type DefaultValues } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import type { z } from "zod"; -import { bottleSchema, REGIONS } from "@/lib/schemas/bottle"; +import { + bottleSchema, + REGIONS, + type BottleInput, + type BottleValues, +} from "@/lib/schemas/bottle"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -23,9 +27,9 @@ import { } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; -// フォームの入力/出力型(登録・編集で共有)。onSubmit は zod 変換後の値を受け取る。 -export type BottleFormInput = z.input; -export type BottleFormValues = z.output; +// 送信結果。重複(409)は入力の誤りではなくトーストで既存ボトルを示すため、 +// フォーム内の文言を出す "failed" とは分ける。 +type SubmitOutcome = "ok" | "duplicate" | "failed"; // 数値入力:空欄は「未入力」として undefined を渡す(zod 側で NAS/既定値の扱いを決める)。 const asOptionalNumber = (value: unknown) => @@ -44,11 +48,11 @@ export function BottleForm({ errorLabel, onSubmit, }: { - defaultValues: DefaultValues; + defaultValues: DefaultValues; submitLabel: string; submittingLabel: string; errorLabel: string; - onSubmit: (data: BottleFormValues) => Promise; + onSubmit: (data: BottleValues) => Promise; }) { const [serverError, setServerError] = useState(null); const { @@ -56,7 +60,7 @@ export function BottleForm({ control, handleSubmit, formState: { errors, isSubmitting }, - } = useForm({ + } = useForm({ resolver: zodResolver(bottleSchema), defaultValues, }); @@ -64,9 +68,9 @@ export function BottleForm({ const submit = handleSubmit(async (data) => { setServerError(null); try { - // 想定内の失敗(サーバが !ok)は false が返る=文言のみ。想定外の例外だけ catch でログする。 - const ok = await onSubmit(data); - if (!ok) setServerError(errorLabel); + // 想定内の失敗は "failed" が返る=文言のみ。想定外の例外だけ catch でログする。 + // "duplicate" は呼び出し側がトーストで知らせるので、ここでは何も出さない。 + if ((await onSubmit(data)) === "failed") setServerError(errorLabel); } catch (error) { console.error(error); setServerError(errorLabel); diff --git a/src/app/bottles/duplicate-bottle-toast.tsx b/src/app/bottles/duplicate-bottle-toast.tsx new file mode 100644 index 0000000..10dfaac --- /dev/null +++ b/src/app/bottles/duplicate-bottle-toast.tsx @@ -0,0 +1,57 @@ +"use client"; + +import Link from "next/link"; +import { ChevronRightIcon } from "lucide-react"; +import { toast } from "sonner"; + +import { BottlePlaceholder } from "@/components/illustrations/bottle-placeholder"; +import { Badge } from "@/components/ui/badge"; +import type { Bottle } from "@/generated/prisma/client"; + +// 409 のレスポンスはボトル行をそのまま返す。ここで使う分だけを取る。 +type ExistingBottle = Pick< + Bottle, + "id" | "name" | "age" | "caskType" | "isLimited" +>; + +const TOAST_ID = "duplicate-bottle"; + +const TOAST_DURATION_MS = 6_000; + +export function dismissDuplicateBottleToast() { + toast.dismiss(TOAST_ID); +} + +// 登録・編集のどちらで重複しても、既存のボトルを示して詳細へ辿れるようにする +// (その場で本数は加算しない → docs/requirements.md「5. スコープ」保留)。 +export function showDuplicateBottleToast(bottle: ExistingBottle) { + toast.info("このボトルは既に登録されています", { + id: TOAST_ID, + closeButton: true, + duration: TOAST_DURATION_MS, + description: ( + + + + + + {bottle.name} + {bottle.age != null && ` ${bottle.age}年`} + + {bottle.isLimited && 限定版} + + {bottle.caskType && ( + + {bottle.caskType} + + )} + + + + ), + }); +} diff --git a/src/app/bottles/new/create-bottle-form.tsx b/src/app/bottles/new/create-bottle-form.tsx index f110dba..022d942 100644 --- a/src/app/bottles/new/create-bottle-form.tsx +++ b/src/app/bottles/new/create-bottle-form.tsx @@ -2,6 +2,10 @@ import { useRouter } from "next/navigation"; import { BottleForm } from "../bottle-form"; +import { + dismissDuplicateBottleToast, + showDuplicateBottleToast, +} from "../duplicate-bottle-toast"; // 登録用ラッパー:共有フォームに初期値・文言・送信処理(POST)を渡す。 export function CreateBottleForm() { @@ -20,16 +24,23 @@ export function CreateBottleForm() { submittingLabel="登録中…" errorLabel="登録に失敗しました。もう一度お試しください。" onSubmit={async (data) => { + dismissDuplicateBottleToast(); // 編集と違い空→null 正規化はせず data をそのまま送る(region は「未選択」で null になり得る)。 const response = await fetch("/api/bottles", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); - if (!response.ok) return false; + // 重複は一覧へ飛ばさず、既存のボトルを示して入力内容を残す。 + if (response.status === 409) { + const { bottle } = await response.json(); + showDuplicateBottleToast(bottle); + return "duplicate"; + } + if (!response.ok) return "failed"; router.push("/bottles"); router.refresh(); - return true; + return "ok"; }} /> ); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 309933c..02cbba3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import "./globals.css"; import { Geist } from "next/font/google"; +import { Toaster } from "@/components/ui/sonner"; import { cn } from "@/lib/utils"; const geist = Geist({ subsets: ["latin"], variable: "--font-sans" }); @@ -18,7 +19,14 @@ export default function RootLayout({ }>) { return ( - {children} + + {children} + {/* トーストの表示先。ここに無いと toast() が無反応になるが型も lint も通るため、 + レイアウトを組み替えるときも下げない(→ #59)。 + 位置はモックに合わせて上部(sonner の既定は bottom-right)。下部は送信ボタンと + タブバー(#59)の定位置で、重ねると操作を塞ぐ。 */} + + ); } diff --git a/src/components/illustrations/bottle-placeholder.tsx b/src/components/illustrations/bottle-placeholder.tsx new file mode 100644 index 0000000..ff12d2e --- /dev/null +++ b/src/components/illustrations/bottle-placeholder.tsx @@ -0,0 +1,32 @@ +// 写真のプレースホルダ(docs/ui-mockups/assets/ph-card.svg から移設)。 +// 写真アップロードは「採用」でフェーズ 4 のため、それまでは全てのボトルがこれを使う。 +// グラデーションの id は 1 ページに複数出ても定義が同一なので実害はない。 +export function BottlePlaceholder({ className }: { className?: string }) { + return ( + + + + + + + + + + + + ); +} diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000..e74a997 --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { Toaster as Sonner, type ToasterProps } from "sonner"; +import { + CircleCheckIcon, + InfoIcon, + TriangleAlertIcon, + OctagonXIcon, + Loader2Icon, +} from "lucide-react"; + +// shadcn の既定は next-themes の useTheme でテーマを決めるが、本アプリはテーマを +// 切り替えないため依存ごと外した。色は下の CSS 変数(globals.css のトークン)から取る。 +const Toaster = ({ ...props }: ToasterProps) => { + return ( + , + info: , + warning: , + error: , + loading: , + }} + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + "--border-radius": "var(--radius)", + } as React.CSSProperties + } + {...props} + /> + ); +}; + +export { Toaster }; diff --git a/src/lib/bottle-identity.test.ts b/src/lib/bottle-identity.test.ts new file mode 100644 index 0000000..eef4951 --- /dev/null +++ b/src/lib/bottle-identity.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; + +import { + IDENTITY_KEY_SEPARATOR as SEP, + buildIdentityKey, + normalizeText, +} from "./bottle-identity"; + +describe("normalizeText", () => { + it("全角と半角を同じにする(NFKC)", () => { + expect(normalizeText("MACALLAN")).toBe(normalizeText("MACALLAN")); + expect(normalizeText("アイラ")).toBe(normalizeText("アイラ")); + }); + + it("大文字と小文字を同じにする", () => { + expect(normalizeText("Macallan")).toBe(normalizeText("macallan")); + }); + + it("内部の空白も含めて全て取り除く", () => { + expect(normalizeText(" 山崎 12 年 ")).toBe("山崎12年"); + expect(normalizeText("山崎 12年")).toBe("山崎12年"); + }); + + it("空文字はそのまま空文字", () => { + expect(normalizeText("")).toBe(""); + }); + + // Web や PDF からのコピペで混入する。残すと見た目が同じ 2 行を別物として登録できる。 + it.each([ + ["ZWSP", "​"], + ["ソフトハイフン", "­"], + ["ZWNJ", "‌"], + ["BOM", ""], + ["異体字セレクタ", "︀"], + ])("幅を持たない %s を取り除く", (_name, invisible) => { + expect(normalizeText(`山崎${invisible}`)).toBe("山崎"); + }); + + it("区切り文字は取り除かない(Cc なので不可視文字の対象外)", () => { + expect(normalizeText(SEP)).toBe(SEP); + }); + + // 小文字化を先にすると NFKC が後から大文字を作って残る。 + it("NFKC を小文字化より先に掛ける", () => { + expect(normalizeText("ᴬ")).toBe("a"); + }); +}); + +describe("buildIdentityKey", () => { + it("4 項目を区切り文字でつなぐ", () => { + expect( + buildIdentityKey({ + name: "山崎", + age: 12, + caskType: "シェリー", + isLimited: true, + }), + ).toBe(["山崎", "12", "シェリー", "1"].join(SEP)); + }); + + // 見た目が同じなら同じキーになる、が正規化の目的。 + it("不可視文字が混ざっても同じキーになる", () => { + expect(buildIdentityKey({ name: "山崎​", age: 12 })).toBe( + buildIdentityKey({ name: "山崎", age: 12 }), + ); + expect(buildIdentityKey({ name: "山崎", caskType: "シェリー­" })).toBe( + buildIdentityKey({ name: "山崎", caskType: "シェリー" }), + ); + }); + + it("表記ゆれが違っても同じキーになる", () => { + expect(buildIdentityKey({ name: "MACALLAN", age: 12 })).toBe( + buildIdentityKey({ name: "macallan ", age: 12 }), + ); + }); + + it("年数と樽の未入力は空文字にする(NULL を消して比較できるようにする)", () => { + expect(buildIdentityKey({ name: "山崎" })).toBe( + ["山崎", "", "", "0"].join(SEP), + ); + expect(buildIdentityKey({ name: "山崎", age: null, caskType: null })).toBe( + ["山崎", "", "", "0"].join(SEP), + ); + }); + + // "0" にすると NAS と 0 年が同じキーになる。 + it("NAS と 0 年を同じキーにしない", () => { + expect(buildIdentityKey({ name: "山崎" })).not.toBe( + buildIdentityKey({ name: "山崎", age: 0 }), + ); + }); + + it("限定版の有無でキーが変わる", () => { + expect(buildIdentityKey({ name: "山崎", isLimited: true })).not.toBe( + buildIdentityKey({ name: "山崎", isLimited: false }), + ); + }); + + // 区切りが無いと 銘柄名「山崎12」+年数なし と 銘柄名「山崎」+12 年 が同じ文字列になる。 + it("項目の切れ目が違えば別のキーになる", () => { + expect(buildIdentityKey({ name: "山崎12" })).not.toBe( + buildIdentityKey({ name: "山崎", age: 12 }), + ); + }); +}); diff --git a/src/lib/bottle-identity.ts b/src/lib/bottle-identity.ts new file mode 100644 index 0000000..368523d --- /dev/null +++ b/src/lib/bottle-identity.ts @@ -0,0 +1,37 @@ +// 「同じ物」の判定キーを組み立てる(→ docs/data-model.md「重複をどう防ぐか」・ADR-0013)。 + +// 判定キーの区切り。入力に現れない制御文字(Unit Separator)を使い、 +// 混入は共有スキーマ(src/lib/schemas/bottle.ts)で弾く。 +export const IDENTITY_KEY_SEPARATOR = "\u001F"; + +export function normalizeText(value: string): string { + return value + .normalize("NFKC") + .toLowerCase() + .replace(/[\s\p{Cf}\p{Default_Ignorable_Code_Point}]/gu, ""); +} + +type IdentityFields = { + name: string; + age?: number | null; + caskType?: string | null; + isLimited?: boolean; +}; + +// 判定キーは列に保存されるため、下記の表現は後から変えられない(変えると既存行が無効になる)。 +// age … 未入力・NAS は ""("0" にすると「0年」と同じキーになる)/数値はそのまま +// cask … 未入力は "" +// isLimited … "1" / "0"(真偽値に「未入力」は無いので "" は使わない) +export function buildIdentityKey({ + name, + age, + caskType, + isLimited, +}: IdentityFields): string { + return [ + normalizeText(name), + age == null ? "" : String(age), + caskType == null ? "" : normalizeText(caskType), + isLimited ? "1" : "0", + ].join(IDENTITY_KEY_SEPARATOR); +} diff --git a/src/lib/bottles.test.ts b/src/lib/bottles.test.ts new file mode 100644 index 0000000..e49a667 --- /dev/null +++ b/src/lib/bottles.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { Prisma, type Bottle } from "@/generated/prisma/client"; +import { prisma } from "@/lib/prisma"; +import { createBottle, updateBottle } from "@/lib/bottles"; +import { bottleSchema, bottleUpdateSchema } from "@/lib/schemas/bottle"; + +vi.mock("@/lib/prisma", () => ({ + prisma: { + bottle: { create: vi.fn(), updateMany: vi.fn(), findUnique: vi.fn() }, + }, +})); + +const existing = { id: "bottle_existing", name: "山崎" } as Bottle; + +const duplicateError = new Prisma.PrismaClientKnownRequestError("duplicate", { + code: "P2002", + clientVersion: "test", +}); + +const input = (over: Record = {}) => + bottleSchema.parse({ name: "山崎", age: 12, ...over }); + +// 更新は全項目そろっていることが前提(空欄は null)。 +const updateInput = (over: Record = {}) => + bottleUpdateSchema.parse({ + name: "山崎", + region: null, + subRegion: null, + age: 12, + caskType: null, + isLimited: false, + quantity: 1, + note: null, + ...over, + }); + +// data に渡された identityKey を取り出す。 +const keyPassedTo = (fn: { mock: { calls: unknown[][] } }) => + (fn.mock.calls[0][0] as { data: { identityKey: string } }).data.identityKey; + +beforeEach(() => { + vi.mocked(prisma.bottle.create) + .mockReset() + .mockResolvedValue({ id: "bottle_1" } as Bottle); + vi.mocked(prisma.bottle.updateMany).mockReset().mockResolvedValue({ + count: 1, + }); + vi.mocked(prisma.bottle.findUnique).mockReset().mockResolvedValue(existing); +}); + +describe("createBottle", () => { + it("判定キーとログインユーザーを付けて保存する", async () => { + const result = await createBottle("user_me", input()); + + expect(result).toEqual({ status: "created", bottle: { id: "bottle_1" } }); + expect(prisma.bottle.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + name: "山崎", + userId: "user_me", + identityKey: expect.any(String), + }), + }); + }); + + it("一意制約に当たったら衝突相手のボトルを返す", async () => { + vi.mocked(prisma.bottle.create).mockRejectedValue(duplicateError); + + const result = await createBottle("user_me", input()); + + expect(result).toEqual({ status: "duplicate", bottle: existing }); + // P2002 は衝突相手の行を持たないので、判定キーで取りに行く。 + expect(prisma.bottle.findUnique).toHaveBeenCalledWith({ + where: { + userId_identityKey: { + userId: "user_me", + identityKey: expect.any(String), + }, + }, + }); + }); + + it("重複以外のエラーは握り潰さない", async () => { + vi.mocked(prisma.bottle.create).mockRejectedValue(new Error("boom")); + + await expect(createBottle("user_me", input())).rejects.toThrow("boom"); + }); +}); + +describe("updateBottle", () => { + it("判定キーを付け直して更新する", async () => { + const result = await updateBottle("user_me", "bottle_1", updateInput()); + + expect(result).toEqual({ status: "updated" }); + expect(prisma.bottle.updateMany).toHaveBeenCalledWith({ + // 認可:他人のボトルは更新できない。 + where: { id: "bottle_1", userId: "user_me" }, + data: expect.objectContaining({ identityKey: expect.any(String) }), + }); + }); + + it("自分のボトルが無ければ notFound", async () => { + vi.mocked(prisma.bottle.updateMany).mockResolvedValue({ count: 0 }); + + const result = await updateBottle("user_me", "bottle_1", updateInput()); + + expect(result).toEqual({ status: "notFound" }); + }); + + it("編集で別のボトルと同じ物になったら衝突相手を返す", async () => { + vi.mocked(prisma.bottle.updateMany).mockRejectedValue(duplicateError); + + const result = await updateBottle("user_me", "bottle_1", updateInput()); + + expect(result).toEqual({ status: "duplicate", bottle: existing }); + }); +}); + +// 登録と編集でキーの作り方がずれると、編集した瞬間に重複が素通りする。 +it("同じ入力なら登録と編集で同じ判定キーになる", async () => { + await createBottle("user_me", input()); + await updateBottle("user_me", "bottle_1", updateInput()); + + expect(keyPassedTo(vi.mocked(prisma.bottle.create))).toBe( + keyPassedTo(vi.mocked(prisma.bottle.updateMany)), + ); +}); diff --git a/src/lib/bottles.ts b/src/lib/bottles.ts new file mode 100644 index 0000000..e0d3310 --- /dev/null +++ b/src/lib/bottles.ts @@ -0,0 +1,73 @@ +import { Prisma, type Bottle } from "@/generated/prisma/client"; +import { buildIdentityKey } from "@/lib/bottle-identity"; +import { prisma } from "@/lib/prisma"; +import type { BottleUpdateValues, BottleValues } from "@/lib/schemas/bottle"; + +// ボトルの書き込みはここだけを通す。Route Handler から Prisma を直接呼ぶと +// 判定キーの設定漏れが起きるため(登録と編集で同じキーになることを 1 か所で保証する)。 + +export type CreateResult = + | { status: "created"; bottle: Bottle } + | { status: "duplicate"; bottle: Bottle }; + +export type UpdateResult = + | { status: "updated" } + | { status: "duplicate"; bottle: Bottle } + | { status: "notFound" }; + +const isDuplicateError = (error: unknown) => + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002"; + +// P2002 は違反した制約しか持たず、衝突相手の行は入っていないので取りに行く。 +// 「先に検索してから書く」順にすると、二重送信で両方が検索をすり抜ける。 +async function findByIdentityKey(userId: string, identityKey: string) { + return prisma.bottle.findUnique({ + where: { userId_identityKey: { userId, identityKey } }, + }); +} + +export async function createBottle( + userId: string, + input: BottleValues, +): Promise { + const identityKey = buildIdentityKey(input); + + try { + const bottle = await prisma.bottle.create({ + // 認可:所有者はボディではなくセッションから決める。 + data: { ...input, identityKey, userId }, + }); + return { status: "created", bottle }; + } catch (error) { + if (!isDuplicateError(error)) throw error; + + const existing = await findByIdentityKey(userId, identityKey); + if (!existing) throw error; // 衝突直後に消された場合のみ。通常は必ず見つかる。 + return { status: "duplicate", bottle: existing }; + } +} + +export async function updateBottle( + userId: string, + id: string, + input: BottleUpdateValues, +): Promise { + const identityKey = buildIdentityKey(input); + + try { + // 認可:where に userId を含めることで他人のボトルは更新できない。 + // updateMany は非一意フィルタで userId を AND でき、件数を返すため 404 判定に使える。 + const { count } = await prisma.bottle.updateMany({ + where: { id, userId }, + data: { ...input, identityKey }, + }); + return count === 0 ? { status: "notFound" } : { status: "updated" }; + } catch (error) { + if (!isDuplicateError(error)) throw error; + + const existing = await findByIdentityKey(userId, identityKey); + if (!existing) throw error; + return { status: "duplicate", bottle: existing }; + } +} diff --git a/src/lib/schemas/bottle.test.ts b/src/lib/schemas/bottle.test.ts index dd8d464..14ed342 100644 --- a/src/lib/schemas/bottle.test.ts +++ b/src/lib/schemas/bottle.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; +import { IDENTITY_KEY_SEPARATOR as SEP } from "@/lib/bottle-identity"; + import { bottleSchema } from "./bottle"; describe("bottleSchema", () => { @@ -22,10 +24,14 @@ describe("bottleSchema", () => { expect(bottleSchema.parse(input)).toEqual(input); }); - it.each(["", " "])("銘柄名が空(%j)なら通らない", (name) => { - const result = bottleSchema.safeParse({ name }); - expect(result.success).toBe(false); - }); + // 不可視文字だけの銘柄名は trim も min(1) も通り抜けるが、正規化すると空になる。 + it.each(["", " ", " ", "​", "­"])( + "銘柄名が実質空(%j)なら通らない", + (name) => { + const result = bottleSchema.safeParse({ name }); + expect(result.success).toBe(false); + }, + ); it("固定リストにない産地は通らない", () => { const result = bottleSchema.safeParse({ name: "山崎", region: "月" }); @@ -51,4 +57,28 @@ describe("bottleSchema", () => { expect(result.subRegion).toBeUndefined(); expect(result.note).toBeUndefined(); }); + + // 判定キーの区切り文字。混ざると別の組み合わせと同じキーになりうる。 + it.each(["name", "caskType"] as const)( + "判定キーに入る %s に区切り文字が混ざると通らない", + (field) => { + const result = bottleSchema.safeParse({ + name: "山崎", + [field]: `山崎${SEP}12`, + }); + expect(result.success).toBe(false); + }, + ); + + // キーに入らない項目まで弾くと、正当な入力(例:メモ)を落とすだけになる。 + it.each(["subRegion", "note"] as const)( + "判定キーに入らない %s は区切り文字を含んでも通る", + (field) => { + const result = bottleSchema.safeParse({ + name: "山崎", + [field]: `a${SEP}b`, + }); + expect(result.success).toBe(true); + }, + ); }); diff --git a/src/lib/schemas/bottle.ts b/src/lib/schemas/bottle.ts index d6aa4c9..a5c2b40 100644 --- a/src/lib/schemas/bottle.ts +++ b/src/lib/schemas/bottle.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { IDENTITY_KEY_SEPARATOR, normalizeText } from "@/lib/bottle-identity"; + // 産地の固定リスト(表記ゆれ防止・選択肢はここで一元管理 → docs/adr.md の ADR-0009)。 // 5 大ウイスキーの産地。 export const REGIONS = [ @@ -19,9 +21,33 @@ const optionalText = z .transform((value) => (value === "" ? undefined : value)) .nullish(); +// 判定キーは 4 項目を区切り文字でつなぐため、値に区切りが混ざると別の組み合わせと +// 同じキーになりうる。API には UI を通さず送れるので、キーに入る自由入力 +// (銘柄名・樽)はここで弾く(→ docs/data-model.md「重複をどう防ぐか」)。 +const SEPARATOR_MESSAGE = "使用できない文字が含まれています"; +const hasNoSeparator = (value: string) => + !value.includes(IDENTITY_KEY_SEPARATOR); + +const optionalKeyText = z + .string() + .trim() + .refine(hasNoSeparator, SEPARATOR_MESSAGE) + .transform((value) => (value === "" ? undefined : value)) + .nullish(); + // フォーム(クライアント)と Route Handler(サーバ再検証)で共有する(→ CLAUDE.md アーキの鉄則)。 export const bottleSchema = z.object({ - name: z.string().trim().min(1, "銘柄名を入力してください"), + name: z + .string() + .trim() + .min(1, "銘柄名を入力してください") + // 不可視文字だけの銘柄名は trim も min(1) も通り抜けるが、正規化すると空になる。 + // 判定キーの銘柄名が空の行を作らせない(一覧にも名前の無いカードが並ぶ)。 + .refine( + (value) => normalizeText(value).length > 0, + "銘柄名を入力してください", + ) + .refine(hasNoSeparator, SEPARATOR_MESSAGE), region: z.enum(REGIONS).nullish(), subRegion: optionalText, age: z @@ -29,7 +55,7 @@ export const bottleSchema = z.object({ .int("年数は1以上の整数で入力してください") .min(1, "年数は1以上の整数で入力してください") .nullish(), - caskType: optionalText, + caskType: optionalKeyText, isLimited: z.boolean().default(false), quantity: z .number() @@ -38,3 +64,11 @@ export const bottleSchema = z.object({ .default(1), note: optionalText, }); + +// bottleSchema の 2 つの顔。zod は検証時に値を変換するので、入れる前と出た後で型が違う。 +// 差が出るのは .default() を持つ 2 つだけ(quantity・isLimited が必須になる)。 +export type BottleInput = z.input; // 変換前:フォームが持つ値 +export type BottleValues = z.output; // 変換後:検証を通った値 + +export const bottleUpdateSchema = bottleSchema.required(); +export type BottleUpdateValues = z.output;