mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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[]) ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) },
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" } });
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:") {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user