Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
10 changes: 9 additions & 1 deletion docs/adr.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:ユーザーストーリーを廃止する

Expand All @@ -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
74 changes: 68 additions & 6 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,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" },
});
});

Expand Down
25 changes: 15 additions & 10 deletions src/app/api/bottles/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!parsed.success) {
return NextResponse.json(
{ error: "入力内容に誤りがあります" },
Expand All @@ -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 });
}

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 });
}
Loading
Loading