From a8a7c19d0b426961bfc511075720204de6589126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 5 May 2026 20:30:21 +0200 Subject: [PATCH] chore(idempotency): default userIdResolver to cookie session (#118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the `resolveUserIdForIdempotency` helper that was duplicated across three PWA write routes. `withIdempotency` now reads the userId from `getSession()` by default; routes that authenticate via Bearer token can still pass a custom resolver. Net: −15 LOC across the route layer, +2 test cases for the new default. Co-authored-by: Claude Opus 4.7 (1M context) --- src/app/api/measurements/route.ts | 22 +++--- src/app/api/medications/[id]/intake/route.ts | 78 ++++++++++---------- src/app/api/mood-entries/route.ts | 37 +++++----- src/lib/__tests__/idempotency.test.ts | 34 +++++++++ src/lib/idempotency.ts | 24 ++++-- 5 files changed, 122 insertions(+), 73 deletions(-) diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index c2bb47e09..33af9e880 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -2,7 +2,12 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiError, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { createMeasurementSchema, createBatchMeasurementSchema, @@ -10,7 +15,6 @@ import { getUnitForType, } from "@/lib/validations/measurement"; import { withIdempotency } from "@/lib/idempotency"; -import { getSession } from "@/lib/auth/session"; import { NextRequest } from "next/server"; import type { MeasurementType, @@ -19,11 +23,6 @@ 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(); @@ -66,9 +65,7 @@ export const GET = apiHandler(async (request: NextRequest) => { }); }); -export const POST = apiHandler( - withIdempotency<[NextRequest]>(postMeasurement, resolveUserIdForIdempotency), -); +export const POST = apiHandler(withIdempotency<[NextRequest]>(postMeasurement)); async function postMeasurement(request: NextRequest) { const { user } = await requireAuth(); @@ -149,7 +146,10 @@ async function postMeasurement(request: NextRequest) { }, }); } catch (err) { - if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2002" + ) { return apiError("A measurement with this data already exists", 409); } throw err; diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index e806c041c..706a2e76c 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -2,27 +2,23 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiError, + getClientIp, + safeJson, +} from "@/lib/api-response"; 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 }> }; -async function resolveUserIdForIdempotency(): Promise { - const session = await getSession().catch(() => null); - return session?.user.id ?? null; -} - export const POST = apiHandler( - withIdempotency<[NextRequest, RouteParams]>( - postIntake, - resolveUserIdForIdempotency, - ), + withIdempotency<[NextRequest, RouteParams]>(postIntake), ); async function postIntake(request: NextRequest, { params }: RouteParams) { @@ -116,38 +112,40 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { return apiSuccess(event, 201); } -export const GET = apiHandler(async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); +export const GET = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); - const { id } = await params; - const medication = await prisma.medication.findUnique({ where: { id } }); - if (!medication || medication.userId !== user.id) { - return apiError("Medication not found", 404); - } + const { id } = await params; + const medication = await prisma.medication.findUnique({ where: { id } }); + if (!medication || medication.userId !== user.id) { + return apiError("Medication not found", 404); + } - const searchParams = Object.fromEntries(request.nextUrl.searchParams); - const parsed = listIntakeEventsSchema.safeParse(searchParams); - if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); - } + const searchParams = Object.fromEntries(request.nextUrl.searchParams); + const parsed = listIntakeEventsSchema.safeParse(searchParams); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } - const { limit, offset, sortBy, sortDir } = parsed.data; - const where = { medicationId: id, userId: user.id }; + const { limit, offset, sortBy, sortDir } = parsed.data; + const where = { medicationId: id, userId: user.id }; - const [events, total] = await Promise.all([ - prisma.medicationIntakeEvent.findMany({ - where, - orderBy: { [sortBy]: sortDir }, - take: limit, - skip: offset, - }), - prisma.medicationIntakeEvent.count({ where }), - ]); + const [events, total] = await Promise.all([ + prisma.medicationIntakeEvent.findMany({ + where, + orderBy: { [sortBy]: sortDir }, + take: limit, + skip: offset, + }), + prisma.medicationIntakeEvent.count({ where }), + ]); - annotate({ - action: { name: "medication.intake.list" }, - meta: { medication_id: id, total }, - }); + annotate({ + action: { name: "medication.intake.list" }, + meta: { medication_id: id, total }, + }); - return apiSuccess({ events, meta: { total, limit, offset } }); -}); + return apiSuccess({ events, meta: { total, limit, offset } }); + }, +); diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 9b4114bf8..2e4150fd4 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -1,6 +1,11 @@ import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiError, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { createMoodEntrySchema, listMoodEntriesSchema, @@ -11,12 +16,6 @@ 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", { @@ -67,7 +66,10 @@ export const GET = apiHandler(async (request: NextRequest) => { prisma.moodEntry.count({ where }), ]); - annotate({ action: { name: "mood-entries.list" }, meta: { total, limit, offset } }); + annotate({ + action: { name: "mood-entries.list" }, + meta: { total, limit, offset }, + }); const entriesWithParsedTags = entries.map((e) => ({ ...e, @@ -80,9 +82,7 @@ export const GET = apiHandler(async (request: NextRequest) => { }); }); -export const POST = apiHandler( - withIdempotency<[NextRequest]>(postMoodEntry, resolveUserIdForIdempotency), -); +export const POST = apiHandler(withIdempotency<[NextRequest]>(postMoodEntry)); async function postMoodEntry(request: NextRequest) { const { user } = await requireAuth(); @@ -118,15 +118,18 @@ async function postMoodEntry(request: NextRequest) { details: { moodEntryId: entry.id, mood }, }); - annotate({ action: { name: "mood-entries.create" }, meta: { moodEntryId: entry.id, mood } }); + annotate({ + action: { name: "mood-entries.create" }, + meta: { moodEntryId: entry.id, mood }, + }); return apiSuccess({ ...entry, tags: parseTags(entry.tags) }, 201); } catch (err) { - if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") { - return apiError( - "A mood entry with this data already exists", - 409, - ); + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2002" + ) { + return apiError("A mood entry with this data already exists", 409); } throw err; } diff --git a/src/lib/__tests__/idempotency.test.ts b/src/lib/__tests__/idempotency.test.ts index caf27a452..72af15410 100644 --- a/src/lib/__tests__/idempotency.test.ts +++ b/src/lib/__tests__/idempotency.test.ts @@ -15,8 +15,13 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), })); +vi.mock("@/lib/auth/session", () => ({ + getSession: vi.fn(), +})); + import { withIdempotency } from "../idempotency"; import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; function makeRequest( method: string, @@ -142,4 +147,33 @@ describe("withIdempotency", () => { await wrapped(makeRequest("GET", { "idempotency-key": "abc-12345678" })); expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled(); }); + + it("defaults to the cookie session when no resolver is given", async () => { + vi.mocked(getSession).mockResolvedValue({ + session: { id: "s-1", expiresAt: new Date(Date.now() + 60_000) }, + user: { id: "u-default" }, + } as never); + const handler = vi.fn(async () => + NextResponse.json({ data: "ok", error: null }, { status: 201 }), + ); + const wrapped = withIdempotency<[NextRequest]>(handler); + await wrapped(makeRequest("POST", { "idempotency-key": "abc-12345678" })); + expect(getSession).toHaveBeenCalledTimes(1); + expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1); + const persisted = vi.mocked(prisma.idempotencyKey.create).mock + .calls[0][0] as { data: { userId: string } }; + expect(persisted.data.userId).toBe("u-default"); + }); + + it("skips caching when the default resolver finds no session", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const handler = vi.fn(async () => + NextResponse.json({ data: "ok", error: null }, { status: 201 }), + ); + const wrapped = withIdempotency<[NextRequest]>(handler); + await wrapped(makeRequest("POST", { "idempotency-key": "abc-12345678" })); + expect(handler).toHaveBeenCalledTimes(1); + expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled(); + expect(prisma.idempotencyKey.create).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index d1fe6a612..424448984 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -10,6 +10,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; import { annotate } from "@/lib/logging/context"; const TTL_MS = 24 * 60 * 60 * 1000; @@ -36,7 +37,9 @@ function getIdempotencyKey(request: Request | NextRequest): string | null { * Look up a cached response for a (userId, key, method, path) tuple. * Returns the cached NextResponse or null. */ -async function findCached(ctx: IdempotencyContext): Promise { +async function findCached( + ctx: IdempotencyContext, +): Promise { const row = await prisma.idempotencyKey.findUnique({ where: { userId_key_method_path: { @@ -51,7 +54,9 @@ async function findCached(ctx: IdempotencyContext): Promise if (!row) return null; if (row.expiresAt <= new Date()) { // Stale — purge and fall through. - await prisma.idempotencyKey.delete({ where: { id: row.id } }).catch(() => {}); + await prisma.idempotencyKey + .delete({ where: { id: row.id } }) + .catch(() => {}); return null; } @@ -82,7 +87,11 @@ async function persistCached( preReadBody?: string, ): Promise { const body = - preReadBody ?? (await response.clone().text().catch(() => "")); + preReadBody ?? + (await response + .clone() + .text() + .catch(() => "")); await prisma.idempotencyKey .create({ @@ -108,7 +117,9 @@ async function persistCached( * * 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. + * once `userIdResolver` returns a non-null value. By default the userId + * is read from the cookie session; pass a custom resolver for routes + * that authenticate via Bearer token or other means. * * No-op when the header is missing or the value is malformed. */ @@ -116,7 +127,10 @@ export function withIdempotency< Args extends [Request | NextRequest, ...unknown[]], >( handler: (...args: Args) => Promise, - userIdResolver: (...args: Args) => Promise, + userIdResolver: (...args: Args) => Promise = async () => { + const session = await getSession().catch(() => null); + return session?.user.id ?? null; + }, ): (...args: Args) => Promise { return async (...args: Args): Promise => { const request = args[0];