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>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 02:27:17 +02:00
1 parent 67575180b8
commit 02b99553b9
135 files changed
+2159 -1583

No files matched your search

@@ -37,10 +37,7 @@ export const POST = apiHandler(
if (!feedback) return apiError("Feedback not found", 404);
if (feedback.gitHubIssueUrl) {
return apiError(
`Already published: ${feedback.gitHubIssueUrl}`,
409,
);
return apiError(`Already published: ${feedback.gitHubIssueUrl}`, 409);
}
// Atomic claim to prevent concurrent admin clicks from creating two
+6 -1
View File
@@ -3,7 +3,12 @@
* DELETE /api/admin/feedback/[id] — archive (soft: sets status to ARCHIVED).
*/
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson, getClientIp } from "@/lib/api-response";
import {
apiError,
apiSuccess,
safeJson,
getClientIp,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -30,7 +30,14 @@ export const POST = apiHandler(async () => {
});
if (!delivery.ok) {
getEvent()?.addWarning("Glitchtip test event rejected: " + delivery.method + " " + delivery.status + " " + delivery.details);
getEvent()?.addWarning(
"Glitchtip test event rejected: " +
delivery.method +
" " +
delivery.status +
" " +
delivery.details,
);
return apiError(
`Glitchtip test event rejected (HTTP ${delivery.status ?? 502})`,
502,
@@ -54,10 +54,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Umami is disabled", 422);
}
if (!settings.umamiScriptUrl || !settings.umamiWebsiteId) {
return apiError(
"Umami script URL and website ID must be configured",
422,
);
return apiError("Umami script URL and website ID must be configured", 422);
}
const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl);
@@ -116,8 +113,5 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
getEvent()?.addWarning("Umami test event rejected: " + lastStatus);
return apiError(
`Umami test event rejected (HTTP ${lastStatus})`,
502,
);
return apiError(`Umami test event rejected (HTTP ${lastStatus})`, 502);
});
@@ -158,7 +158,9 @@ export const POST = apiHandler(async () => {
notificationSent = true;
notificationsSent++;
} catch (err) {
getEvent()?.addWarning("Notification failed for " + med.name + ": " + err);
getEvent()?.addWarning(
"Notification failed for " + med.name + ": " + err,
);
}
}
+11 -7
View File
@@ -1,7 +1,12 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
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 { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { adminSettingsSchema } from "@/lib/validations/admin";
@@ -27,8 +32,8 @@ export const GET = apiHandler(async () => {
webPushVapidSubject: settings?.webPushVapidSubject ?? null,
webPushVapidConfigured: Boolean(
settings?.webPushVapidPublicKey &&
settings?.webPushVapidPrivateKeyEncrypted &&
settings?.webPushVapidSubject,
settings?.webPushVapidPrivateKeyEncrypted &&
settings?.webPushVapidSubject,
),
apiGlobal: settings?.apiGlobal ?? true,
umamiEnabled: settings?.umamiEnabled ?? false,
@@ -207,8 +212,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
webPushVapidSubject: settings.webPushVapidSubject,
webPushVapidConfigured: Boolean(
settings.webPushVapidPublicKey &&
settings.webPushVapidPrivateKeyEncrypted &&
settings.webPushVapidSubject,
settings.webPushVapidPrivateKeyEncrypted &&
settings.webPushVapidSubject,
),
apiGlobal: settings.apiGlobal,
umamiEnabled: settings.umamiEnabled,
@@ -216,8 +221,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
umamiWebsiteId: settings.umamiWebsiteId,
glitchtipEnabled: settings.glitchtipEnabled,
glitchtipDsn: settings.glitchtipDsn,
glitchtipEnvironment:
settings.glitchtipEnvironment ?? "production",
glitchtipEnvironment: settings.glitchtipEnvironment ?? "production",
bugReportRepo: settings.githubIssueRepo,
bugReportConfigured: Boolean(
settings.githubIssueRepo && settings.githubIssueTokenEncrypted,
+16 -11
View File
@@ -107,7 +107,10 @@ export const GET = apiHandler(async () => {
() => prisma.user.count({ where: { createdAt: { gte: sevenDaysAgo } } }),
],
["withingsCount", () => prisma.withingsConnection.count()],
["moodLogCount", () => prisma.user.count({ where: { moodLogEnabled: true } })],
[
"moodLogCount",
() => prisma.user.count({ where: { moodLogEnabled: true } }),
],
[
"telegramCount",
() => prisma.user.count({ where: { telegramEnabled: true } }),
@@ -207,20 +210,21 @@ export const GET = apiHandler(async () => {
const telegramCount = (results.telegramCount as number | null) ?? 0;
const ntfyCount = (results.ntfyCount as number | null) ?? 0;
const webPushCount = (results.webPushCount as number | null) ?? 0;
const appSettings = results.appSettings as
| { glitchtipEnabled: boolean; umamiEnabled: boolean }
| null;
const appSettings = results.appSettings as {
glitchtipEnabled: boolean;
umamiEnabled: boolean;
} | null;
const lastErrorEntry = results.lastErrorEntry as { createdAt: Date } | null;
const latestBackup = results.latestBackup as { createdAt: Date } | null;
const backedUpUsers = (results.backedUpUsers as number | null) ?? 0;
const last30dEvents = (results.last30dEvents as number | null) ?? 0;
const lastLoginEntry = results.lastLoginEntry as { createdAt: Date } | null;
const lastIdempotencyCleanup = results.lastIdempotencyCleanup as
| { createdAt: Date }
| null;
const lastAuditLogCleanup = results.lastAuditLogCleanup as
| { createdAt: Date }
| null;
const lastIdempotencyCleanup = results.lastIdempotencyCleanup as {
createdAt: Date;
} | null;
const lastAuditLogCleanup = results.lastAuditLogCleanup as {
createdAt: Date;
} | null;
if (failed.length > 0) {
annotate({ meta: { statusOverviewProbeFailures: failed } });
@@ -300,7 +304,8 @@ export const GET = apiHandler(async () => {
workerUptimeSeconds,
lastIdempotencyCleanup:
lastIdempotencyCleanup?.createdAt?.toISOString() ?? null,
lastAuditLogCleanup: lastAuditLogCleanup?.createdAt?.toISOString() ?? null,
lastAuditLogCleanup:
lastAuditLogCleanup?.createdAt?.toISOString() ?? null,
},
auditLog: {
severity: anyFailed("last30dEvents", "lastLoginEntry") ? "alert" : "info",
@@ -2,69 +2,82 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
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 { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { destroyAllSessions } from "@/lib/auth/session";
import { NextRequest, NextResponse } from "next/server";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
export const POST = apiHandler(async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const ip = getClientIp(request);
const rl = await checkRateLimit(`admin-reset-pw:${ip}`, 10, 60 * 1000);
if (!rl.allowed) {
return NextResponse.json(
{ data: null, error: "Rate limit exceeded" },
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
export const POST = apiHandler(
async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const ip = getClientIp(request);
const rl = await checkRateLimit(`admin-reset-pw:${ip}`, 10, 60 * 1000);
if (!rl.allowed) {
return NextResponse.json(
{ data: null, error: "Rate limit exceeded" },
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
const { user } = await requireAdmin();
const { user } = await requireAdmin();
const { id } = await params;
annotate({ action: { name: "admin.users.reset-password", entity_type: "user", entity_id: id } });
const { id } = await params;
annotate({
action: {
name: "admin.users.reset-password",
entity_type: "user",
entity_id: id,
},
});
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const { password } = body as { password?: string };
if (jsonError) return jsonError;
const { password } = body as { password?: string };
if (!password || typeof password !== "string") {
return apiError("Password required", 422);
}
if (!password || typeof password !== "string") {
return apiError("Password required", 422);
}
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return apiError("User not found", 404);
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return apiError("User not found", 404);
const locale = await resolveServerLocale({
request,
userLocale: target.locale ?? null,
});
const strength = checkPasswordStrength(password, [target.username], locale);
if (!strength.isAcceptable) {
return apiError(
strength.feedback[0] || "Password too weak (score < 3)",
422,
);
}
const locale = await resolveServerLocale({
request,
userLocale: target.locale ?? null,
});
const strength = checkPasswordStrength(password, [target.username], locale);
if (!strength.isAcceptable) {
return apiError(
strength.feedback[0] || "Password too weak (score < 3)",
422,
);
}
const passwordHash = await hashPassword(password);
await prisma.user.update({
where: { id },
data: { passwordHash },
});
const passwordHash = await hashPassword(password);
await prisma.user.update({
where: { id },
data: { passwordHash },
});
// Invalidate all sessions of the target user
await destroyAllSessions(id);
// Invalidate all sessions of the target user
await destroyAllSessions(id);
await auditLog("admin.user.reset-password", {
userId: user.id,
ipAddress: getClientIp(request),
details: { targetUserId: id },
});
await auditLog("admin.user.reset-password", {
userId: user.id,
ipAddress: getClientIp(request),
details: { targetUserId: id },
});
return apiSuccess({ success: true });
});
return apiSuccess({ success: true });
},
);
+61 -48
View File
@@ -1,7 +1,12 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
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 { annotate } from "@/lib/logging/context";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -12,57 +17,65 @@ const updateUserSchema = z.object({
role: z.enum(["ADMIN", "USER"]).optional(),
});
export const PUT = apiHandler(async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user } = await requireAdmin();
export const PUT = apiHandler(
async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user } = await requireAdmin();
const { id } = await params;
annotate({ action: { name: "admin.users.update", entity_type: "user", entity_id: id } });
const { id } = await params;
annotate({
action: {
name: "admin.users.update",
entity_type: "user",
entity_id: id,
},
});
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = updateUserSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return apiError("User not found", 404);
// Prevent removing the last admin
if (parsed.data.role === "USER" && target.role === "ADMIN") {
const adminCount = await prisma.user.count({ where: { role: "ADMIN" } });
if (adminCount <= 1) {
return apiError("The last admin cannot be demoted", 400);
if (jsonError) return jsonError;
const parsed = updateUserSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
}
const updatedUser = await prisma.user.update({
where: { id },
data: {
...(parsed.data.username !== undefined && {
username: parsed.data.username,
}),
...(parsed.data.email !== undefined && { email: parsed.data.email }),
...(parsed.data.role !== undefined && { role: parsed.data.role }),
},
select: {
id: true,
username: true,
email: true,
role: true,
createdAt: true,
},
});
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return apiError("User not found", 404);
await auditLog("admin.user.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { targetUserId: id, changes: parsed.data },
});
// Prevent removing the last admin
if (parsed.data.role === "USER" && target.role === "ADMIN") {
const adminCount = await prisma.user.count({ where: { role: "ADMIN" } });
if (adminCount <= 1) {
return apiError("The last admin cannot be demoted", 400);
}
}
return apiSuccess(updatedUser);
});
const updatedUser = await prisma.user.update({
where: { id },
data: {
...(parsed.data.username !== undefined && {
username: parsed.data.username,
}),
...(parsed.data.email !== undefined && { email: parsed.data.email }),
...(parsed.data.role !== undefined && { role: parsed.data.role }),
},
select: {
id: true,
username: true,
email: true,
role: true,
createdAt: true,
},
});
await auditLog("admin.user.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { targetUserId: id, changes: parsed.data },
});
return apiSuccess(updatedUser);
},
);
+3 -1
View File
@@ -14,7 +14,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
const rl = await checkRateLimit(`codex-callback:${user.id}`, 10, 60_000);
if (!rl.allowed) {
const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL;
return NextResponse.redirect(`${appUrl}/settings/integrations?codex_error=rate_limited`);
return NextResponse.redirect(
`${appUrl}/settings/integrations?codex_error=rate_limited`,
);
}
const url = new URL(request.url);
@@ -1,7 +1,12 @@
import { prisma } from "@/lib/db";
import { verifyRegistration } from "@/lib/auth/passkey";
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 { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -9,7 +14,8 @@ import { annotate } from "@/lib/logging/context";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson<Record<string, unknown>>(request);
const { data: body, error: jsonError } =
await safeJson<Record<string, unknown>>(request);
if (jsonError) return jsonError;
const challengeId = body.challengeId as string | undefined;
@@ -35,7 +41,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
counter: BigInt(registrationInfo.credential.counter),
credentialDeviceType: registrationInfo.credentialDeviceType,
credentialBackedUp: registrationInfo.credentialBackedUp,
transports: ((credential.response as Record<string, unknown> | undefined)?.transports as string[]) ?? [],
transports:
((credential.response as Record<string, unknown> | undefined)
?.transports as string[]) ?? [],
},
});
+34 -32
View File
@@ -5,44 +5,46 @@ import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
export const DELETE = apiHandler(async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user } = await requireAuth();
export const DELETE = apiHandler(
async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user } = await requireAuth();
const { id } = await params;
const { id } = await params;
const passkey = await prisma.passkey.findUnique({
where: { id },
});
const passkey = await prisma.passkey.findUnique({
where: { id },
});
if (!passkey || passkey.userId !== user.id) {
return apiError("Passkey not found", 404);
}
if (!passkey || passkey.userId !== user.id) {
return apiError("Passkey not found", 404);
}
// Check: at least 1 auth method must remain
const passkeyCount = await prisma.passkey.count({
where: { userId: user.id },
});
const hasPassword = !!user.passwordHash;
// Check: at least 1 auth method must remain
const passkeyCount = await prisma.passkey.count({
where: { userId: user.id },
});
const hasPassword = !!user.passwordHash;
if (passkeyCount <= 1 && !hasPassword) {
return apiError(
"Cannot delete — at least one authentication method must remain",
400,
);
}
if (passkeyCount <= 1 && !hasPassword) {
return apiError(
"Cannot delete — at least one authentication method must remain",
400,
);
}
await prisma.passkey.delete({ where: { id } });
await prisma.passkey.delete({ where: { id } });
await auditLog("auth.passkey.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { passkeyId: id, passkeyName: passkey.name },
});
await auditLog("auth.passkey.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { passkeyId: id, passkeyName: passkey.name },
});
annotate({ action: { name: "auth.passkey.delete" } });
annotate({ action: { name: "auth.passkey.delete" } });
return apiSuccess({ success: true });
});
return apiSuccess({ success: true });
},
);
+9 -13
View File
@@ -7,7 +7,12 @@ import {
} from "@/lib/auth/password";
import { changePasswordSchema } from "@/lib/validations/auth";
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 { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -23,10 +28,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
15 * 60 * 1000,
);
if (!rl.allowed) {
return apiError(
"Too many attempts. Please wait 15 minutes.",
429,
);
return apiError("Too many attempts. Please wait 15 minutes.", 429);
}
const { data: body, error: jsonError } = await safeJson(request);
@@ -42,19 +44,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("No password set for this account", 400);
}
const currentValid = await verifyPassword(
user.passwordHash,
currentPassword,
);
const currentValid = await verifyPassword(user.passwordHash, currentPassword);
if (!currentValid) {
return apiError("Current password is incorrect", 401);
}
if (currentPassword === newPassword) {
return apiError(
"New password must differ from current password",
422,
);
return apiError("New password must differ from current password", 422);
}
const locale = await resolveServerLocale({
+6 -1
View File
@@ -1,4 +1,9 @@
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
+7 -3
View File
@@ -3,7 +3,12 @@ import { registerSchema } from "@/lib/validations/auth";
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
import { createSession } from "@/lib/auth/session";
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 { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
@@ -18,8 +23,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
return NextResponse.json(
{
data: null,
error:
"Too many registration attempts. Please try again later.",
error: "Too many registration attempts. Please try again later.",
},
{ status: 429, headers: rateLimitHeaders(rl) },
);
+11 -6
View File
@@ -1,6 +1,11 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -17,7 +22,10 @@ const bugReportSchema = z.object({
screenshot: z
.string()
.max(7_000_000, "Screenshot too large (max 5 MB)")
.regex(SCREENSHOT_DATA_URL, "Screenshot must be a base64-encoded PNG/JPEG/WEBP/GIF data URL")
.regex(
SCREENSHOT_DATA_URL,
"Screenshot must be a base64-encoded PNG/JPEG/WEBP/GIF data URL",
)
.optional(),
});
@@ -92,10 +100,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const safeUsername = user.username.replace(/[`*_~<>\[\]|]/g, "");
const safeDescription = description
.replace(/<[^>]*>/g, "") // strip HTML tags
.replace(
/\]\(\s*(javascript|data|vbscript|file):/gi,
"](about:blank#",
) // neutralise dangerous markdown link schemes
.replace(/\]\(\s*(javascript|data|vbscript|file):/gi, "](about:blank#") // neutralise dangerous markdown link schemes
.replace(/```/g, "\\`\\`\\`")
.slice(0, 5000);
+1 -5
View File
@@ -18,11 +18,7 @@ export const GET = apiHandler(async () => {
// Light rate limit — the endpoint is cheap, but no reason to let a logged-in
// client hammer Postgres in a loop.
const rl = await checkRateLimit(
`bugreport-status:${user.id}`,
30,
60 * 1000,
);
const rl = await checkRateLimit(`bugreport-status:${user.id}`, 30, 60 * 1000);
if (!rl.allowed) {
return apiError("Rate limit exceeded", 429);
}
@@ -22,7 +22,11 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
@@ -50,7 +54,9 @@ function makeReq(): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
});
describe("GET /api/dashboard/summary", () => {
+5 -1
View File
@@ -25,7 +25,11 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
+4 -1
View File
@@ -52,7 +52,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
userId: user.id,
details: { reason: "token_owned_by_other_user", deviceId: existing.id },
});
return apiError("Device token already registered to another account", 409);
return apiError(
"Device token already registered to another account",
409,
);
}
const updated = await prisma.device.update({
where: { token },
+7 -2
View File
@@ -38,8 +38,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!["csv", "json"].includes(format)) {
return apiError("Format must be csv or json", 422);
}
if (!["measurements", "medications", "intake", "mood", "all"].includes(type)) {
return apiError("Type must be measurements, medications, intake, mood, or all", 422);
if (
!["measurements", "medications", "intake", "mood", "all"].includes(type)
) {
return apiError(
"Type must be measurements, medications, intake, mood, or all",
422,
);
}
const userId = user.id;
+6 -1
View File
@@ -6,7 +6,12 @@
* the database (≤5 MB each).
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson, getClientIp } from "@/lib/api-response";
import {
apiError,
apiSuccess,
safeJson,
getClientIp,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
@@ -25,7 +25,11 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
@@ -47,7 +51,9 @@ const SESSION_OK = {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.passkey.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never);
+7 -15
View File
@@ -456,10 +456,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const now = new Date();
const userId = user.id;
const startDate = maxDate(
user.createdAt,
GAMIFICATION_ROLLOUT_AT,
);
const startDate = maxDate(user.createdAt, GAMIFICATION_ROLLOUT_AT);
const [measurements, intakeEvents, medications, passkeys, auditEvents] =
await Promise.all([
@@ -469,12 +466,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
measuredAt: { gte: startDate, lte: now },
source: { not: "IMPORT" },
type: {
in: [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
],
in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
},
},
select: {
@@ -741,14 +733,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
mergedDates[id] = date;
}
const result = evaluateAchievementsWithCompletionDates(
metrics,
mergedDates,
);
const result = evaluateAchievementsWithCompletionDates(metrics, mergedDates);
// Persist newly unlocked achievements
const newUnlocks = result.achievements.filter(
(a) => a.unlocked && a.completedAt && !persisted.some((p) => p.achievementId === a.id),
(a) =>
a.unlocked &&
a.completedAt &&
!persisted.some((p) => p.achievementId === a.id),
);
if (newUnlocks.length > 0) {
await prisma.userAchievement.createMany({
+4 -1
View File
@@ -2,7 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u-1" }, session: { id: "s-1" } })),
requireAuth: vi.fn(async () => ({
user: { id: "u-1" },
session: { id: "s-1" },
})),
}));
vi.mock("@/lib/db", () => ({
+6 -1
View File
@@ -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 { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
+6 -1
View File
@@ -2,7 +2,12 @@ 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 {
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";
@@ -21,13 +21,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
const locale = resolveBloodPressureStatusLocale(resolved);
const result = await generateBloodPressureStatusForUser(
user.id,
{
locale,
force: false,
},
);
const result = await generateBloodPressureStatusForUser(user.id, {
locale,
force: false,
});
annotate({ action: { name: "insights.blood-pressure-status" } });
@@ -24,7 +24,11 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
@@ -40,7 +44,9 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: null,
dateOfBirth: null,
@@ -80,10 +86,20 @@ describe("GET /api/insights/cards", () => {
}> = [];
for (let i = 0; i < 10; i++) {
const at = new Date(now.getTime() - i * 86_400_000);
measurements.push({ type: "BLOOD_PRESSURE_SYS", value: 180, measuredAt: at });
measurements.push({ type: "BLOOD_PRESSURE_DIA", value: 110, measuredAt: at });
measurements.push({
type: "BLOOD_PRESSURE_SYS",
value: 180,
measuredAt: at,
});
measurements.push({
type: "BLOOD_PRESSURE_DIA",
value: 110,
measuredAt: at,
});
}
vi.mocked(prisma.measurement.findMany).mockResolvedValue(measurements as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue(
measurements as never,
);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: null,
dateOfBirth: null,
+22 -14
View File
@@ -68,12 +68,7 @@ export const GET = apiHandler(async () => {
where: {
userId: user.id,
type: {
in: [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
],
in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
},
measuredAt: { gte: ninetyDaysAgo },
},
@@ -91,7 +86,9 @@ export const GET = apiHandler(async () => {
.filter((m) => m.type === t)
.map((m) => ({ date: m.measuredAt, value: m.value }));
const summaries: Partial<Record<MeasurementType, ReturnType<typeof summarize>>> = {};
const summaries: Partial<
Record<MeasurementType, ReturnType<typeof summarize>>
> = {};
for (const t of [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
@@ -105,8 +102,7 @@ export const GET = apiHandler(async () => {
let bmi: number | null = null;
if (dbUser?.heightCm && summaries.WEIGHT?.latest) {
const heightM = dbUser.heightCm / 100;
bmi =
Math.round((summaries.WEIGHT.latest / (heightM * heightM)) * 10) / 10;
bmi = Math.round((summaries.WEIGHT.latest / (heightM * heightM)) * 10) / 10;
}
let bpPctInTarget: number | null = null;
@@ -142,7 +138,12 @@ export const GET = apiHandler(async () => {
medicationId: { in: activeMeds.map((m) => m.id) },
scheduledFor: { gte: new Date(Date.now() - 30 * 86_400_000) },
},
select: { medicationId: true, takenAt: true, skipped: true, scheduledFor: true },
select: {
medicationId: true,
takenAt: true,
skipped: true,
scheduledFor: true,
},
});
const eventsByMed = new Map<string, typeof allEvents>();
for (const e of allEvents) {
@@ -154,15 +155,22 @@ export const GET = apiHandler(async () => {
for (const med of activeMeds) {
const events = eventsByMed.get(med.id) ?? [];
const takenLast7 = events.filter(
(e) => e.takenAt !== null && !e.skipped && e.scheduledFor.getTime() >= cutoff7,
(e) =>
e.takenAt !== null &&
!e.skipped &&
e.scheduledFor.getTime() >= cutoff7,
).length;
const taken30 = events.filter(
(e) => e.takenAt !== null && !e.skipped,
).length;
const taken30 = events.filter((e) => e.takenAt !== null && !e.skipped).length;
const expected7 = med.schedules.length * 7;
const expected30 = med.schedules.length * 30;
medicationCompliance.push({
name: med.name,
compliance7: expected7 > 0 ? Math.round((takenLast7 / expected7) * 100) : 0,
compliance30: expected30 > 0 ? Math.round((taken30 / expected30) * 100) : 0,
compliance7:
expected7 > 0 ? Math.round((takenLast7 / expected7) * 100) : 0,
compliance30:
expected30 > 0 ? Math.round((taken30 / expected30) * 100) : 0,
});
}
}
+19 -6
View File
@@ -74,7 +74,8 @@ export const GET = apiHandler(async () => {
date: e.moodLoggedAt,
value: e.score,
}));
const moodSummary = moodDataPoints.length > 0 ? summarize(moodDataPoints) : null;
const moodSummary =
moodDataPoints.length > 0 ? summarize(moodDataPoints) : null;
const byType = (t: MeasurementType): DataPoint[] =>
allMeasurements
@@ -173,7 +174,9 @@ export const GET = apiHandler(async () => {
// Mood vs Systolic BP
const moodBpPairs = buildMoodMetricPairs(
dailyMoodEntries,
allMeasurements.filter((m) => m.type === "BLOOD_PRESSURE_SYS").map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
allMeasurements
.filter((m) => m.type === "BLOOD_PRESSURE_SYS")
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
);
const moodBpCorrelation = pearsonCorrelation(moodBpPairs);
const moodBpScatterData = moodBpPairs.map((p) => ({ mood: p.a, sysBP: p.b }));
@@ -181,18 +184,28 @@ export const GET = apiHandler(async () => {
// Mood vs Weight
const moodWeightPairs = buildMoodMetricPairs(
dailyMoodEntries,
allMeasurements.filter((m) => m.type === "WEIGHT").map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
allMeasurements
.filter((m) => m.type === "WEIGHT")
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
);
const moodWeightCorrelation = pearsonCorrelation(moodWeightPairs);
const moodWeightScatterData = moodWeightPairs.map((p) => ({ mood: p.a, weight: p.b }));
const moodWeightScatterData = moodWeightPairs.map((p) => ({
mood: p.a,
weight: p.b,
}));
// Mood vs Pulse
const moodPulsePairs = buildMoodMetricPairs(
dailyMoodEntries,
allMeasurements.filter((m) => m.type === "PULSE").map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
allMeasurements
.filter((m) => m.type === "PULSE")
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
);
const moodPulseCorrelation = pearsonCorrelation(moodPulsePairs);
const moodPulseScatterData = moodPulsePairs.map((p) => ({ mood: p.a, pulse: p.b }));
const moodPulseScatterData = moodPulsePairs.map((p) => ({
mood: p.a,
pulse: p.b,
}));
// Medication compliance
const medications = await prisma.medication.findMany({
@@ -19,7 +19,11 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
+17 -4
View File
@@ -2,7 +2,10 @@ import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { extractFeatures } from "@/lib/insights/features";
import { getInsightsSystemPrompt, buildUserPrompt } from "@/lib/insights/prompt";
import {
getInsightsSystemPrompt,
buildUserPrompt,
} from "@/lib/insights/prompt";
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
import { resolveProvider } from "@/lib/ai/provider";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -41,8 +44,15 @@ export const POST = apiHandler(async (request: NextRequest) => {
) {
try {
const cached = JSON.parse(dbUser.insightsCachedText);
annotate({ action: { name: "insights.generate" }, meta: { cached: true } });
return apiSuccess({ insights: cached, cached: true, cachedAt: dbUser.insightsCachedAt });
annotate({
action: { name: "insights.generate" },
meta: { cached: true },
});
return apiSuccess({
insights: cached,
cached: true,
cachedAt: dbUser.insightsCachedAt,
});
} catch {
// Invalid cache, regenerate
}
@@ -60,7 +70,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
const provider = await resolveProvider(userId);
if (provider.type === "none") {
return apiError("No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.", 422);
return apiError(
"No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.",
422,
);
}
const includeRaw = dbUser?.insightsPrivacyMode === "raw";
@@ -21,13 +21,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
const locale = resolveMedicationComplianceStatusLocale(resolved);
const result = await generateMedicationComplianceStatusForUser(
user.id,
{
locale,
force: false,
},
);
const result = await generateMedicationComplianceStatusForUser(user.id, {
locale,
force: false,
});
annotate({ action: { name: "insights.medication-compliance-status" } });
+2 -1
View File
@@ -42,7 +42,8 @@ export const GET = apiHandler(async () => {
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson<Record<string, unknown>>(request);
const { data: body, error: jsonError } =
await safeJson<Record<string, unknown>>(request);
if (jsonError) return jsonError;
const data: Record<string, unknown> = {};
@@ -21,7 +21,11 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PATCH } from "../route";
@@ -102,7 +106,8 @@ describe("PATCH /api/integrations/healthkit", () => {
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
const updateArgs = vi.mocked(prisma.user.update).mock.calls[0][0] as unknown as {
const updateArgs = vi.mocked(prisma.user.update).mock
.calls[0][0] as unknown as {
data: { healthKitConfigJson: { entries: Array<{ id: string }> } };
};
const ids = updateArgs.data.healthKitConfigJson.entries.map((e) => e.id);
+19 -6
View File
@@ -43,9 +43,24 @@ export interface HealthKitEntry {
}
export const DEFAULT_HEALTHKIT_ENTRIES: HealthKitEntry[] = [
{ id: "bodyMass", kind: "bodyMass", direction: "bidirectional", enabled: true },
{ id: "bp", kind: "bloodPressure", direction: "bidirectional", enabled: true },
{ id: "glucose", kind: "bloodGlucose", direction: "bidirectional", enabled: true },
{
id: "bodyMass",
kind: "bodyMass",
direction: "bidirectional",
enabled: true,
},
{
id: "bp",
kind: "bloodPressure",
direction: "bidirectional",
enabled: true,
},
{
id: "glucose",
kind: "bloodGlucose",
direction: "bidirectional",
enabled: true,
},
{ id: "heartRate", kind: "heartRate", direction: "readOnly", enabled: true },
{ id: "stepCount", kind: "stepCount", direction: "readOnly", enabled: true },
{ id: "sleep", kind: "sleepAnalysis", direction: "readOnly", enabled: true },
@@ -135,9 +150,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
const stored = parseStored(row?.healthKitConfigJson ?? null);
// Resolve known ids set: default ids + already-stored ids.
const knownIds = new Set<string>(
DEFAULT_HEALTHKIT_ENTRIES.map((e) => e.id),
);
const knownIds = new Set<string>(DEFAULT_HEALTHKIT_ENTRIES.map((e) => e.id));
if (stored) for (const e of stored.entries) knownIds.add(e.id);
// Merge by id; unknown ids are silently ignored per spec.
+92 -81
View File
@@ -2,112 +2,123 @@ 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 { updateMeasurementSchema } from "@/lib/validations/measurement";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
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 { id } = await params;
const measurement = await prisma.measurement.findUnique({
where: { id },
});
const measurement = await prisma.measurement.findUnique({
where: { id },
});
if (!measurement || measurement.userId !== user.id) {
return apiError("Measurement not found", 404);
}
if (!measurement || measurement.userId !== user.id) {
return apiError("Measurement not found", 404);
}
annotate({
action: {
name: "measurement.get",
entity_type: "measurement",
entity_id: id,
},
});
annotate({
action: {
name: "measurement.get",
entity_type: "measurement",
entity_id: id,
},
});
return apiSuccess(measurement);
});
return apiSuccess(measurement);
},
);
export const PUT = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
export const PUT = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const { id } = await params;
const existing = await prisma.measurement.findUnique({
where: { id },
});
const existing = await prisma.measurement.findUnique({
where: { id },
});
if (!existing || existing.userId !== user.id) {
return apiError("Measurement not found", 404);
}
if (!existing || existing.userId !== user.id) {
return apiError("Measurement not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = updateMeasurementSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
if (jsonError) return jsonError;
const parsed = updateMeasurementSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const data = parsed.data;
const measurement = await prisma.measurement.update({
where: { id },
data: {
...(data.value !== undefined && { value: data.value }),
...(data.measuredAt !== undefined && { measuredAt: data.measuredAt }),
...(data.notes !== undefined && { notes: data.notes }),
},
});
const data = parsed.data;
const measurement = await prisma.measurement.update({
where: { id },
data: {
...(data.value !== undefined && { value: data.value }),
...(data.measuredAt !== undefined && { measuredAt: data.measuredAt }),
...(data.notes !== undefined && { notes: data.notes }),
},
});
await auditLog("measurement.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { measurementId: id },
});
await auditLog("measurement.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { measurementId: id },
});
annotate({
action: {
name: "measurement.update",
entity_type: "measurement",
entity_id: id,
},
});
annotate({
action: {
name: "measurement.update",
entity_type: "measurement",
entity_id: id,
},
});
return apiSuccess(measurement);
});
return apiSuccess(measurement);
},
);
export const DELETE = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const { id } = await params;
const existing = await prisma.measurement.findUnique({
where: { id },
});
const existing = await prisma.measurement.findUnique({
where: { id },
});
if (!existing || existing.userId !== user.id) {
return apiError("Measurement not found", 404);
}
if (!existing || existing.userId !== user.id) {
return apiError("Measurement not found", 404);
}
await prisma.measurement.delete({ where: { id } });
await prisma.measurement.delete({ where: { id } });
await auditLog("measurement.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { measurementId: id, type: existing.type },
});
await auditLog("measurement.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { measurementId: id, type: existing.type },
});
annotate({
action: {
name: "measurement.delete",
entity_type: "measurement",
entity_id: id,
},
});
annotate({
action: {
name: "measurement.delete",
entity_type: "measurement",
entity_id: id,
},
});
return apiSuccess({ deleted: true });
});
return apiSuccess({ deleted: true });
},
);
@@ -15,7 +15,11 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
+130 -126
View File
@@ -14,164 +14,168 @@ function medicationScope(medicationId: string): string {
return `medication:${medicationId}:ingest`;
}
export const GET = apiHandler(async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
if (!(await isApiGloballyEnabled())) {
return apiError("API is globally disabled", 403);
}
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
if (!(await isApiGloballyEnabled())) {
return apiError("API is globally disabled", 403);
}
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 scope = medicationScope(id);
const now = new Date();
const scope = medicationScope(id);
const now = new Date();
const activeTokenCount = await prisma.apiToken.count({
where: {
userId: user.id,
revoked: false,
permissions: { has: scope },
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
},
});
annotate({
action: {
name: "medication.api_endpoint.get",
entity_type: "medication",
entity_id: id,
},
meta: { active_token_count: activeTokenCount },
});
return apiSuccess({
enabled: activeTokenCount > 0,
activeTokenCount,
});
});
export const PUT = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
if (!(await isApiGloballyEnabled())) {
return apiError("API is globally disabled", 403);
}
const { id } = await params;
const medication = await prisma.medication.findUnique({ where: { id } });
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
let enabled = false;
try {
const body = await request.json();
enabled = body?.enabled === true;
} catch {
return apiError("Invalid request", 422);
}
const scope = medicationScope(id);
if (enabled) {
const existing = await prisma.apiToken.count({
const activeTokenCount = await prisma.apiToken.count({
where: {
userId: user.id,
revoked: false,
permissions: { has: scope },
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
},
});
if (existing > 0) {
annotate({
action: {
name: "medication.api_endpoint.get",
entity_type: "medication",
entity_id: id,
},
meta: { active_token_count: activeTokenCount },
});
return apiSuccess({
enabled: activeTokenCount > 0,
activeTokenCount,
});
},
);
export const PUT = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
if (!(await isApiGloballyEnabled())) {
return apiError("API is globally disabled", 403);
}
const { id } = await params;
const medication = await prisma.medication.findUnique({ where: { id } });
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
let enabled = false;
try {
const body = await request.json();
enabled = body?.enabled === true;
} catch {
return apiError("Invalid request", 422);
}
const scope = medicationScope(id);
if (enabled) {
const existing = await prisma.apiToken.count({
where: {
userId: user.id,
revoked: false,
permissions: { has: scope },
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
},
});
if (existing > 0) {
annotate({
action: {
name: "medication.api_endpoint.enable",
entity_type: "medication",
entity_id: id,
},
meta: { already_enabled: true, active_token_count: existing },
});
return apiSuccess({
enabled: true,
activeTokenCount: existing,
token: null,
created: false,
});
}
const rawToken = `hlk_${randomBytes(32).toString("hex")}`;
const tokenHashValue = hashToken(rawToken);
await prisma.apiToken.create({
data: {
userId: user.id,
name: `API Endpoint: ${medication.name}`,
tokenHash: tokenHashValue,
permissions: ["medication:ingest", scope],
expiresAt: null,
},
});
await auditLog("medication.api_endpoint.enable", {
userId: user.id,
ipAddress: getClientIp(request),
details: { medicationId: id, medicationName: medication.name },
});
annotate({
action: {
name: "medication.api_endpoint.enable",
entity_type: "medication",
entity_id: id,
},
meta: { already_enabled: true, active_token_count: existing },
meta: { created: true },
});
return apiSuccess({
enabled: true,
activeTokenCount: existing,
token: null,
created: false,
});
return apiSuccess(
{
enabled: true,
activeTokenCount: 1,
token: rawToken,
created: true,
},
201,
);
}
const rawToken = `hlk_${randomBytes(32).toString("hex")}`;
const tokenHashValue = hashToken(rawToken);
await prisma.apiToken.create({
data: {
const revoked = await prisma.apiToken.updateMany({
where: {
userId: user.id,
name: `API Endpoint: ${medication.name}`,
tokenHash: tokenHashValue,
permissions: ["medication:ingest", scope],
expiresAt: null,
revoked: false,
permissions: { has: scope },
},
data: { revoked: true },
});
await auditLog("medication.api_endpoint.enable", {
await auditLog("medication.api_endpoint.disable", {
userId: user.id,
ipAddress: getClientIp(request),
details: { medicationId: id, medicationName: medication.name },
details: {
medicationId: id,
medicationName: medication.name,
revokedTokens: revoked.count,
},
});
annotate({
action: {
name: "medication.api_endpoint.enable",
name: "medication.api_endpoint.disable",
entity_type: "medication",
entity_id: id,
},
meta: { created: true },
meta: { revoked_token_count: revoked.count },
});
return apiSuccess(
{
enabled: true,
activeTokenCount: 1,
token: rawToken,
created: true,
},
201,
);
}
const revoked = await prisma.apiToken.updateMany({
where: {
userId: user.id,
revoked: false,
permissions: { has: scope },
},
data: { revoked: true },
});
await auditLog("medication.api_endpoint.disable", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
medicationName: medication.name,
revokedTokens: revoked.count,
},
});
annotate({
action: {
name: "medication.api_endpoint.disable",
entity_type: "medication",
entity_id: id,
},
meta: { revoked_token_count: revoked.count },
});
return apiSuccess({
enabled: false,
revokedTokenCount: revoked.count,
});
});
return apiSuccess({
enabled: false,
revokedTokenCount: revoked.count,
});
},
);
+117 -115
View File
@@ -10,124 +10,126 @@ import type { DailyComplianceEntry } from "@/lib/analytics/compliance";
type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(async (_request: Request, { params }: RouteParams) => {
const { user } = await requireAuth();
export const GET = apiHandler(
async (_request: Request, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const medication = await prisma.medication.findUnique({
where: { id },
include: { schedules: true },
});
const { id } = await params;
const medication = await prisma.medication.findUnique({
where: { id },
include: { schedules: true },
});
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
const events = await prisma.medicationIntakeEvent.findMany({
where: { medicationId: id, userId: user.id },
orderBy: { scheduledFor: "desc" },
});
const mapped = events.map((e) => ({
takenAt: e.takenAt,
skipped: e.skipped,
scheduledFor: e.scheduledFor,
}));
const createdAt = medication.createdAt;
const compliance7 = calculateCompliance(
mapped,
medication.schedules,
7,
createdAt,
);
const compliance30 = calculateCompliance(
mapped,
medication.schedules,
30,
createdAt,
);
// Build daily compliance map for heatmap/line chart (90 days)
const now = new Date();
const schedulesPerDay = medication.schedules.length;
const dailyCompliance: Record<string, DailyComplianceEntry> = {};
for (let d = 0; d < 90; d++) {
const dayStart = new Date(now.getTime() - (d + 1) * 24 * 60 * 60 * 1000);
const dayEnd = new Date(now.getTime() - d * 24 * 60 * 60 * 1000);
// Skip days before medication was created
if (dayEnd <= createdAt) continue;
const dateKey = dayStart.toISOString().slice(0, 10);
const dayEvents = mapped.filter(
(e) => e.scheduledFor >= dayStart && e.scheduledFor < dayEnd,
);
const takenEvents = dayEvents.filter(
(e) => e.takenAt !== null && !e.skipped,
);
// Classify timing for each taken event against the best-matching schedule
let onTime = 0;
let late = 0;
let veryLate = 0;
for (const evt of takenEvents) {
if (medication.schedules.length === 0) {
// No schedule info: treat all taken as on_time
onTime++;
continue;
}
// Match event to the closest schedule window by scheduledFor time
const evtHour = evt.scheduledFor.getUTCHours();
const evtMin = evt.scheduledFor.getUTCMinutes();
let bestSchedule = medication.schedules[0];
let bestDist = Infinity;
for (const sched of medication.schedules) {
const [sh, sm] = sched.windowStart.split(":").map(Number);
const dist = Math.abs(evtHour * 60 + evtMin - (sh * 60 + sm));
if (dist < bestDist) {
bestDist = dist;
bestSchedule = sched;
}
}
const timing = classifyIntakeTiming(
evt.takenAt,
bestSchedule.windowStart,
bestSchedule.windowEnd,
dayStart, // the scheduled date
);
if (timing === "on_time") onTime++;
else if (timing === "late") late++;
else veryLate++;
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
dailyCompliance[dateKey] = {
expected: schedulesPerDay,
taken: takenEvents.length,
skipped: dayEvents.filter((e) => e.skipped).length,
onTime,
late,
veryLate,
};
}
const events = await prisma.medicationIntakeEvent.findMany({
where: { medicationId: id, userId: user.id },
orderBy: { scheduledFor: "desc" },
});
annotate({
action: {
name: "medication.compliance",
entity_type: "medication",
entity_id: id,
},
meta: { compliance7: compliance7.rate, compliance30: compliance30.rate },
});
const mapped = events.map((e) => ({
takenAt: e.takenAt,
skipped: e.skipped,
scheduledFor: e.scheduledFor,
}));
return apiSuccess({ compliance7, compliance30, dailyCompliance });
});
const createdAt = medication.createdAt;
const compliance7 = calculateCompliance(
mapped,
medication.schedules,
7,
createdAt,
);
const compliance30 = calculateCompliance(
mapped,
medication.schedules,
30,
createdAt,
);
// Build daily compliance map for heatmap/line chart (90 days)
const now = new Date();
const schedulesPerDay = medication.schedules.length;
const dailyCompliance: Record<string, DailyComplianceEntry> = {};
for (let d = 0; d < 90; d++) {
const dayStart = new Date(now.getTime() - (d + 1) * 24 * 60 * 60 * 1000);
const dayEnd = new Date(now.getTime() - d * 24 * 60 * 60 * 1000);
// Skip days before medication was created
if (dayEnd <= createdAt) continue;
const dateKey = dayStart.toISOString().slice(0, 10);
const dayEvents = mapped.filter(
(e) => e.scheduledFor >= dayStart && e.scheduledFor < dayEnd,
);
const takenEvents = dayEvents.filter(
(e) => e.takenAt !== null && !e.skipped,
);
// Classify timing for each taken event against the best-matching schedule
let onTime = 0;
let late = 0;
let veryLate = 0;
for (const evt of takenEvents) {
if (medication.schedules.length === 0) {
// No schedule info: treat all taken as on_time
onTime++;
continue;
}
// Match event to the closest schedule window by scheduledFor time
const evtHour = evt.scheduledFor.getUTCHours();
const evtMin = evt.scheduledFor.getUTCMinutes();
let bestSchedule = medication.schedules[0];
let bestDist = Infinity;
for (const sched of medication.schedules) {
const [sh, sm] = sched.windowStart.split(":").map(Number);
const dist = Math.abs(evtHour * 60 + evtMin - (sh * 60 + sm));
if (dist < bestDist) {
bestDist = dist;
bestSchedule = sched;
}
}
const timing = classifyIntakeTiming(
evt.takenAt,
bestSchedule.windowStart,
bestSchedule.windowEnd,
dayStart, // the scheduled date
);
if (timing === "on_time") onTime++;
else if (timing === "late") late++;
else veryLate++;
}
dailyCompliance[dateKey] = {
expected: schedulesPerDay,
taken: takenEvents.length,
skipped: dayEvents.filter((e) => e.skipped).length,
onTime,
late,
veryLate,
};
}
annotate({
action: {
name: "medication.compliance",
entity_type: "medication",
entity_id: id,
},
meta: { compliance7: compliance7.rate, compliance30: compliance30.rate },
});
return apiSuccess({ compliance7, compliance30, dailyCompliance });
},
);
@@ -2,101 +2,102 @@ 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 { updateIntakeEventSchema } from "@/lib/validations/medication";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string; eventId: string }> };
export const PUT = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
export const PUT = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id, eventId } = await params;
const { id, eventId } = await params;
const event = await prisma.medicationIntakeEvent.findUnique({
where: { id: eventId },
});
const event = await prisma.medicationIntakeEvent.findUnique({
where: { id: eventId },
});
if (
!event ||
event.userId !== user.id ||
event.medicationId !== id
) {
return apiError("Intake not found", 404);
}
if (!event || event.userId !== user.id || event.medicationId !== id) {
return apiError("Intake not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = updateIntakeEventSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
if (jsonError) return jsonError;
const parsed = updateIntakeEventSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const data = parsed.data;
const updated = await prisma.medicationIntakeEvent.update({
where: { id: eventId },
data: {
...(data.takenAt !== undefined && { takenAt: data.takenAt }),
...(data.skipped !== undefined && { skipped: data.skipped }),
...(data.scheduledFor !== undefined && {
scheduledFor: data.scheduledFor,
}),
},
});
const data = parsed.data;
const updated = await prisma.medicationIntakeEvent.update({
where: { id: eventId },
data: {
...(data.takenAt !== undefined && { takenAt: data.takenAt }),
...(data.skipped !== undefined && { skipped: data.skipped }),
...(data.scheduledFor !== undefined && {
scheduledFor: data.scheduledFor,
}),
},
});
await auditLog("medication.intake.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { eventId, medicationId: id },
});
await auditLog("medication.intake.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { eventId, medicationId: id },
});
annotate({
action: {
name: "medication.intake.update",
entity_type: "intake_event",
entity_id: eventId,
},
meta: { medication_id: id },
});
annotate({
action: {
name: "medication.intake.update",
entity_type: "intake_event",
entity_id: eventId,
},
meta: { medication_id: id },
});
return apiSuccess(updated);
});
return apiSuccess(updated);
},
);
export const DELETE = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id, eventId } = await params;
const { id, eventId } = await params;
const event = await prisma.medicationIntakeEvent.findUnique({
where: { id: eventId },
});
const event = await prisma.medicationIntakeEvent.findUnique({
where: { id: eventId },
});
if (
!event ||
event.userId !== user.id ||
event.medicationId !== id
) {
return apiError("Intake not found", 404);
}
if (!event || event.userId !== user.id || event.medicationId !== id) {
return apiError("Intake not found", 404);
}
await prisma.medicationIntakeEvent.delete({ where: { id: eventId } });
await prisma.medicationIntakeEvent.delete({ where: { id: eventId } });
const ip = getClientIp(request) ?? "unknown";
await auditLog("medication.intake.delete", {
userId: user.id,
ipAddress: ip,
details: { eventId, medicationId: id },
});
const ip = getClientIp(request) ?? "unknown";
await auditLog("medication.intake.delete", {
userId: user.id,
ipAddress: ip,
details: { eventId, medicationId: id },
});
annotate({
action: {
name: "medication.intake.delete",
entity_type: "intake_event",
entity_id: eventId,
},
meta: { medication_id: id },
});
annotate({
action: {
name: "medication.intake.delete",
entity_type: "intake_event",
entity_id: eventId,
},
meta: { medication_id: id },
});
return apiSuccess({ deleted: true });
});
return apiSuccess({ deleted: true });
},
);
@@ -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 { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -16,97 +21,101 @@ const importSchema = z.array(importEntrySchema).min(1).max(1000);
type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
export const POST = 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 { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
// Support both direct arrays and object-with-array payloads
const payload = Array.isArray(body)
? body
: typeof body === "object" && body !== null
? Object.values(body).find((value) => Array.isArray(value))
: null;
const parsed = importSchema.safeParse(payload);
if (!parsed.success) {
return apiError(
`Invalid format: ${parsed.error.issues[0].message}`,
422,
);
}
const entries = parsed.data;
let imported = 0;
let skippedDuplicates = 0;
let skippedInvalid = 0;
for (const entry of entries) {
// Parse as local datetime first; if invalid, fall back to CET offset.
let takenAt = new Date(`${entry.datum}T${entry.uhrzeit}`);
if (isNaN(takenAt.getTime())) {
takenAt = new Date(`${entry.datum}T${entry.uhrzeit}+01:00`);
}
if (isNaN(takenAt.getTime())) {
skippedInvalid++;
continue;
const { id } = await params;
const medication = await prisma.medication.findUnique({ where: { id } });
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
// Prefer explicit counter; otherwise use timestamp-based dedup key.
const idempotencyKey = entry.zaehler
? `import-${id}-${String(entry.zaehler)}`
: `import-${id}-${takenAt.getTime()}`;
const { data: body, error: jsonError } = await safeJson(request);
const existing = await prisma.medicationIntakeEvent.findUnique({
where: { idempotencyKey },
});
if (existing) {
skippedDuplicates++;
continue;
if (jsonError) return jsonError;
// Support both direct arrays and object-with-array payloads
const payload = Array.isArray(body)
? body
: typeof body === "object" && body !== null
? Object.values(body).find((value) => Array.isArray(value))
: null;
const parsed = importSchema.safeParse(payload);
if (!parsed.success) {
return apiError(`Invalid format: ${parsed.error.issues[0].message}`, 422);
}
await prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
const entries = parsed.data;
let imported = 0;
let skippedDuplicates = 0;
let skippedInvalid = 0;
for (const entry of entries) {
// Parse as local datetime first; if invalid, fall back to CET offset.
let takenAt = new Date(`${entry.datum}T${entry.uhrzeit}`);
if (isNaN(takenAt.getTime())) {
takenAt = new Date(`${entry.datum}T${entry.uhrzeit}+01:00`);
}
if (isNaN(takenAt.getTime())) {
skippedInvalid++;
continue;
}
// Prefer explicit counter; otherwise use timestamp-based dedup key.
const idempotencyKey = entry.zaehler
? `import-${id}-${String(entry.zaehler)}`
: `import-${id}-${takenAt.getTime()}`;
const existing = await prisma.medicationIntakeEvent.findUnique({
where: { idempotencyKey },
});
if (existing) {
skippedDuplicates++;
continue;
}
await prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: id,
scheduledFor: takenAt,
takenAt,
skipped: false,
source: "IMPORT",
idempotencyKey,
},
});
imported++;
}
await auditLog("medication.intake.import", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
scheduledFor: takenAt,
takenAt,
skipped: false,
source: "IMPORT",
idempotencyKey,
imported,
skippedDuplicates,
skippedInvalid,
total: entries.length,
},
});
imported++;
}
await auditLog("medication.intake.import", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
imported,
skippedDuplicates,
skippedInvalid,
total: entries.length,
},
});
annotate({
action: {
name: "medication.intake.import",
entity_type: "medication",
entity_id: id,
},
meta: {
imported,
skippedDuplicates,
skippedInvalid,
total: entries.length,
},
});
annotate({
action: {
name: "medication.intake.import",
entity_type: "medication",
entity_id: id,
},
meta: { imported, skippedDuplicates, skippedInvalid, total: entries.length },
});
return apiSuccess({ imported, skippedDuplicates, skippedInvalid }, 201);
});
return apiSuccess({ imported, skippedDuplicates, skippedInvalid }, 201);
},
);
@@ -7,33 +7,35 @@ import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
export const DELETE = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
export const DELETE = 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 { count } = await prisma.medicationIntakeEvent.deleteMany({
where: { medicationId: id, userId: user.id },
});
const { count } = await prisma.medicationIntakeEvent.deleteMany({
where: { medicationId: id, userId: user.id },
});
await auditLog("medication.intake.purge", {
userId: user.id,
ipAddress: getClientIp(request),
details: { medicationId: id, name: medication.name, deletedCount: count },
});
await auditLog("medication.intake.purge", {
userId: user.id,
ipAddress: getClientIp(request),
details: { medicationId: id, name: medication.name, deletedCount: count },
});
annotate({
action: {
name: "medication.intake.purge",
entity_type: "medication",
entity_id: id,
},
meta: { purged_count: count },
});
annotate({
action: {
name: "medication.intake.purge",
entity_type: "medication",
entity_id: id,
},
meta: { purged_count: count },
});
return apiSuccess({ purged: true, count });
});
return apiSuccess({ purged: true, count });
},
);
@@ -7,108 +7,114 @@ import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
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 },
select: { userId: true },
});
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
const { id } = await params;
const medication = await prisma.medication.findUnique({
where: { id },
select: { userId: true },
});
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
const config = await prisma.reminderPhaseConfig.findUnique({
where: { medicationId: id },
});
const config = await prisma.reminderPhaseConfig.findUnique({
where: { medicationId: id },
});
annotate({
action: {
name: "medication.phase_config.get",
entity_type: "medication",
entity_id: id,
},
});
annotate({
action: {
name: "medication.phase_config.get",
entity_type: "medication",
entity_id: id,
},
});
// Return config or defaults
return apiSuccess(
config ?? {
greenValue: 60,
greenMode: "MINUTES",
yellowValue: 30,
yellowMode: "MINUTES",
orangeValue: 0,
orangeMode: "MINUTES",
redValue: 240,
redMode: "MINUTES",
},
);
});
// Return config or defaults
return apiSuccess(
config ?? {
greenValue: 60,
greenMode: "MINUTES",
yellowValue: 30,
yellowMode: "MINUTES",
orangeValue: 0,
orangeMode: "MINUTES",
redValue: 240,
redMode: "MINUTES",
},
);
},
);
export const PUT = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
export const PUT = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const medication = await prisma.medication.findUnique({
where: { id },
select: { userId: true },
});
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
const { id } = await params;
const medication = await prisma.medication.findUnique({
where: { id },
select: { userId: true },
});
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = phaseConfigSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid input", 400);
}
if (jsonError) return jsonError;
const parsed = phaseConfigSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid input", 400);
}
const config = await prisma.reminderPhaseConfig.upsert({
where: { medicationId: id },
create: {
medicationId: id,
...parsed.data,
},
update: parsed.data,
});
const config = await prisma.reminderPhaseConfig.upsert({
where: { medicationId: id },
create: {
medicationId: id,
...parsed.data,
},
update: parsed.data,
});
annotate({
action: {
name: "medication.phase_config.update",
entity_type: "medication",
entity_id: id,
},
});
annotate({
action: {
name: "medication.phase_config.update",
entity_type: "medication",
entity_id: id,
},
});
return apiSuccess(config);
});
return apiSuccess(config);
},
);
export const DELETE = apiHandler(async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
export const DELETE = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const medication = await prisma.medication.findUnique({
where: { id },
select: { userId: true },
});
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
const { id } = await params;
const medication = await prisma.medication.findUnique({
where: { id },
select: { userId: true },
});
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
await prisma.reminderPhaseConfig.deleteMany({
where: { medicationId: id },
});
await prisma.reminderPhaseConfig.deleteMany({
where: { medicationId: id },
});
annotate({
action: {
name: "medication.phase_config.reset",
entity_type: "medication",
entity_id: id,
},
});
annotate({
action: {
name: "medication.phase_config.reset",
entity_type: "medication",
entity_id: id,
},
});
return apiSuccess({ reset: true });
});
return apiSuccess({ reset: true });
},
);
+183 -172
View File
@@ -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 { updateMedicationSchema } from "@/lib/validations/medication";
import {
deleteMedicationCategory,
@@ -14,193 +19,199 @@ import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
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 },
include: { schedules: true },
});
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
let category = "OTHER";
try {
const categories = await getMedicationCategories([id]);
category = categories[id] ?? "OTHER";
} catch {
// Category enrichment is optional
}
annotate({
action: {
name: "medication.get",
entity_type: "medication",
entity_id: id,
},
});
return apiSuccess({ ...medication, category });
});
export const PUT = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const existing = await prisma.medication.findUnique({ where: { id } });
if (!existing || existing.userId !== user.id) {
return apiError("Medication not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = updateMedicationSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const { name, dose, category, active, notificationsEnabled, schedules } =
parsed.data;
const pausedAtPatch =
active === undefined
? {}
: active
? { pausedAt: null as Date | null }
: existing.active
? { pausedAt: new Date() }
: {};
// If schedules provided, replace all
if (schedules) {
await prisma.medicationSchedule.deleteMany({
where: { medicationId: id },
const { id } = await params;
const medication = await prisma.medication.findUnique({
where: { id },
include: { schedules: true },
});
}
const baseUpdateData = {
...(name !== undefined && { name }),
...(dose !== undefined && { dose }),
...(active !== undefined && { active }),
...(notificationsEnabled !== undefined && { notificationsEnabled }),
...(schedules && {
schedules: {
create: schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
daysOfWeek: serializeScheduleRecurrence({
daysOfWeek: s.daysOfWeek ?? [],
intervalWeeks: s.intervalWeeks ?? 1,
}),
})),
},
}),
};
const withoutNotifications = { ...baseUpdateData } as Record<
string,
unknown
>;
delete withoutNotifications.notificationsEnabled;
const hasPausedAtPatch = Object.keys(pausedAtPatch).length > 0;
const hasNotificationsPatch = notificationsEnabled !== undefined;
const updateCandidates: Array<Record<string, unknown>> = [
{ ...baseUpdateData, ...pausedAtPatch },
];
if (hasPausedAtPatch) {
updateCandidates.push(baseUpdateData);
}
if (hasNotificationsPatch) {
updateCandidates.push({ ...withoutNotifications, ...pausedAtPatch });
if (hasPausedAtPatch) {
updateCandidates.push(withoutNotifications);
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
}
let medication;
let lastUpdateErr: unknown;
for (const candidate of updateCandidates) {
let category = "OTHER";
try {
medication = await prisma.medication.update({
where: { id },
data: candidate,
include: { schedules: true },
});
break;
} catch (updateErr) {
lastUpdateErr = updateErr;
const categories = await getMedicationCategories([id]);
category = categories[id] ?? "OTHER";
} catch {
// Category enrichment is optional
}
}
if (!medication) throw lastUpdateErr;
const normalizedCategory =
category !== undefined
? await setMedicationCategory(id, category)
: ((await getMedicationCategories([id]))[id] ?? "OTHER");
annotate({
action: {
name: "medication.get",
entity_type: "medication",
entity_id: id,
},
});
await auditLog("medication.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { medicationId: id },
});
return apiSuccess({ ...medication, category });
},
);
annotate({
action: {
name: "medication.update",
entity_type: "medication",
entity_id: id,
},
});
export const PUT = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
return apiSuccess({
...medication,
category: normalizedCategory,
});
});
const { id } = await params;
const existing = await prisma.medication.findUnique({ where: { id } });
if (!existing || existing.userId !== user.id) {
return apiError("Medication not found", 404);
}
export const DELETE = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request);
const { id } = await params;
const existing = await prisma.medication.findUnique({ where: { id } });
if (!existing || existing.userId !== user.id) {
return apiError("Medication not found", 404);
}
if (jsonError) return jsonError;
const parsed = updateMedicationSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
// Revoke API tokens scoped to this medication
const medicationScope = `medication:${id}:ingest`;
await prisma.apiToken.updateMany({
where: {
const { name, dose, category, active, notificationsEnabled, schedules } =
parsed.data;
const pausedAtPatch =
active === undefined
? {}
: active
? { pausedAt: null as Date | null }
: existing.active
? { pausedAt: new Date() }
: {};
// If schedules provided, replace all
if (schedules) {
await prisma.medicationSchedule.deleteMany({
where: { medicationId: id },
});
}
const baseUpdateData = {
...(name !== undefined && { name }),
...(dose !== undefined && { dose }),
...(active !== undefined && { active }),
...(notificationsEnabled !== undefined && { notificationsEnabled }),
...(schedules && {
schedules: {
create: schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
daysOfWeek: serializeScheduleRecurrence({
daysOfWeek: s.daysOfWeek ?? [],
intervalWeeks: s.intervalWeeks ?? 1,
}),
})),
},
}),
};
const withoutNotifications = { ...baseUpdateData } as Record<
string,
unknown
>;
delete withoutNotifications.notificationsEnabled;
const hasPausedAtPatch = Object.keys(pausedAtPatch).length > 0;
const hasNotificationsPatch = notificationsEnabled !== undefined;
const updateCandidates: Array<Record<string, unknown>> = [
{ ...baseUpdateData, ...pausedAtPatch },
];
if (hasPausedAtPatch) {
updateCandidates.push(baseUpdateData);
}
if (hasNotificationsPatch) {
updateCandidates.push({ ...withoutNotifications, ...pausedAtPatch });
if (hasPausedAtPatch) {
updateCandidates.push(withoutNotifications);
}
}
let medication;
let lastUpdateErr: unknown;
for (const candidate of updateCandidates) {
try {
medication = await prisma.medication.update({
where: { id },
data: candidate,
include: { schedules: true },
});
break;
} catch (updateErr) {
lastUpdateErr = updateErr;
}
}
if (!medication) throw lastUpdateErr;
const normalizedCategory =
category !== undefined
? await setMedicationCategory(id, category)
: ((await getMedicationCategories([id]))[id] ?? "OTHER");
await auditLog("medication.update", {
userId: user.id,
revoked: false,
permissions: { has: medicationScope },
},
data: { revoked: true },
});
ipAddress: getClientIp(request),
details: { medicationId: id },
});
await deleteMedicationCategory(id);
await prisma.medication.delete({ where: { id } });
annotate({
action: {
name: "medication.update",
entity_type: "medication",
entity_id: id,
},
});
await auditLog("medication.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { medicationId: id, name: existing.name },
});
return apiSuccess({
...medication,
category: normalizedCategory,
});
},
);
annotate({
action: {
name: "medication.delete",
entity_type: "medication",
entity_id: id,
},
});
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
return apiSuccess({ deleted: true });
});
const { id } = await params;
const existing = await prisma.medication.findUnique({ where: { id } });
if (!existing || existing.userId !== user.id) {
return apiError("Medication not found", 404);
}
// Revoke API tokens scoped to this medication
const medicationScope = `medication:${id}:ingest`;
await prisma.apiToken.updateMany({
where: {
userId: user.id,
revoked: false,
permissions: { has: medicationScope },
},
data: { revoked: true },
});
await deleteMedicationCategory(id);
await prisma.medication.delete({ where: { id } });
await auditLog("medication.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { medicationId: id, name: existing.name },
});
annotate({
action: {
name: "medication.delete",
entity_type: "medication",
entity_id: id,
},
});
return apiSuccess({ deleted: true });
},
);
@@ -92,7 +92,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({
action: { name: "medication.intake_summary" },
meta: { point_count: points.length, medication_count: medicationNames.size },
meta: {
point_count: points.length,
medication_count: medicationNames.size,
},
});
return apiSuccess({
@@ -27,7 +27,11 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, POST } from "../route";
@@ -41,7 +45,9 @@ const SESSION_OK = {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
});
describe("GET /api/medications/intake", () => {
+7 -3
View File
@@ -14,7 +14,12 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
@@ -175,8 +180,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
} else {
// snoozed: snoozedUntil lives on the Medication row.
const until =
snoozedUntil ?? new Date(Date.now() + 30 * 60_000); // default +30min
const until = snoozedUntil ?? new Date(Date.now() + 30 * 60_000); // default +30min
await prisma.medication.update({
where: { id: existing.medicationId },
data: { snoozedUntil: until },
+6 -1
View File
@@ -2,7 +2,12 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } 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 {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { createMedicationSchema } from "@/lib/validations/medication";
import {
getMedicationCategories,
+8 -1
View File
@@ -53,7 +53,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (!delivery.ok) {
getEvent()?.addWarning("Glitchtip event rejected: " + delivery.method + " " + delivery.status + " " + delivery.details);
getEvent()?.addWarning(
"Glitchtip event rejected: " +
delivery.method +
" " +
delivery.status +
" " +
delivery.details,
);
return apiError("Glitchtip could not accept error", 502);
}
+85 -65
View File
@@ -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 {
updateMoodEntrySchema,
getScoreForMood,
@@ -26,92 +31,107 @@ function parseTags(tags: string | null): string[] {
}
}
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 { id } = await params;
const entry = await prisma.moodEntry.findUnique({ where: { id } });
const entry = await prisma.moodEntry.findUnique({ where: { id } });
if (!entry || entry.userId !== user.id) {
return apiError("Mood entry not found", 404);
}
if (!entry || entry.userId !== user.id) {
return apiError("Mood entry not found", 404);
}
annotate({ action: { name: "mood-entries.get" }, meta: { moodEntryId: id } });
annotate({
action: { name: "mood-entries.get" },
meta: { moodEntryId: id },
});
return apiSuccess({ ...entry, tags: parseTags(entry.tags) });
});
return apiSuccess({ ...entry, tags: parseTags(entry.tags) });
},
);
export const PUT = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
export const PUT = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const { id } = await params;
const existing = await prisma.moodEntry.findUnique({ where: { id } });
const existing = await prisma.moodEntry.findUnique({ where: { id } });
if (!existing || existing.userId !== user.id) {
return apiError("Mood entry not found", 404);
}
if (!existing || existing.userId !== user.id) {
return apiError("Mood entry not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = updateMoodEntrySchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
if (jsonError) return jsonError;
const parsed = updateMoodEntrySchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const data = parsed.data;
const data = parsed.data;
const updateData: Record<string, unknown> = {};
if (data.mood !== undefined) {
updateData.mood = data.mood;
updateData.score = getScoreForMood(data.mood);
}
if (data.moodLoggedAt !== undefined) {
updateData.moodLoggedAt = data.moodLoggedAt;
updateData.date = toBerlinDate(data.moodLoggedAt);
}
if (data.tags !== undefined) {
updateData.tags = data.tags ? JSON.stringify(data.tags) : null;
}
const updateData: Record<string, unknown> = {};
if (data.mood !== undefined) {
updateData.mood = data.mood;
updateData.score = getScoreForMood(data.mood);
}
if (data.moodLoggedAt !== undefined) {
updateData.moodLoggedAt = data.moodLoggedAt;
updateData.date = toBerlinDate(data.moodLoggedAt);
}
if (data.tags !== undefined) {
updateData.tags = data.tags ? JSON.stringify(data.tags) : null;
}
const entry = await prisma.moodEntry.update({
where: { id },
data: updateData,
});
const entry = await prisma.moodEntry.update({
where: { id },
data: updateData,
});
await auditLog("moodEntry.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { moodEntryId: id },
});
await auditLog("moodEntry.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { moodEntryId: id },
});
annotate({ action: { name: "mood-entries.update" }, meta: { moodEntryId: id } });
annotate({
action: { name: "mood-entries.update" },
meta: { moodEntryId: id },
});
return apiSuccess({ ...entry, tags: parseTags(entry.tags) });
});
return apiSuccess({ ...entry, tags: parseTags(entry.tags) });
},
);
export const DELETE = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const { id } = await params;
const existing = await prisma.moodEntry.findUnique({ where: { id } });
const existing = await prisma.moodEntry.findUnique({ where: { id } });
if (!existing || existing.userId !== user.id) {
return apiError("Mood entry not found", 404);
}
if (!existing || existing.userId !== user.id) {
return apiError("Mood entry not found", 404);
}
await prisma.moodEntry.delete({ where: { id } });
await prisma.moodEntry.delete({ where: { id } });
await auditLog("moodEntry.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { moodEntryId: id, mood: existing.mood },
});
await auditLog("moodEntry.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { moodEntryId: id, mood: existing.mood },
});
annotate({ action: { name: "mood-entries.delete" }, meta: { moodEntryId: id } });
annotate({
action: { name: "mood-entries.delete" },
meta: { moodEntryId: id },
});
return apiSuccess({ deleted: true });
});
return apiSuccess({ deleted: true });
},
);
+4 -1
View File
@@ -47,7 +47,10 @@ export const GET = apiHandler(async () => {
const summary = summarize(dataPoints);
annotate({ action: { name: "mood.analytics" }, meta: { entryCount: moodEntries.length } });
annotate({
action: { name: "mood.analytics" },
meta: { entryCount: moodEntries.length },
});
return apiSuccess({ entries, summary });
});
+1 -4
View File
@@ -34,10 +34,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
if (user.role === "ADMIN") {
const adminCount = await prisma.user.count({ where: { role: "ADMIN" } });
if (adminCount <= 1) {
return apiError(
"The last admin account cannot be deleted",
400,
);
return apiError("The last admin account cannot be deleted", 400);
}
}
+1 -4
View File
@@ -3,10 +3,7 @@ import { randomBytes } from "node:crypto";
import { prisma } from "@/lib/db";
import { apiSuccess, apiError } from "@/lib/api-response";
import { encrypt } from "@/lib/crypto";
import {
encryptMoodLogSecret,
readMoodLogSecret,
} from "@/lib/moodlog-secret";
import { encryptMoodLogSecret, readMoodLogSecret } from "@/lib/moodlog-secret";
import { moodLogCredentialsSchema } from "@/lib/validations/moodlog";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
+1 -2
View File
@@ -38,8 +38,7 @@ export const POST = apiHandler(async () => {
eventType: "SYSTEM_ALERT",
userId: user.id,
title: "HealthLog Test",
message:
"HealthLog: Connection successful! ntfy notifications are active.",
message: "HealthLog: Connection successful! ntfy notifications are active.",
});
if (!success) {
+1 -4
View File
@@ -91,10 +91,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
try {
appBaseUrl = new URL(appUrl);
} catch {
return apiError(
"Server configuration error: APP_URL is invalid.",
500,
);
return apiError("Server configuration error: APP_URL is invalid.", 500);
}
if (appBaseUrl.protocol !== "https:") {
+5 -5
View File
@@ -23,10 +23,7 @@ export const POST = apiHandler(async () => {
});
if (!dbUser?.telegramBotToken || !dbUser?.telegramChatId) {
return apiError(
"Bot token and chat ID must be saved first",
422,
);
return apiError("Bot token and chat ID must be saved first", 422);
}
const botToken = decrypt(dbUser.telegramBotToken);
@@ -43,7 +40,10 @@ export const POST = apiHandler(async () => {
);
}
annotate({ action: { name: "settings.telegram.test" }, meta: { success: true } });
annotate({
action: { name: "settings.telegram.test" },
meta: { success: true },
});
return apiSuccess({ sent: true });
});
+10 -3
View File
@@ -244,7 +244,9 @@ async function handleCallback(update: TelegramUpdate) {
});
const duration =
minutes <= 60 ? t("telegram.snoozeOneHour") : t("telegram.snoozeThreeHours");
minutes <= 60
? t("telegram.snoozeOneHour")
: t("telegram.snoozeThreeHours");
await answerTelegramCallbackQuery(
botToken,
callback.id,
@@ -350,7 +352,8 @@ async function handleCallback(update: TelegramUpdate) {
// Format: "add:{medicationId}" or "add:{medicationId}:umid:{userMsgId}"
const withoutPrefix = data.slice("add:".length);
const umidIdx = withoutPrefix.indexOf(":umid:");
const medicationId = umidIdx >= 0 ? withoutPrefix.slice(0, umidIdx) : withoutPrefix.trim();
const medicationId =
umidIdx >= 0 ? withoutPrefix.slice(0, umidIdx) : withoutPrefix.trim();
if (!medicationId) {
await answerTelegramCallbackQuery(
botToken,
@@ -395,7 +398,11 @@ async function handleCallback(update: TelegramUpdate) {
await deleteMessage(botToken, chatId, userMsgId).catch(() => {});
}
}
await answerTelegramCallbackQuery(botToken, callback.id, t("telegram.cancelled"));
await answerTelegramCallbackQuery(
botToken,
callback.id,
t("telegram.cancelled"),
);
} else {
await answerTelegramCallbackQuery(
botToken,
+19 -17
View File
@@ -9,25 +9,27 @@ type RouteParams = { params: Promise<{ id: string }> };
/**
* Revoke an API token.
*/
export const DELETE = apiHandler(async (_request: Request, { params }: RouteParams) => {
const { user } = await requireAuth();
annotate({ action: { name: "tokens.revoke" } });
export const DELETE = apiHandler(
async (_request: Request, { params }: RouteParams) => {
const { user } = await requireAuth();
annotate({ action: { name: "tokens.revoke" } });
if (!(await isApiGloballyEnabled())) {
return apiError("API is globally disabled", 403);
}
if (!(await isApiGloballyEnabled())) {
return apiError("API is globally disabled", 403);
}
const { id } = await params;
const token = await prisma.apiToken.findUnique({ where: { id } });
const { id } = await params;
const token = await prisma.apiToken.findUnique({ where: { id } });
if (!token || token.userId !== user.id) {
return apiError("Token not found", 404);
}
if (!token || token.userId !== user.id) {
return apiError("Token not found", 404);
}
await prisma.apiToken.update({
where: { id },
data: { revoked: true },
});
await prisma.apiToken.update({
where: { id },
data: { revoked: true },
});
return apiSuccess({ revoked: true });
});
return apiSuccess({ revoked: true });
},
);
+6 -2
View File
@@ -46,7 +46,8 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "user.ai-provider.update" } });
const { data: body, error } = await safeJson<Record<string, unknown>>(request);
const { data: body, error } =
await safeJson<Record<string, unknown>>(request);
if (error) return error;
const updates: Record<string, unknown> = {};
@@ -54,7 +55,10 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
if (body.provider !== undefined) {
if (body.provider === null || body.provider === "") {
updates.aiProvider = null;
} else if (typeof body.provider === "string" && ALLOWED.has(body.provider)) {
} else if (
typeof body.provider === "string" &&
ALLOWED.has(body.provider)
) {
updates.aiProvider = body.provider;
} else {
throw new HttpError(422, "Invalid provider");
@@ -21,7 +21,11 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PATCH } from "../route";
+6 -1
View File
@@ -8,7 +8,12 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { applyProfileUpdate } from "@/lib/auth/profile-update";
import { prisma } from "@/lib/db";
+9 -6
View File
@@ -8,7 +8,12 @@
* DELETE with ?metric=... resets one metric to default.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, safeJson, getClientIp } from "@/lib/api-response";
import {
apiSuccess,
apiError,
safeJson,
getClientIp,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
@@ -36,9 +41,8 @@ async function loadProfileAndOverrides(userId: string) {
},
});
if (!profile) return null;
const overrides = (profile.thresholdsJson ?? null) as
| ThresholdOverridesJson
| null;
const overrides = (profile.thresholdsJson ??
null) as ThresholdOverridesJson | null;
return { profile, overrides };
}
@@ -130,8 +134,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
await prisma.user.update({
where: { id: user.id },
data: {
thresholdsJson:
Object.keys(after).length === 0 ? Prisma.JsonNull : after,
thresholdsJson: Object.keys(after).length === 0 ? Prisma.JsonNull : after,
},
});
+2 -1
View File
@@ -23,7 +23,8 @@ export const GET = apiHandler(async () => {
});
const configured =
!!dbUser?.withingsClientIdEncrypted && !!dbUser?.withingsClientSecretEncrypted;
!!dbUser?.withingsClientIdEncrypted &&
!!dbUser?.withingsClientSecretEncrypted;
const connection = await prisma.withingsConnection.findUnique({
where: { userId: user.id },
+9 -3
View File
@@ -72,7 +72,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
const formData = await request.formData();
withingsUserId = formData.get("userid") as string;
} else {
const { data: body, error: jsonError } = await safeJson<{ userid?: string | number }>(request);
const { data: body, error: jsonError } = await safeJson<{
userid?: string | number;
}>(request);
if (jsonError) return jsonError;
withingsUserId = body.userid?.toString() ?? null;
}
@@ -89,13 +91,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (!connection) {
getEvent()?.addWarning("Webhook for unknown withings user: " + withingsUserId);
getEvent()?.addWarning(
"Webhook for unknown withings user: " + withingsUserId,
);
return NextResponse.json({ status: "unknown_user" }, { status: 200 });
}
// Sync measurements (non-blocking response for Withings)
syncUserMeasurements(connection.userId).catch((err) => {
getEvent()?.addWarning("Sync failed for user " + connection.userId + ": " + err);
getEvent()?.addWarning(
"Sync failed for user " + connection.userId + ": " + err,
);
});
return NextResponse.json({ status: "ok" }, { status: 200 });