diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index c59a793f4..c2bb47e09 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -9,6 +9,8 @@ import { listMeasurementsSchema, getUnitForType, } from "@/lib/validations/measurement"; +import { withIdempotency } from "@/lib/idempotency"; +import { getSession } from "@/lib/auth/session"; import { NextRequest } from "next/server"; import type { MeasurementType, @@ -17,6 +19,11 @@ import type { } from "@/generated/prisma/client"; import { Prisma } from "@/generated/prisma/client"; +async function resolveUserIdForIdempotency(): Promise { + const session = await getSession().catch(() => null); + return session?.user.id ?? null; +} + export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -59,7 +66,11 @@ export const GET = apiHandler(async (request: NextRequest) => { }); }); -export const POST = apiHandler(async (request: NextRequest) => { +export const POST = apiHandler( + withIdempotency<[NextRequest]>(postMeasurement, resolveUserIdForIdempotency), +); + +async function postMeasurement(request: NextRequest) { const { user } = await requireAuth(); const { data: body, error: jsonError } = await safeJson(request); @@ -159,4 +170,4 @@ export const POST = apiHandler(async (request: NextRequest) => { }); return apiSuccess(measurement, 201); -}); +} diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index f69424984..e806c041c 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -7,11 +7,25 @@ import { intakeSchema, listIntakeEventsSchema, } from "@/lib/validations/medication"; +import { withIdempotency } from "@/lib/idempotency"; +import { getSession } from "@/lib/auth/session"; import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string }> }; -export const POST = apiHandler(async (request: NextRequest, { params }: RouteParams) => { +async function resolveUserIdForIdempotency(): Promise { + const session = await getSession().catch(() => null); + return session?.user.id ?? null; +} + +export const POST = apiHandler( + withIdempotency<[NextRequest, RouteParams]>( + postIntake, + resolveUserIdForIdempotency, + ), +); + +async function postIntake(request: NextRequest, { params }: RouteParams) { const { user } = await requireAuth(); const { id } = await params; @@ -100,7 +114,7 @@ export const POST = apiHandler(async (request: NextRequest, { params }: RoutePar }); return apiSuccess(event, 201); -}); +} export const GET = apiHandler(async (request: NextRequest, { params }: RouteParams) => { const { user } = await requireAuth(); diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 4db023f5f..9b4114bf8 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -10,6 +10,13 @@ import { NextRequest } from "next/server"; import { Prisma } from "@/generated/prisma/client"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { withIdempotency } from "@/lib/idempotency"; +import { getSession } from "@/lib/auth/session"; + +async function resolveUserIdForIdempotency(): Promise { + const session = await getSession().catch(() => null); + return session?.user.id ?? null; +} function toBerlinDate(date: Date): string { return new Intl.DateTimeFormat("sv-SE", { @@ -73,7 +80,11 @@ export const GET = apiHandler(async (request: NextRequest) => { }); }); -export const POST = apiHandler(async (request: NextRequest) => { +export const POST = apiHandler( + withIdempotency<[NextRequest]>(postMoodEntry, resolveUserIdForIdempotency), +); + +async function postMoodEntry(request: NextRequest) { const { user } = await requireAuth(); const { data: body, error: jsonError } = await safeJson(request); @@ -119,4 +130,4 @@ export const POST = apiHandler(async (request: NextRequest) => { } throw err; } -}); +} diff --git a/src/lib/__tests__/idempotency.test.ts b/src/lib/__tests__/idempotency.test.ts new file mode 100644 index 000000000..caf27a452 --- /dev/null +++ b/src/lib/__tests__/idempotency.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + idempotencyKey: { + findUnique: vi.fn(), + create: vi.fn(), + delete: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +import { withIdempotency } from "../idempotency"; +import { prisma } from "@/lib/db"; + +function makeRequest( + method: string, + headers: Record = {}, +): NextRequest { + return new NextRequest("http://localhost/api/example", { + method, + headers: { "content-type": "application/json", ...headers }, + body: method === "GET" ? undefined : JSON.stringify({ ok: true }), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValue(null); + vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} as never); +}); + +describe("withIdempotency", () => { + it("passes through when no Idempotency-Key header is present", async () => { + const handler = vi.fn(async () => + NextResponse.json({ data: { ok: true }, error: null }, { status: 201 }), + ); + const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1"); + const res = await wrapped(makeRequest("POST")); + expect(res.status).toBe(201); + expect(handler).toHaveBeenCalledTimes(1); + expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled(); + expect(prisma.idempotencyKey.create).not.toHaveBeenCalled(); + }); + + it("caches the response and replays it on the second call", async () => { + let callCount = 0; + const handler = vi.fn(async () => { + callCount += 1; + return NextResponse.json( + { data: { result: callCount }, error: null }, + { status: 201 }, + ); + }); + const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1"); + + // First call: nothing cached → handler runs → response stored. + const req1 = makeRequest("POST", { "idempotency-key": "abc-12345678" }); + const res1 = await wrapped(req1); + const body1 = await res1.json(); + expect(res1.status).toBe(201); + expect(body1).toEqual({ data: { result: 1 }, error: null }); + expect(handler).toHaveBeenCalledTimes(1); + expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1); + + // Capture the persisted body for replay. + const persistedBody = ( + vi.mocked(prisma.idempotencyKey.create).mock.calls[0][0] as { + data: { responseBody: string; responseStatus: number }; + } + ).data; + + // Second call: cache hit returns persisted envelope. + vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValueOnce({ + id: "idem-1", + userId: "u-1", + key: "abc-12345678", + method: "POST", + path: "/api/example", + responseStatus: persistedBody.responseStatus, + responseBody: persistedBody.responseBody, + expiresAt: new Date(Date.now() + 86_400_000), + createdAt: new Date(), + } as never); + + const req2 = makeRequest("POST", { "idempotency-key": "abc-12345678" }); + const res2 = await wrapped(req2); + expect(res2.headers.get("X-Idempotent-Replay")).toBe("true"); + expect(res2.status).toBe(201); + const body2 = await res2.json(); + expect(body2).toEqual({ data: { result: 1 }, error: null }); + expect(handler).toHaveBeenCalledTimes(1); // not called again + }); + + it("ignores expired cache rows and re-runs the handler", async () => { + const handler = vi.fn(async () => + NextResponse.json({ data: "fresh", error: null }, { status: 200 }), + ); + vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValueOnce({ + id: "idem-old", + userId: "u-1", + key: "abc-12345678", + method: "POST", + path: "/api/example", + responseStatus: 200, + responseBody: '{"data":"stale","error":null}', + expiresAt: new Date(Date.now() - 1000), + createdAt: new Date(), + } as never); + vi.mocked(prisma.idempotencyKey.delete).mockResolvedValue({} as never); + + const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1"); + const res = await wrapped( + makeRequest("POST", { "idempotency-key": "abc-12345678" }), + ); + const body = await res.json(); + expect(handler).toHaveBeenCalledTimes(1); + expect(body).toEqual({ data: "fresh", error: null }); + expect(prisma.idempotencyKey.delete).toHaveBeenCalled(); + }); + + it("ignores malformed Idempotency-Key headers", async () => { + const handler = vi.fn(async () => + NextResponse.json({ data: "ok", error: null }), + ); + const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1"); + await wrapped(makeRequest("POST", { "idempotency-key": "!!" })); + expect(handler).toHaveBeenCalledTimes(1); + expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled(); + }); + + it("does nothing for GET requests", async () => { + const handler = vi.fn(async () => + NextResponse.json({ data: "ok", error: null }), + ); + const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1"); + await wrapped(makeRequest("GET", { "idempotency-key": "abc-12345678" })); + expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts new file mode 100644 index 000000000..7324a50c4 --- /dev/null +++ b/src/lib/idempotency.ts @@ -0,0 +1,151 @@ +/** + * Idempotency-Key support for write endpoints (POST/PUT/PATCH/DELETE). + * + * Mobile clients send `Idempotency-Key: `. The first request runs + * the handler normally and the response (status + body) is cached. Any + * retry within the TTL (24h) for the same `(userId, key, method, path)` + * tuple returns the cached envelope as a 200 (carrying the original + * status inside the JSON if needed) — no second side-effect. + */ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; + +const TTL_MS = 24 * 60 * 60 * 1000; +const SUPPORTED_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +const KEY_REGEX = /^[A-Za-z0-9_\-:.]{8,128}$/; + +export interface IdempotencyContext { + userId: string; + key: string; + method: string; + path: string; +} + +function getIdempotencyKey(request: Request | NextRequest): string | null { + const raw = request.headers.get("idempotency-key"); + if (!raw) return null; + const trimmed = raw.trim(); + if (!KEY_REGEX.test(trimmed)) return null; + return trimmed; +} + +/** + * Look up a cached response for a (userId, key, method, path) tuple. + * Returns the cached NextResponse or null. + */ +async function findCached(ctx: IdempotencyContext): Promise { + const row = await prisma.idempotencyKey.findUnique({ + where: { + userId_key_method_path: { + userId: ctx.userId, + key: ctx.key, + method: ctx.method, + path: ctx.path, + }, + }, + }); + + if (!row) return null; + if (row.expiresAt <= new Date()) { + // Stale — purge and fall through. + await prisma.idempotencyKey.delete({ where: { id: row.id } }).catch(() => {}); + return null; + } + + let parsed: unknown = null; + try { + parsed = JSON.parse(row.responseBody); + } catch { + parsed = null; + } + + annotate({ + action: { name: "idempotency.replay" }, + meta: { method: ctx.method, path: ctx.path }, + }); + + // Replay original status to keep client behaviour byte-identical. + return NextResponse.json(parsed, { + status: row.responseStatus, + headers: { + "X-Idempotent-Replay": "true", + }, + }); +} + +async function persistCached( + ctx: IdempotencyContext, + response: Response, +): Promise { + const cloned = response.clone(); + const body = await cloned.text().catch(() => ""); + + await prisma.idempotencyKey + .create({ + data: { + userId: ctx.userId, + key: ctx.key, + method: ctx.method, + path: ctx.path, + responseStatus: response.status, + responseBody: body, + expiresAt: new Date(Date.now() + TTL_MS), + }, + }) + .catch(() => { + // Concurrent insert with the same key — the other writer wins. + // Ignore because a future replay will hit their cached value. + }); +} + +/** + * Wrap a write handler so a repeat call with the same `Idempotency-Key` + * (and same userId/method/path) returns the originally cached response. + * + * The wrapped handler is responsible for authentication itself — this + * helper only triggers for methods in {POST, PUT, PATCH, DELETE} and only + * once `userIdResolver` returns a non-null value. + * + * No-op when the header is missing or the value is malformed. + */ +export function withIdempotency< + Args extends [Request | NextRequest, ...unknown[]], +>( + handler: (...args: Args) => Promise, + userIdResolver: (...args: Args) => Promise, +): (...args: Args) => Promise { + return async (...args: Args): Promise => { + const request = args[0]; + if (!SUPPORTED_METHODS.has(request.method)) { + return handler(...args); + } + + const key = getIdempotencyKey(request); + if (!key) return handler(...args); + + const userId = await userIdResolver(...args); + if (!userId) return handler(...args); + + const url = new URL(request.url); + const ctx: IdempotencyContext = { + userId, + key, + method: request.method, + path: url.pathname, + }; + + const cached = await findCached(ctx); + if (cached) return cached; + + const response = await handler(...args); + + // Persist regardless of status so error retries don't fork into + // two different responses (matches Stripe/Square semantics). + await persistCached(ctx, response); + + return response; + }; +}