chore(idempotency): default userIdResolver to cookie session (#118)

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 authored and GitHub committed 2026-05-05 20:30:21 +02:00
1 parent aac2e18668
commit a8a7c19d0b
5 files changed
+122 -73

No files matched your search

+11 -11
View File
@@ -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<string | null> {
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;
+38 -40
View File
@@ -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<string | null> {
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 } });
},
);
+20 -17
View File
@@ -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<string | null> {
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;
}
+34
View File
@@ -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();
});
});
+19 -5
View File
@@ -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<NextResponse | null> {
async function findCached(
ctx: IdempotencyContext,
): Promise<NextResponse | null> {
const row = await prisma.idempotencyKey.findUnique({
where: {
userId_key_method_path: {
@@ -51,7 +54,9 @@ async function findCached(ctx: IdempotencyContext): Promise<NextResponse | null>
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<void> {
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<Response>,
userIdResolver: (...args: Args) => Promise<string | null>,
userIdResolver: (...args: Args) => Promise<string | null> = async () => {
const session = await getSession().catch(() => null);
return session?.user.id ?? null;
},
): (...args: Args) => Promise<Response> {
return async (...args: Args): Promise<Response> => {
const request = args[0];