feat(api): generic Idempotency-Key support for safe POST retries

Introduces `withIdempotency()` in `src/lib/idempotency.ts` — wraps a
mutation handler so that a POST/PUT/PATCH/DELETE carrying an
`Idempotency-Key` header is replayed from the cached response instead of
running twice. Cache row is keyed by `(userId, key, method, path)` with
24h TTL; replays carry `X-Idempotent-Replay: true` so callers can
distinguish them.

Applied to the three mutation endpoints most exposed to client retries:
  • POST `/api/measurements`
  • POST `/api/mood-entries`
  • POST `/api/medications/[id]/intake`

Without the header: zero behaviour change. Existing clients that don't
send the header simply skip the cache lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-03 21:53:02 +02:00
1 parent c8387d4f2b
commit 9766cedea4
5 files changed
+338 -6

No files matched your search

+13 -2
View File
@@ -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<string | null> {
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);
});
}
+16 -2
View File
@@ -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<string | null> {
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();
+13 -2
View File
@@ -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<string | null> {
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;
}
});
}