mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
c8387d4f2b
commit
9766cedea4
5 files changed
+338
-6
No files matched your search
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user