mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
aac2e18668
commit
a8a7c19d0b
5 files changed
+122
-73
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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 } });
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
Reference in new issue
Block a user