chore(release): v1.6.0

Medication editor overhaul, route of administration, today-tile read-flip.

- Route-of-administration column (ORAL | INJECTION | OTHER) decoupled from
  treatmentClass; injection-site picker surfaces for any injection.
  Migration 0088 backfills ORAL via a constant column default.
- One-time injection (oneShot + deliveryForm = INJECTION) editor path.
- Profile-photo upload card on Settings → Account.
- Today-tile read-flip: /api/medications/intake?scope=today and
  /api/dashboard/summary gate through the canonical recurrence engine,
  matching the reminder worker for bi-weekly, rolling, RRULE, and one-time
  cadences.
- Measurement note cap 25 → 200; dashboard range colors on semantic tokens;
  admin sidebar icons + shared-helper consolidation; API-tokens multi-language
  snippets restored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-30 00:10:12 +02:00
1 parent d01d1d1dc9
commit 8ee7aaac44
53 files changed
+1049 -1017

No files matched your search

+2 -2
View File
@@ -39,8 +39,8 @@ export const GET = apiHandler(async () => {
const payload: BackupsList = {
rows,
// Mirrors the value surfaced by `/api/admin/status-overview` so the
// backups page and the status card grid agree.
// Matches the retention window the backup-prune job enforces so the
// backups page states the same number the worker acts on.
retentionDays: 90,
};
+3 -3
View File
@@ -135,9 +135,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
meta: {
since: parsed.since,
count: samples.length,
// Memory total in bytes is useful so the chart can render an
// absolute label ("4.2 GiB / 8 GiB") in the tooltip without a
// second round-trip. Latest row wins; falls back to 0 on empty.
// Memory total in bytes feeds the tooltip's absolute label
// ("X.X / Y.Y GiB") so the percentage isn't read in a vacuum.
// Latest row wins; falls back to 0 on empty.
memTotalBytes: rows.length
? Number(rows[rows.length - 1].memTotalBytes)
: 0,
@@ -1,191 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: { count: vi.fn() },
withingsConnection: { count: vi.fn() },
notificationChannel: { count: vi.fn() },
pushSubscription: { count: vi.fn() },
appSettings: { findUnique: vi.fn() },
auditLog: { findFirst: vi.fn(), count: vi.fn() },
dataBackup: { findFirst: vi.fn(), findMany: vi.fn() },
},
}));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
h: T,
): T => h,
requireAdmin: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/jobs/worker-status", () => ({
getWorkerStatus: vi.fn(() => ({
running: true,
startedAt: new Date(Date.now() - 3_600_000).toISOString(),
lastHeartbeat: null,
lastReminderCheck: null,
lastWithingsSync: null,
lastInsightsRun: null,
jobsProcessed: 0,
errors: 0,
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
const mocks = prisma as unknown as {
user: { count: ReturnType<typeof vi.fn> };
withingsConnection: { count: ReturnType<typeof vi.fn> };
notificationChannel: { count: ReturnType<typeof vi.fn> };
pushSubscription: { count: ReturnType<typeof vi.fn> };
appSettings: { findUnique: ReturnType<typeof vi.fn> };
auditLog: {
findFirst: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
};
dataBackup: {
findFirst: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
};
};
describe("GET /api/admin/status-overview", () => {
beforeEach(() => {
vi.clearAllMocks();
// default returns
mocks.user.count.mockResolvedValue(0);
mocks.withingsConnection.count.mockResolvedValue(0);
mocks.notificationChannel.count.mockResolvedValue(0);
mocks.pushSubscription.count.mockResolvedValue(0);
mocks.appSettings.findUnique.mockResolvedValue(null);
mocks.auditLog.findFirst.mockResolvedValue(null);
mocks.auditLog.count.mockResolvedValue(0);
mocks.dataBackup.findFirst.mockResolvedValue(null);
mocks.dataBackup.findMany.mockResolvedValue([]);
});
it("returns the 6-section overview shape", async () => {
const res = await GET();
const body = (await res.json()) as { data: Record<string, unknown> };
expect(body.data).toHaveProperty("users");
expect(body.data).toHaveProperty("integrations");
expect(body.data).toHaveProperty("monitoring");
expect(body.data).toHaveProperty("backups");
expect(body.data).toHaveProperty("maintenance");
expect(body.data).toHaveProperty("auditLog");
});
it("batches DB calls in a single Promise.allSettled (no N+1)", async () => {
await GET();
// 3 user.count + 1 withings + 1 ntfy notifications + 1 webPush + 3
// moodLog/telegram done via user.count → expect user.count called 4 times
// (total, admins, newThisWeek, moodLog, telegram)
expect(mocks.user.count).toHaveBeenCalledTimes(5);
expect(mocks.withingsConnection.count).toHaveBeenCalledTimes(1);
expect(mocks.notificationChannel.count).toHaveBeenCalledTimes(1);
expect(mocks.pushSubscription.count).toHaveBeenCalledTimes(1);
// The route does not loop — every aggregate is a single query.
expect(mocks.dataBackup.findMany).toHaveBeenCalledTimes(1);
});
it("P20: a single failed probe still returns the rest of the grid", async () => {
// Simulate a transient DB failure on the withings count probe only.
// Without Promise.allSettled the whole route would 500 and the entire
// admin overview grid would go blank — with allSettled the failure
// is isolated to its card (severity → "alert", value → 0) and the
// other 5 cards still render.
mocks.withingsConnection.count.mockRejectedValueOnce(
new Error("simulated db timeout"),
);
const res = await GET();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
users: { severity: string };
integrations: { severity: string; withings: number };
backups: { severity: string };
};
};
expect(body.data.integrations.severity).toBe("alert");
expect(body.data.integrations.withings).toBe(0);
// Untouched cards keep their normal severity.
expect(body.data.users.severity).toBe("good");
});
it("flags backups as alert when none exist", async () => {
mocks.dataBackup.findFirst.mockResolvedValue(null);
const res = await GET();
const body = (await res.json()) as {
data: { backups: { severity: string } };
};
expect(body.data.backups.severity).toBe("alert");
});
it("flags backups as good when fresh (<8d)", async () => {
mocks.dataBackup.findFirst.mockResolvedValue({
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000),
});
const res = await GET();
const body = (await res.json()) as {
data: { backups: { severity: string } };
};
expect(body.data.backups.severity).toBe("good");
});
it("flags monitoring as alert on a recent error (<24h)", async () => {
mocks.auditLog.findFirst.mockImplementation(
({ where }: { where: { action: unknown } }) => {
const action = where.action as { startsWith?: string } | string;
if (
typeof action === "object" &&
action.startsWith === "system.error"
) {
return Promise.resolve({ createdAt: new Date(Date.now() - 60_000) });
}
return Promise.resolve(null);
},
);
mocks.appSettings.findUnique.mockResolvedValue({
glitchtipEnabled: true,
umamiEnabled: true,
});
const res = await GET();
const body = (await res.json()) as {
data: { monitoring: { severity: string } };
};
expect(body.data.monitoring.severity).toBe("alert");
});
it("flags maintenance as alert when worker is stopped", async () => {
const wsModule = await import("@/lib/jobs/worker-status");
vi.mocked(wsModule.getWorkerStatus).mockReturnValue({
running: false,
startedAt: null,
lastHeartbeat: null,
lastReminderCheck: null,
lastWithingsSync: null,
lastInsightsRun: null,
jobsProcessed: 0,
errors: 0,
});
const res = await GET();
const body = (await res.json()) as {
data: { maintenance: { severity: string; workerRunning: boolean } };
};
expect(body.data.maintenance.severity).toBe("alert");
expect(body.data.maintenance.workerRunning).toBe(false);
});
});
-318
View File
@@ -1,318 +0,0 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { getWorkerStatus } from "@/lib/jobs/worker-status";
/**
* Severity tokens map onto §2.6 status taxonomy of `docs/ui-guidelines.md`.
* Returned as plain strings so the client renders dots + labels — not RAW
* colours — and never leans on color alone for state.
*/
export type StatusSeverity = "good" | "info" | "caution" | "alert" | "pending";
export interface StatusOverview {
users: {
severity: StatusSeverity;
total: number;
admins: number;
newThisWeek: number;
};
integrations: {
severity: StatusSeverity;
withings: number;
moodLog: number;
telegram: number;
ntfy: number;
webPush: number;
};
monitoring: {
severity: StatusSeverity;
glitchtipEnabled: boolean;
umamiEnabled: boolean;
wideEventsEnabled: boolean;
lastErrorAt: string | null;
};
backups: {
severity: StatusSeverity;
lastBackupAt: string | null;
backedUpUsers: number;
retentionDays: number;
};
maintenance: {
severity: StatusSeverity;
workerRunning: boolean;
workerUptimeSeconds: number | null;
lastIdempotencyCleanup: string | null;
lastAuditLogCleanup: string | null;
};
auditLog: {
severity: StatusSeverity;
eventsLast30d: number;
lastLoginAt: string | null;
};
}
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
function severityForBackup(lastBackupAt: Date | null): StatusSeverity {
if (!lastBackupAt) return "alert";
const ageMs = Date.now() - lastBackupAt.getTime();
if (ageMs > 14 * ONE_DAY_MS) return "alert";
if (ageMs > 8 * ONE_DAY_MS) return "caution";
return "good";
}
function severityForMaintenance(workerRunning: boolean): StatusSeverity {
return workerRunning ? "good" : "alert";
}
function severityForMonitoring(
glitchtipEnabled: boolean,
umamiEnabled: boolean,
recentErrorAt: Date | null,
): StatusSeverity {
if (recentErrorAt && Date.now() - recentErrorAt.getTime() < ONE_DAY_MS) {
return "alert";
}
if (glitchtipEnabled && umamiEnabled) return "good";
if (glitchtipEnabled || umamiEnabled) return "caution";
return "info";
}
function severityForIntegrations(totalConfigured: number): StatusSeverity {
if (totalConfigured === 0) return "info";
return "good";
}
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.status-overview" } });
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * ONE_DAY_MS);
const thirtyDaysAgo = new Date(now.getTime() - 30 * ONE_DAY_MS);
// P20: switch from Promise.all to Promise.allSettled so a single
// failed probe (e.g. a missing index, a transient timeout, an
// unavailable model) doesn't blank the whole admin overview grid.
// Each probe is labelled so we can map a rejection back to the
// overview field it was meant to populate, then surface the failed
// labels in a Wide Event annotation for ops triage.
const probes = [
["userTotal", () => prisma.user.count()],
["userAdmins", () => prisma.user.count({ where: { role: "ADMIN" } })],
[
"userNewThisWeek",
() => prisma.user.count({ where: { createdAt: { gte: sevenDaysAgo } } }),
],
["withingsCount", () => prisma.withingsConnection.count()],
[
"moodLogCount",
() => prisma.user.count({ where: { moodLogEnabled: true } }),
],
[
"telegramCount",
() => prisma.user.count({ where: { telegramEnabled: true } }),
],
[
"ntfyCount",
() =>
prisma.notificationChannel.count({
where: { type: "NTFY", enabled: true },
}),
],
["webPushCount", () => prisma.pushSubscription.count()],
[
"appSettings",
() => prisma.appSettings.findUnique({ where: { id: "singleton" } }),
],
[
"lastErrorEntry",
() =>
prisma.auditLog.findFirst({
where: { action: { startsWith: "system.error" } },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
],
[
"latestBackup",
() =>
prisma.dataBackup.findFirst({
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
],
[
"backedUpUsers",
() =>
prisma.dataBackup
.findMany({ select: { userId: true }, distinct: ["userId"] })
.then((rows) => rows.length),
],
[
"last30dEvents",
() =>
prisma.auditLog.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
],
[
"lastLoginEntry",
() =>
prisma.auditLog.findFirst({
where: { action: "auth.login" },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
],
[
"lastIdempotencyCleanup",
() =>
prisma.auditLog.findFirst({
where: { action: "system.cleanup.idempotency" },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
],
[
"lastAuditLogCleanup",
() =>
prisma.auditLog.findFirst({
where: { action: "system.cleanup.audit-log" },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
],
] as const;
const settled = await Promise.allSettled(probes.map(([, run]) => run()));
const failed: string[] = [];
const results: Record<string, unknown> = {};
settled.forEach((r, i) => {
const label = probes[i][0];
if (r.status === "fulfilled") {
results[label] = r.value;
} else {
failed.push(label);
results[label] = null;
}
});
// Pull each probe out in the same shape the overview expects. `?? 0`
// turns a null (failed-count probe) into a renderable zero so the
// grid tiles still draw something instead of going blank.
const userTotal = (results.userTotal as number | null) ?? 0;
const userAdmins = (results.userAdmins as number | null) ?? 0;
const userNewThisWeek = (results.userNewThisWeek as number | null) ?? 0;
const withingsCount = (results.withingsCount as number | null) ?? 0;
const moodLogCount = (results.moodLogCount as number | null) ?? 0;
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 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;
if (failed.length > 0) {
annotate({ meta: { statusOverviewProbeFailures: failed } });
}
const worker = getWorkerStatus();
const workerUptimeSeconds = worker.startedAt
? Math.max(
0,
Math.floor(
(now.getTime() - new Date(worker.startedAt).getTime()) / 1000,
),
)
: null;
const integrationsTotal =
withingsCount + moodLogCount + telegramCount + ntfyCount + webPushCount;
// Map probe-name → which overview card depends on it. If any probe
// for a card failed, force the card's severity to "alert" so the UI
// surfaces the failure instead of showing zeros as if they were real.
const failedSet = new Set(failed);
const anyFailed = (...labels: string[]) =>
labels.some((l) => failedSet.has(l));
const overview: StatusOverview = {
users: {
severity: anyFailed("userTotal", "userAdmins", "userNewThisWeek")
? "alert"
: "good",
total: userTotal,
admins: userAdmins,
newThisWeek: userNewThisWeek,
},
integrations: {
severity: anyFailed(
"withingsCount",
"moodLogCount",
"telegramCount",
"ntfyCount",
"webPushCount",
)
? "alert"
: severityForIntegrations(integrationsTotal),
withings: withingsCount,
moodLog: moodLogCount,
telegram: telegramCount,
ntfy: ntfyCount,
webPush: webPushCount,
},
monitoring: {
severity: anyFailed("appSettings", "lastErrorEntry")
? "alert"
: severityForMonitoring(
Boolean(appSettings?.glitchtipEnabled),
Boolean(appSettings?.umamiEnabled),
lastErrorEntry?.createdAt ?? null,
),
glitchtipEnabled: Boolean(appSettings?.glitchtipEnabled),
umamiEnabled: Boolean(appSettings?.umamiEnabled),
wideEventsEnabled: true,
lastErrorAt: lastErrorEntry?.createdAt?.toISOString() ?? null,
},
backups: {
severity: anyFailed("latestBackup", "backedUpUsers")
? "alert"
: severityForBackup(latestBackup?.createdAt ?? null),
lastBackupAt: latestBackup?.createdAt?.toISOString() ?? null,
backedUpUsers,
retentionDays: 90,
},
maintenance: {
severity: anyFailed("lastIdempotencyCleanup", "lastAuditLogCleanup")
? "alert"
: severityForMaintenance(worker.running),
workerRunning: worker.running,
workerUptimeSeconds,
lastIdempotencyCleanup:
lastIdempotencyCleanup?.createdAt?.toISOString() ?? null,
lastAuditLogCleanup:
lastAuditLogCleanup?.createdAt?.toISOString() ?? null,
},
auditLog: {
severity: anyFailed("last30dEvents", "lastLoginEntry") ? "alert" : "info",
eventsLast30d: last30dEvents,
lastLoginAt: lastLoginEntry?.createdAt?.toISOString() ?? null,
},
};
return apiSuccess(overview);
});
@@ -544,6 +544,10 @@ describe("GET /api/dashboard/summary", () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{
id: "med-ramipril",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
schedules: [
{
id: "sched-morgens",
@@ -551,6 +555,10 @@ describe("GET /api/dashboard/summary", () => {
windowStart: "07:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: [],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
},
],
},
@@ -632,6 +640,10 @@ describe("GET /api/dashboard/summary", () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{
id: "med-ramipril",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
schedules: [
{
id: "sched-morgens",
@@ -639,6 +651,10 @@ describe("GET /api/dashboard/summary", () => {
windowStart: "07:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: [],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
},
],
},
@@ -95,7 +95,7 @@ describe("PUT /api/measurements/[id] — 422 multi-issue (v1.4.43 W6)", () => {
putReq({
value: "string",
measuredAt: "not-iso",
notes: "a".repeat(200),
notes: "a".repeat(201),
}),
ROUTE_CTX,
);
+2
View File
@@ -91,6 +91,7 @@ export const PUT = apiHandler(
category,
treatmentClass,
dosesPerUnit,
deliveryForm,
active,
notificationsEnabled,
schedules,
@@ -180,6 +181,7 @@ export const PUT = apiHandler(
...(dose !== undefined && { dose }),
...(treatmentClass !== undefined && { treatmentClass }),
...(dosesPerUnit !== undefined && { dosesPerUnit }),
...(deliveryForm !== undefined && { deliveryForm }),
...(active !== undefined && { active }),
...(notificationsEnabled !== undefined && { notificationsEnabled }),
// v1.5 scheduling primitives — pass-through when supplied.
@@ -162,6 +162,10 @@ describe("GET /api/medications/intake", () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{
id: "med-ramipril",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
schedules: [
{
id: "sched-morgens",
@@ -169,6 +173,10 @@ describe("GET /api/medications/intake", () => {
windowStart: "07:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: [],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
},
],
},
@@ -237,6 +245,10 @@ describe("GET /api/medications/intake", () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{
id: "med-ramipril",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
schedules: [
{
id: "sched-morgens",
@@ -244,6 +256,10 @@ describe("GET /api/medications/intake", () => {
windowStart: "07:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: [],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
},
],
},
+3
View File
@@ -121,6 +121,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
category,
treatmentClass,
dosesPerUnit,
deliveryForm,
notificationsEnabled,
schedules,
startsOn,
@@ -181,6 +182,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
// wire schema; Prisma fills the default GENERIC when omitted.
...(treatmentClass !== undefined && { treatmentClass }),
...(dosesPerUnit !== undefined && { dosesPerUnit }),
// v1.6.0 — route of administration; Prisma defaults to ORAL when omitted.
...(deliveryForm !== undefined && { deliveryForm }),
// v1.5 — wizard's reminders toggle now ships through the create
// payload (was orphaned in the initial diff). Prisma defaults to
// true when omitted, matching the legacy form's behaviour.