Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/adr.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 は正規化関数だけを共用する)。
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
14 changes: 14 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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");
Comment thread
coderabbitai[bot] marked this conversation as resolved.

31 changes: 18 additions & 13 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
32 changes: 30 additions & 2 deletions src/app/api/bottles/[id]/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<Awaited<ReturnType<typeof getSession>>>;

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}`, {
Expand All @@ -39,6 +49,7 @@ beforeEach(() => {
vi.mocked(prisma.bottle.deleteMany)
.mockReset()
.mockResolvedValue({ count: 1 });
vi.mocked(prisma.bottle.findUnique).mockReset().mockResolvedValue(existing);
});

describe("PATCH /api/bottles/[id]", () => {
Expand Down Expand Up @@ -72,7 +83,24 @@ describe("PATCH /api/bottles/[id]", () => {
expect(response.status).toBe(200);
expect(prisma.bottle.updateMany).toHaveBeenCalledWith({
where: { id: "bottle_1", userId: "user_me" },
data: { name: "山崎", quantity: 2, isLimited: false },
data: {
name: "山崎",
quantity: 2,
isLimited: false,
identityKey: expect.any(String),
},
});
});

it("編集で別のボトルと同じ物になると 409 で、既存のボトルを返す", async () => {
vi.mocked(prisma.bottle.updateMany).mockRejectedValue(duplicateError);

const response = await patch("bottle_1", { name: "山崎", age: 12 });

expect(response.status).toBe(409);
// クライアントはこの id で詳細へ辿る。
expect(await response.json()).toMatchObject({
bottle: { id: "bottle_existing" },
});
});

Expand Down
20 changes: 12 additions & 8 deletions src/app/api/bottles/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { getSession } from "@/lib/session";
import { prisma } from "@/lib/prisma";
import { updateBottle } from "@/lib/bottles";
import { bottleSchema } from "@/lib/schemas/bottle";

export async function PATCH(
Expand All @@ -23,20 +24,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 });
}

Expand Down
29 changes: 26 additions & 3 deletions src/app/api/bottles/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<Awaited<ReturnType<typeof getSession>>>;
type Bottle = Awaited<ReturnType<typeof prisma.bottle.create>>;

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", {
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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" });

Expand Down
18 changes: 12 additions & 6 deletions src/app/api/bottles/route.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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 });
}
11 changes: 9 additions & 2 deletions src/app/bottles/[id]/edit/edit-bottle-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
import type { Bottle } from "@/generated/prisma/client";
import { REGIONS } from "@/lib/schemas/bottle";
import { BottleForm } from "../../bottle-form";
import { showDuplicateBottleToast } from "../../duplicate-bottle-toast";

// 編集用ラッパー:共有フォームに既存値・文言・送信処理(PATCH)を渡す。
export function EditBottleForm({ bottle }: { bottle: Bottle }) {
Expand Down Expand Up @@ -40,10 +41,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";
}}
/>
);
Expand Down
26 changes: 15 additions & 11 deletions src/app/bottles/bottle-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -23,9 +27,9 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";

// フォームの入力/出力型(登録・編集で共有)。onSubmit は zod 変換後の値を受け取る。
export type BottleFormInput = z.input<typeof bottleSchema>;
export type BottleFormValues = z.output<typeof bottleSchema>;
// 送信結果。重複(409)は入力の誤りではなくトーストで既存ボトルを示すため、
// フォーム内の文言を出す "failed" とは分ける。
type SubmitOutcome = "ok" | "duplicate" | "failed";

// 数値入力:空欄は「未入力」として undefined を渡す(zod 側で NAS/既定値の扱いを決める)。
const asOptionalNumber = (value: unknown) =>
Expand All @@ -44,29 +48,29 @@ export function BottleForm({
errorLabel,
onSubmit,
}: {
defaultValues: DefaultValues<BottleFormInput>;
defaultValues: DefaultValues<BottleInput>;
submitLabel: string;
submittingLabel: string;
errorLabel: string;
onSubmit: (data: BottleFormValues) => Promise<boolean>;
onSubmit: (data: BottleValues) => Promise<SubmitOutcome>;
}) {
const [serverError, setServerError] = useState<string | null>(null);
const {
register,
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<BottleFormInput, unknown, BottleFormValues>({
} = useForm<BottleInput, unknown, BottleValues>({
resolver: zodResolver(bottleSchema),
defaultValues,
});

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);
Expand Down
Loading
Loading