Files
HealthLog/src/app/api/ingest/medication/route.ts
T
Marc-André BombeckandClaude Opus 4.7 02b99553b9 chore(style): repo-wide prettier sweep before v1.4.6 tag
Long-overdue prettier sweep; prettier had drifted vs. checked-in code
across ~130 files (mostly long-argument line-break wrapping in TS and
table-padding in markdown). Pure formatting — no semantic changes —
and required to clear the v1.4.6 release gate's `pnpm format:check`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:27:17 +02:00

157 lines
4.2 KiB
TypeScript

import { prisma } from "@/lib/db";
import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { hashToken } from "@/lib/auth/hmac";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { externalIntakeSchema } from "@/lib/validations/medication";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { NextRequest, NextResponse } from "next/server";
/**
* External medication ingest endpoint.
* Auth: Bearer token (hashed and looked up in api_tokens table).
* Idempotent via idempotencyKey.
* Rate limit: 60 requests per minute per IP.
*/
export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "ingest.medication" } });
if (!(await isApiGloballyEnabled())) {
return apiError("API is globally disabled", 403);
}
const ip = getClientIp(request);
// Rate limiting: 60 requests per minute per IP
const rl = await checkRateLimit(`ingest:${ip}`, 60, 60 * 1000);
if (!rl.allowed) {
return NextResponse.json(
{ data: null, error: "Rate limit exceeded" },
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
// Extract bearer token
const authHeader = request.headers.get("authorization");
if (!authHeader?.startsWith("Bearer ")) {
return apiError("Authorization header required", 401);
}
const token = authHeader.slice(7);
const tokenHashValue = hashToken(token);
const apiToken = await prisma.apiToken.findUnique({
where: { tokenHash: tokenHashValue },
select: {
id: true,
userId: true,
permissions: true,
revoked: true,
expiresAt: true,
lastUsedAt: true,
},
});
if (!apiToken || apiToken.revoked) {
return apiError("Invalid or revoked token", 401);
}
if (apiToken.expiresAt && apiToken.expiresAt < new Date()) {
return apiError("Token expired", 401);
}
if (
!apiToken.permissions.includes("*") &&
!apiToken.permissions.includes("medication:ingest")
) {
return apiError("Insufficient permissions", 403);
}
// Annotate auth after verification
getEvent()?.setAuth({
user_id: apiToken.userId,
auth_method: "api_key",
});
// Update lastUsedAt
await prisma.apiToken.update({
where: { id: apiToken.id },
data: { lastUsedAt: new Date() },
});
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = externalIntakeSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const { medicationName, takenAt, idempotencyKey } = parsed.data;
// Idempotency check (scoped to token owner to prevent cross-user lookups)
const existing = await prisma.medicationIntakeEvent.findFirst({
where: {
idempotencyKey,
userId: apiToken.userId,
},
});
if (existing) {
return apiSuccess(existing);
}
// Find medication by name for this user
const medication = await prisma.medication.findFirst({
where: {
userId: apiToken.userId,
name: { equals: medicationName, mode: "insensitive" },
active: true,
},
});
if (!medication) {
return apiError("Medication not found", 404);
}
const medicationScope = `medication:${medication.id}:ingest`;
if (
!apiToken.permissions.includes("*") &&
!apiToken.permissions.includes(medicationScope)
) {
return apiError("API endpoint for this medication is disabled", 403);
}
const event = await prisma.medicationIntakeEvent.create({
data: {
userId: apiToken.userId,
medicationId: medication.id,
scheduledFor: takenAt ?? new Date(),
takenAt: takenAt ?? new Date(),
skipped: false,
source: "API",
idempotencyKey,
},
});
await auditLog("medication.ingest.external", {
userId: apiToken.userId,
ipAddress: ip,
details: {
medicationId: medication.id,
eventId: event.id,
tokenId: apiToken.id,
},
});
annotate({ meta: { medication_id: medication.id, event_id: event.id } });
return apiSuccess(event, 201);
});