mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
Bundles the deferred v1.4.47 Mediums + Lows with the v1.4.48-deferred items into a single release. Headline: server-side suppression of MEDICATION_REMINDER APNs for iOS clients that own their local reminders (closes GH #206), a push_attempts table backing /api/admin/notifications/diagnostic, and a sweep of v1.4.48 QA forward-findings. Performance (the user-visible win): - /api/analytics cold-fallback path splits the heavy single-statement aggregate into two parallel queries. The linearly composable columns (count / min / max / mean) keep the full-partition scan; the windowed 7 / 30 / 90-day avg / slope / r² columns take a 90-day outer cap so the planner does an index range scan on (user_id, type, measured_at) instead of a full-partition sequential scan. Output is bit-identical; cold slim slice drops from multi-second to sub-second on tenants with large measurement partitions. Server-side notifications: - GET + PATCH /api/auth/me/notification-prefs with deep-merge semantics. Initial category medication.clientManaged: boolean (default false). Cron medicationReminderJob skips dose-due APNs when the flag is true, emitting a medication_reminder.suppressed_client_managed wide-event annotation per skip. - push_attempts table backs the diagnostic endpoint. Every APNS / Web-Push / Telegram / NTFY sender writes a fire-and-forget row. Daily 03:35 Europe/Berlin cleanup prunes rows older than 90 days. - /api/admin/notifications/diagnostic — admin-only endpoint surfacing device APNs token presence (masked), channel config presence, and recent push attempts. Registered in OpenAPI. - Per-channel APNs "Send test" button now fires with eventType: "MEDICATION_REMINDER" so the dispatcher's time-sensitive + priority-10 branch runs. A successful self-test now exercises the exact path production reminders take; an unsuccessful one points the user at iOS Settings → Time Sensitive Notifications rather than appearing to be a server-side bug. Withings reliability: - OAuth callback consumes the nonce atomically (delete-first with P2025 catch for the replay branch). The previous read-then-delete pattern allowed a race between two concurrent callbacks. - ?reason=state redirect differentiated into csrf1 / replay / expired / cross_user so operators can tell mismatch types apart without DB shell access. - connect endpoint rate-limited to 10/60s/user; row-create failures redirect to a consistent error surface. Observability hardening: - redactSensitiveFields(body) denylist helper covers password / token / secret / apiKey / authorization / csrfState / nonce, recursive, case-insensitive. Caught: /api/devices invalid_format issues were echoing apnsToken values verbatim into the audit log. - sanitiseZodIssues(issues, { stripValuesFromMessage: true }) overload applied to 14 audit-log sites. Migrations: 0079 (notification_prefs), 0080 (push_attempts). Tests: 4815 → 5272 unit + integration. Workout-batch integration suite that had been red on main since v1.4.45 (three release commits shipped through it) now green again — updated to match v1.4.42 cross-source dedup semantics. Resolves PR #207.
200 lines
5.8 KiB
TypeScript
200 lines
5.8 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,
|
|
returnAllZodIssues,
|
|
safeJson,
|
|
sanitiseZodIssues,
|
|
} 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 { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
|
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) {
|
|
// v1.4.43 W6 — external native ingest is a bulk-source; surface
|
|
// every Zod issue so a Telegram-bridge or HomeAssistant
|
|
// integration sees the full validation shape at once. Audit
|
|
// breadcrumb keyed `ingest.medication.validation-failed`.
|
|
const issues = sanitiseZodIssues(parsed.error.issues);
|
|
annotate({
|
|
action: { name: "ingest.medication.validation-failed" },
|
|
meta: { issue_count: issues.length },
|
|
});
|
|
// v1.4.49 — strip `message` from the audit-ledger row; external
|
|
// ingest carries free-text `medicationName` + opaque
|
|
// `idempotencyKey`.
|
|
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
|
stripValuesFromMessage: true,
|
|
});
|
|
prisma.auditLog
|
|
.create({
|
|
data: {
|
|
userId: apiToken.userId,
|
|
action: "ingest.medication.validation-failed",
|
|
details: JSON.stringify({ issues: auditIssues }),
|
|
},
|
|
})
|
|
.catch(() => {
|
|
/* swallow — 422 response is the contract */
|
|
});
|
|
return returnAllZodIssues(parsed.error, 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 } });
|
|
|
|
// v1.4.39 W-MED — refresh the compliance rollup for the ingested
|
|
// event's user-day. The external ingest path runs without a User
|
|
// record in scope; resolve the tz once for the recompute.
|
|
const ingestUser = await prisma.user.findUnique({
|
|
where: { id: apiToken.userId },
|
|
select: { timezone: true },
|
|
});
|
|
await recomputeMedicationComplianceForEvent({
|
|
userId: apiToken.userId,
|
|
medicationId: medication.id,
|
|
scheduledFor: event.scheduledFor,
|
|
tz: ingestUser?.timezone ?? null,
|
|
});
|
|
|
|
return apiSuccess(event, 201);
|
|
});
|