mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
chore(release): v1.5.6
Pure-history medication detail page, legacy step consolidation, and the finish of the outbound-fetch migration. - /medications/[id] becomes a read-only history surface; every setting moves into a new advanced-settings sheet, and the edit trigger becomes a two-option picker. The list page is unchanged. - A pg-boss job folds pre-v1.5.0 granular step rows into one canonical daily total per user per day and soft-deletes the originals; migration 0087 adds a partial index for the converging discovery scan. - Every remaining server-side fetch routes through safeFetch (Withings, Codex OAuth + SSE, bug-report, Umami, GlitchTip, Loki); a new healthlog/safe-fetch-required ESLint rule guards the boundary. - Fixes: intake-edit dialog seeded from real values, consolidation skips a unique-constraint day instead of stranding it, avatar upload reads the body once under a bounded cap, egress lint rejects absolute URLs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d54addd61a
commit
8528c3b4fe
54 files changed
+2343
-324
No files matched your search
@@ -89,15 +89,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
let lastDetails = "";
|
||||
|
||||
for (const targetUrl of targetUrls) {
|
||||
const upstream = await safeFetch(targetUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"user-agent": "healthlog-admin-monitoring-test",
|
||||
const upstream = await safeFetch(
|
||||
targetUrl,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"user-agent": "healthlog-admin-monitoring-test",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
// Operator-configured Umami host — pin the connect-time IP.
|
||||
{ requirePublicHost: true },
|
||||
);
|
||||
|
||||
if (upstream.ok) {
|
||||
return apiSuccess({
|
||||
|
||||
@@ -159,7 +159,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// GitHub doesn't support base64 in comments, so we note the limitation
|
||||
const commentBody = `### Screenshot\n\n}...)\n\n*Note: The full screenshot was truncated due to size limits. Please contact the user directly.*`;
|
||||
|
||||
await fetch(
|
||||
await safeFetch(
|
||||
`https://api.github.com/repos/${ghRepo}/issues/${issue.number}/comments`,
|
||||
{
|
||||
method: "POST",
|
||||
|
||||
@@ -26,9 +26,16 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
}
|
||||
|
||||
const response = await safeFetch(settings.umamiScriptUrl, {
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
const response = await safeFetch(
|
||||
settings.umamiScriptUrl,
|
||||
{
|
||||
next: { revalidate: 3600 },
|
||||
},
|
||||
// Operator-configured host — pin the connect-time IP so a low-TTL
|
||||
// DNS record cannot rebind the fetch at an internal range between
|
||||
// the input-time `isPublicUrl` accept and the socket connect.
|
||||
{ requirePublicHost: true },
|
||||
);
|
||||
if (!response.ok) {
|
||||
return new NextResponse(NOOP_SCRIPT, {
|
||||
status: 200,
|
||||
|
||||
@@ -74,16 +74,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
let lastResponseStatus = 404;
|
||||
|
||||
for (const targetUrl of targetUrls) {
|
||||
const upstream = await safeFetch(targetUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type":
|
||||
request.headers.get("content-type") || "application/json",
|
||||
"user-agent": request.headers.get("user-agent") || "healthlog-proxy",
|
||||
const upstream = await safeFetch(
|
||||
targetUrl,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type":
|
||||
request.headers.get("content-type") || "application/json",
|
||||
"user-agent": request.headers.get("user-agent") || "healthlog-proxy",
|
||||
},
|
||||
body,
|
||||
cache: "no-store",
|
||||
},
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
// Operator-configured Umami host — pin the connect-time IP.
|
||||
{ requirePublicHost: true },
|
||||
);
|
||||
|
||||
lastResponseStatus = upstream.status;
|
||||
|
||||
|
||||
@@ -146,6 +146,41 @@ describe("POST /api/user/avatar", () => {
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an oversize chunked upload with no Content-Length at the stream level (413)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
// Build a multipart body as a streamed ReadableStream with NO
|
||||
// Content-Length header — the shape a `Transfer-Encoding: chunked`
|
||||
// upload takes. The bounded stream reader must trip before the body
|
||||
// is fully buffered.
|
||||
const boundary = "----healthlogtest";
|
||||
const head = Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="a.png"\r\nContent-Type: image/png\r\n\r\n`,
|
||||
);
|
||||
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
|
||||
const oversize = Buffer.alloc(2 * 1024 * 1024 + 1, 0x41);
|
||||
const parts = [head, oversize, tail];
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const p of parts) {
|
||||
controller.enqueue(new Uint8Array(p));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const req = new Request("http://localhost/api/user/avatar", {
|
||||
method: "POST",
|
||||
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
|
||||
body: stream,
|
||||
// Node's fetch requires `duplex: "half"` for a streamed body.
|
||||
duplex: "half",
|
||||
} as RequestInit & { duplex: "half" });
|
||||
|
||||
const res = await post(req);
|
||||
expect(res.status).toBe(413);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an unsupported MIME with 415 (magic-byte sniff)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Plain text content — no JPEG/PNG/WebP magic.
|
||||
|
||||
@@ -62,6 +62,67 @@ interface UploadResponse {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Thrown by `readBoundedBody` when the stream exceeds the byte cap. */
|
||||
class BodyTooLargeError extends Error {
|
||||
constructor() {
|
||||
super("Request body exceeds the configured byte cap");
|
||||
this.name = "BodyTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a request body stream into a single buffer while counting bytes,
|
||||
* throwing `BodyTooLargeError` the moment the running total passes
|
||||
* `maxBytes`. Bounds the work at the stream level so a chunked /
|
||||
* unbounded upload cannot park past the cap even when no `Content-Length`
|
||||
* header is present. The captured bytes are retained (capped at
|
||||
* `maxBytes`) so the caller can reconstruct `FormData` from them — this
|
||||
* reads the body exactly once, with no clone and no second parser racing
|
||||
* the same body, so a cap trip frees the allocation immediately instead
|
||||
* of leaving a native parse buffering the rest of an oversized upload.
|
||||
*/
|
||||
async function readBoundedBody(
|
||||
body: ReadableStream<Uint8Array> | null,
|
||||
maxBytes: number,
|
||||
): Promise<Uint8Array<ArrayBuffer>> {
|
||||
if (!body) return new Uint8Array(0);
|
||||
const reader = body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let retained = 0;
|
||||
let overflow = false;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (overflow) continue;
|
||||
if (retained + value.byteLength > maxBytes) {
|
||||
// Past the cap. Drop everything retained so far and stop
|
||||
// retaining further chunks — memory stays bounded at the cap.
|
||||
// Keep draining the stream to its natural close rather than
|
||||
// cancelling it: an abrupt cancel mid-transfer races the
|
||||
// underlying (undici) producer, which then enqueues into a
|
||||
// closed controller and surfaces as an unhandled rejection.
|
||||
chunks.length = 0;
|
||||
retained = 0;
|
||||
overflow = true;
|
||||
continue;
|
||||
}
|
||||
chunks.push(value);
|
||||
retained += value.byteLength;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
if (overflow) throw new BodyTooLargeError();
|
||||
const out = new Uint8Array(retained);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
out.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
@@ -94,10 +155,31 @@ export const POST = apiHandler(async (request: Request) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Stream-level cap. The Content-Length pre-flight above only fires when
|
||||
// the header is present; a `Transfer-Encoding: chunked` upload (or any
|
||||
// client that omits the header) skips it, and `request.formData()`
|
||||
// would then buffer the whole stream before the post-parse `file.size`
|
||||
// check can run. Read the body ONCE through a bounded reader that
|
||||
// aborts on the first byte past AVATAR_MAX_BYTES, then reconstruct the
|
||||
// FormData from the captured bytes. A single read means the cap trip
|
||||
// frees the allocation immediately — no clone, no tee, and no native
|
||||
// parse left buffering the rest of an oversized body. The post-parse
|
||||
// `file.size` check stays in place as defence-in-depth.
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await request.formData();
|
||||
const bytes = await readBoundedBody(request.body, AVATAR_MAX_BYTES);
|
||||
formData = await new Response(new Blob([bytes]), {
|
||||
headers: { "content-type": request.headers.get("content-type") ?? "" },
|
||||
}).formData();
|
||||
} catch (err) {
|
||||
// Surface the most useful failure: a cap overflow always wins.
|
||||
if (err instanceof BodyTooLargeError) {
|
||||
annotate({
|
||||
action: { name: "user.avatar.upload.rejected" },
|
||||
meta: { reason: "stream_size_exceeded" },
|
||||
});
|
||||
return apiError(`Upload exceeds ${AVATAR_MAX_BYTES} byte limit`, 413);
|
||||
}
|
||||
return apiError(
|
||||
`Invalid multipart body: ${err instanceof Error ? err.message : "unknown"}`,
|
||||
400,
|
||||
|
||||
@@ -1,42 +1,31 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.5.5 D-3 §6 + §9 — medication detail page.
|
||||
* v1.5.6 G-1 — medication detail page as a pure history surface.
|
||||
*
|
||||
* Replaces the v1.5.4 "form retirement" gap that left every action
|
||||
* (Pausieren / Beenden / Notifications / API tokens / Phasen / CSV
|
||||
* import / Verlauf löschen / Medikament löschen) reachable only
|
||||
* through the list-page kebab — which the kebab didn't actually wire
|
||||
* up. The detail page surfaces every restored action in section
|
||||
* order; one phone-scroll reaches every one of them.
|
||||
* Supersedes the v1.5.5 composition that stacked every setting inline.
|
||||
* The page now reads as a calm "Vergangenheit" view: back link → slim
|
||||
* header (name / dose / status + a two-option Bearbeiten dropdown) →
|
||||
* STATIC cadence line → GLP-1 dose-ladder read-only visualisation →
|
||||
* intake-history table. Every setting — Notifications, API tokens,
|
||||
* Phasen, CSV import, grace and the destructive zone — moves into the
|
||||
* `<AdvancedSettingsSheet>`, reached from the dropdown's "Erweiterte
|
||||
* Einstellungen" item. "Plan bearbeiten" opens the wizard at Step 1.
|
||||
*
|
||||
* Recurring variant: 8 sections (header → today's dose → cadence →
|
||||
* dose ladder / phases → intake history preview → notifications →
|
||||
* settings → destructive zone).
|
||||
*
|
||||
* One-shot variant: 5 sections (header → today's-dose-or-logged →
|
||||
* cadence static line → intake history single row → destructive
|
||||
* zone). Per D-3 §6 the wizard handles cadence edit via the header
|
||||
* pencil so the cadence row hides its own edit affordance.
|
||||
*
|
||||
* Paused variant: same as the recurring/one-shot layout; the header
|
||||
* status pill flips and the today's-dose card disables both buttons
|
||||
* with a muted helper. Settings and destructive zone keep rendering.
|
||||
* One-shot variant drops the dose-ladder section and renders the
|
||||
* static `Einmalig am …` line in place of the cadence summary.
|
||||
*
|
||||
* Reads:
|
||||
*
|
||||
* - `medicationDetail(id)` for the medication snapshot.
|
||||
* - `medicationIntakeList(id, …)` for the last-14-grouped preview
|
||||
* (the v2 list owns its own paging).
|
||||
* - `notificationsStatus()` + `authMe()` on the notifications
|
||||
* section.
|
||||
* - `medicationTitration(id)` on the GLP-1 dose ladder.
|
||||
* - `medicationIntakeList(id, …)` for `intakeCount`, which the
|
||||
* sheet's destructive zone needs.
|
||||
*
|
||||
* Mutations all cascade through `medicationDependentKeys` (which now
|
||||
* Mutations all cascade through `medicationDependentKeys` (which
|
||||
* includes the `compliance-chart-inline` prefix per D-3 §10).
|
||||
*/
|
||||
|
||||
import { use, useEffect, useState } from "react";
|
||||
import { use, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -49,16 +38,13 @@ import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
|
||||
import { TitrationSection } from "@/components/medications/TitrationSection";
|
||||
import { MedicationDetailHeader } from "@/components/medications/medication-detail-header";
|
||||
import { TodaysDoseCard } from "@/components/medications/todays-dose-card";
|
||||
import { CadenceSummaryRow } from "@/components/medications/cadence-summary-row";
|
||||
import { IntakeHistoryPreview } from "@/components/medications/sections/intake-history-preview";
|
||||
import { NotificationsSection } from "@/components/medications/sections/notifications-section";
|
||||
import { SettingsSection } from "@/components/medications/sections/settings-section";
|
||||
import { DestructiveZoneSection } from "@/components/medications/sections/destructive-zone-section";
|
||||
import { AdvancedSettingsSheet } from "@/components/medications/advanced-settings-sheet";
|
||||
import { PhaseConfigSheet } from "@/components/medications/sections/phase-config-sheet";
|
||||
import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
|
||||
import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
|
||||
|
||||
@@ -135,16 +121,6 @@ function snapshotToWizardPayload(
|
||||
};
|
||||
}
|
||||
|
||||
function isToday(iso: string): boolean {
|
||||
const d = new Date(iso);
|
||||
const today = new Date();
|
||||
return (
|
||||
d.getFullYear() === today.getFullYear() &&
|
||||
d.getMonth() === today.getMonth() &&
|
||||
d.getDate() === today.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
export default function MedicationDetailPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -157,9 +133,8 @@ export default function MedicationDetailPage({
|
||||
const formatters = useFormatters();
|
||||
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [wizardIntent, setWizardIntent] = useState<
|
||||
"cadence" | "name" | undefined
|
||||
>(undefined);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [phaseSheetOpen, setPhaseSheetOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -201,6 +176,14 @@ export default function MedicationDetailPage({
|
||||
enabled: isAuthenticated && !!medication,
|
||||
});
|
||||
|
||||
// v1.5.6 F-1 M-2 — memoise the wizard payload so the header dropdown
|
||||
// + wizard mount share one object identity per medication snapshot
|
||||
// instead of recomputing it on every render.
|
||||
const wizardPayload = useMemo<MedicationPayload | null>(
|
||||
() => (medication ? snapshotToWizardPayload(medication) : null),
|
||||
[medication],
|
||||
);
|
||||
|
||||
if (authLoading || isLoading) {
|
||||
return (
|
||||
<div
|
||||
@@ -246,23 +229,18 @@ export default function MedicationDetailPage({
|
||||
}
|
||||
|
||||
const oneShot = medication.oneShot === true;
|
||||
const todayEvent = intakeList?.events.find(
|
||||
(e) =>
|
||||
(e.takenAt && isToday(e.takenAt)) ||
|
||||
(e.skipped && isToday(e.scheduledFor)),
|
||||
);
|
||||
const intakeCount = intakeList?.meta.total ?? 0;
|
||||
const primaryGrace =
|
||||
medication.schedules[0]?.reminderGraceMinutes ?? null;
|
||||
// `wizardPayload` is non-null here — the early returns above bail
|
||||
// before this point whenever `medication` is undefined.
|
||||
const payload = wizardPayload as MedicationPayload;
|
||||
|
||||
function openWizardWithIntent(intent: "cadence" | "name") {
|
||||
setWizardIntent(intent);
|
||||
setWizardOpen(true);
|
||||
}
|
||||
|
||||
function closeWizard() {
|
||||
setWizardOpen(false);
|
||||
setWizardIntent(undefined);
|
||||
// v1.5.6 G-1 §5 — sibling-swap: close the advanced sheet first, then
|
||||
// open the phase sheet so the two never stack (modal depth ≤ 2).
|
||||
function openPhaseSheet() {
|
||||
setAdvancedOpen(false);
|
||||
setPhaseSheetOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -279,30 +257,21 @@ export default function MedicationDetailPage({
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{/* §9.1 — Header band */}
|
||||
{/* G-1 §3.2 — Header band. The Bearbeiten dropdown owns the
|
||||
wizard ("Plan bearbeiten") + the advanced sheet ("Erweiterte
|
||||
Einstellungen"); the page owns both open-states. */}
|
||||
<MedicationDetailHeader
|
||||
name={medication.name}
|
||||
dose={medication.dose}
|
||||
active={medication.active}
|
||||
endsOn={medication.endsOn}
|
||||
onEdit={() => openWizardWithIntent("name")}
|
||||
onEditPlan={() => setWizardOpen(true)}
|
||||
onOpenAdvanced={() => setAdvancedOpen(true)}
|
||||
/>
|
||||
|
||||
{/* §9.2 — Today's dose card */}
|
||||
<TodaysDoseCard
|
||||
medicationId={id}
|
||||
active={medication.active}
|
||||
oneShot={oneShot}
|
||||
scheduledForToday={!oneShot || !todayEvent}
|
||||
alreadyTakenAt={
|
||||
todayEvent && !todayEvent.skipped ? todayEvent.takenAt : null
|
||||
}
|
||||
alreadySkipped={Boolean(todayEvent?.skipped)}
|
||||
/>
|
||||
|
||||
{/* §9.3 — Cadence summary. One-shot: static line; the
|
||||
`hideEdit` flag suppresses the row's own edit affordance
|
||||
because the header pencil routes the wizard. */}
|
||||
{/* G-1 §3.3 — Static cadence line. One-shot collapses to the
|
||||
`Einmalig am …` card; recurring renders the summary row with
|
||||
`hideEdit` (cadence editing lives in the wizard). */}
|
||||
{oneShot ? (
|
||||
<Card
|
||||
className="p-5 sm:p-6"
|
||||
@@ -318,13 +287,14 @@ export default function MedicationDetailPage({
|
||||
</Card>
|
||||
) : (
|
||||
<CadenceSummaryRow
|
||||
medication={snapshotToWizardPayload(medication)}
|
||||
onEdit={() => openWizardWithIntent("cadence")}
|
||||
medication={payload}
|
||||
hideEdit
|
||||
onEdit={() => {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* §9.4 — Dose ladder / Phasen (GLP-1 only, recurring path
|
||||
only — one-shot variant drops this) */}
|
||||
{/* G-1 §3.4 — Dose ladder / Phasen visualisation (read-only,
|
||||
GLP-1 recurring only). The editor lives in the sheet. */}
|
||||
{!oneShot && medication.treatmentClass === "GLP1" && (
|
||||
<>
|
||||
<DrugLevelChart
|
||||
@@ -338,54 +308,52 @@ export default function MedicationDetailPage({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* §9.5 — Intake history preview */}
|
||||
{/* G-1 §3.5 — Intake history table (primary surface). */}
|
||||
<IntakeHistoryPreview
|
||||
medicationId={id}
|
||||
importOpen={importOpen}
|
||||
onImportOpenChange={setImportOpen}
|
||||
/>
|
||||
|
||||
{/* §9.6 + §9.7 — Notifications + Settings: recurring path only. */}
|
||||
{!oneShot && (
|
||||
<>
|
||||
<NotificationsSection
|
||||
medicationId={id}
|
||||
notificationsEnabled={medication.notificationsEnabled}
|
||||
/>
|
||||
<Separator className="opacity-0" />
|
||||
<SettingsSection
|
||||
medicationId={id}
|
||||
medicationName={medication.name}
|
||||
treatmentClass={medication.treatmentClass}
|
||||
startsOn={medication.startsOn}
|
||||
endsOn={medication.endsOn}
|
||||
reminderGraceMinutes={primaryGrace}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* §9.8 — Verwaltung & Gefahrenzone */}
|
||||
<DestructiveZoneSection
|
||||
{/* G-1 §5 — Advanced settings sheet. Hosts Notifications →
|
||||
Settings → destructive zone. The Phasen button sibling-swaps
|
||||
to the phase sheet below. */}
|
||||
<AdvancedSettingsSheet
|
||||
open={advancedOpen}
|
||||
onOpenChange={setAdvancedOpen}
|
||||
medicationId={id}
|
||||
medicationName={medication.name}
|
||||
treatmentClass={medication.treatmentClass}
|
||||
active={medication.active}
|
||||
startsOn={medication.startsOn}
|
||||
endsOn={medication.endsOn}
|
||||
notificationsEnabled={medication.notificationsEnabled}
|
||||
reminderGraceMinutes={primaryGrace}
|
||||
intakeCount={intakeCount}
|
||||
onRequestPhaseSheet={openPhaseSheet}
|
||||
/>
|
||||
|
||||
{/* Wizard mount — edit-only on the detail page. Intent flips
|
||||
between "name" (header pencil → Step 1) and "cadence"
|
||||
(cadence row pencil → Step 5). Cancel returns to the detail
|
||||
page (the success branch refreshes the medication detail
|
||||
via the `medicationDependentKeys` cascade). */}
|
||||
{/* G-1 §5 — phase sheet mounted as a sibling of the advanced
|
||||
sheet so they never stack (GLP-1 + course window only). */}
|
||||
{!oneShot &&
|
||||
medication.treatmentClass === "GLP1" &&
|
||||
Boolean(medication.startsOn) &&
|
||||
Boolean(medication.endsOn) && (
|
||||
<PhaseConfigSheet
|
||||
medicationId={id}
|
||||
open={phaseSheetOpen}
|
||||
onOpenChange={setPhaseSheetOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Wizard mount — edit-only, lands on Step 1 (no landing
|
||||
intent). Cancel + success both close via the cascade. */}
|
||||
<MedicationWizardDialog
|
||||
open={wizardOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeWizard();
|
||||
}}
|
||||
onOpenChange={setWizardOpen}
|
||||
mode="edit"
|
||||
initial={snapshotToWizardPayload(medication)}
|
||||
landingIntent={wizardIntent}
|
||||
onSuccess={() => closeWizard()}
|
||||
initial={payload}
|
||||
onSuccess={() => setWizardOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* v1.5.6 G-1 §3 — the `/medications/[id]` detail page is a pure
|
||||
* history surface.
|
||||
*
|
||||
* Source-level guards (the page is a client component with `use()`
|
||||
* + hooks, so a full render is heavier than the contract warrants):
|
||||
* - no `TodaysDoseCard` import or render (removed from the detail
|
||||
* page; it lives only on the list page);
|
||||
* - no `isToday` / `todayEvent` derivations (dead once the card is
|
||||
* gone);
|
||||
* - no `landingIntent` / `wizardIntent` / `openWizardWithIntent`
|
||||
* plumbing (the header dropdown is the only wizard entry, landing
|
||||
* on Step 1);
|
||||
* - the page hosts the `AdvancedSettingsSheet` and renders the
|
||||
* cadence row with `hideEdit`.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "src/app/medications/[id]/page.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("medication detail page is a pure history surface (G-1 §3)", () => {
|
||||
it("does not import or render TodaysDoseCard", () => {
|
||||
expect(source).not.toContain("TodaysDoseCard");
|
||||
expect(source).not.toContain("todays-dose-card");
|
||||
});
|
||||
|
||||
it("drops the dead isToday / todayEvent derivations", () => {
|
||||
expect(source).not.toContain("isToday");
|
||||
expect(source).not.toContain("todayEvent");
|
||||
});
|
||||
|
||||
it("drops the landingIntent plumbing", () => {
|
||||
expect(source).not.toContain("landingIntent");
|
||||
expect(source).not.toContain("wizardIntent");
|
||||
expect(source).not.toContain("openWizardWithIntent");
|
||||
});
|
||||
|
||||
it("hosts the AdvancedSettingsSheet and the static cadence row", () => {
|
||||
expect(source).toContain("AdvancedSettingsSheet");
|
||||
expect(source).toContain("hideEdit");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* v1.5.6 G-1 §5 — AdvancedSettingsSheet composition contract.
|
||||
*
|
||||
* The sheet hosts the three moved sections in routine → rare →
|
||||
* destructive order: Notifications → Settings → destructive zone.
|
||||
* `<ResponsiveSheet>` wraps Radix portals that `renderToStaticMarkup`
|
||||
* does not materialise, so we mock it to a passthrough that renders
|
||||
* children only when `open`, and mock the three sections to ordered
|
||||
* markers. The test pins:
|
||||
* - the three sections render in the documented order;
|
||||
* - `onRequestPhaseSheet` threads through to `<SettingsSection>`;
|
||||
* - the destructive zone's `onAfterAction` closes the sheet;
|
||||
* - the sheet renders nothing when `open` is false.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/responsive-sheet", () => ({
|
||||
ResponsiveSheet: ({
|
||||
open,
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
open: boolean;
|
||||
children: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-slot="mock-responsive-sheet">
|
||||
<div data-slot="mock-title">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/components/medications/sections/notifications-section",
|
||||
() => ({
|
||||
NotificationsSection: () => <div data-slot="mock-notifications" />,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/components/medications/sections/settings-section", () => ({
|
||||
SettingsSection: ({
|
||||
onRequestPhaseSheet,
|
||||
}: {
|
||||
onRequestPhaseSheet?: () => void;
|
||||
}) => (
|
||||
<div
|
||||
data-slot="mock-settings"
|
||||
data-has-phase-swap={onRequestPhaseSheet ? "yes" : "no"}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/components/medications/sections/destructive-zone-section",
|
||||
() => ({
|
||||
DestructiveZoneSection: ({
|
||||
onAfterAction,
|
||||
}: {
|
||||
onAfterAction?: () => void;
|
||||
}) => (
|
||||
<button
|
||||
data-slot="mock-destructive"
|
||||
data-has-after-action={onAfterAction ? "yes" : "no"}
|
||||
onClick={onAfterAction}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
import { AdvancedSettingsSheet } from "@/components/medications/advanced-settings-sheet";
|
||||
|
||||
function baseProps(open: boolean, onRequestPhaseSheet?: () => void) {
|
||||
return {
|
||||
open,
|
||||
onOpenChange: () => {},
|
||||
medicationId: "med-1",
|
||||
medicationName: "Test Drug",
|
||||
treatmentClass: "GLP1",
|
||||
active: true,
|
||||
startsOn: "2026-01-01",
|
||||
endsOn: "2026-06-01",
|
||||
notificationsEnabled: true,
|
||||
reminderGraceMinutes: 30,
|
||||
intakeCount: 5,
|
||||
onRequestPhaseSheet,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AdvancedSettingsSheet (G-1 §5)", () => {
|
||||
it("renders the three sections in routine → rare → destructive order", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(true, () => {})} />,
|
||||
);
|
||||
const notif = html.indexOf("mock-notifications");
|
||||
const settings = html.indexOf("mock-settings");
|
||||
const destructive = html.indexOf("mock-destructive");
|
||||
expect(notif).toBeGreaterThan(-1);
|
||||
expect(settings).toBeGreaterThan(notif);
|
||||
expect(destructive).toBeGreaterThan(settings);
|
||||
});
|
||||
|
||||
it("threads onRequestPhaseSheet through to the settings section", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(true, () => {})} />,
|
||||
);
|
||||
expect(html).toContain('data-has-phase-swap="yes"');
|
||||
});
|
||||
|
||||
it("passes an onAfterAction to the destructive zone so it can close the sheet", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(true)} />,
|
||||
);
|
||||
expect(html).toContain('data-has-after-action="yes"');
|
||||
});
|
||||
|
||||
it("renders nothing when closed", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(false)} />,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("titles the sheet with the advanced-settings key", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(true)} />,
|
||||
);
|
||||
expect(html).toContain("medications.detail.advanced.title");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* v1.5.6 G-1 §9 Q1 — IntakeEditDialog seeds from the row's real
|
||||
* values.
|
||||
*
|
||||
* Before this pass the preview opened the dialog with a stub
|
||||
* `{ id, takenAt: null, skipped: false }`, so "Bearbeiten" always
|
||||
* showed an empty form. The fix threads the whole `IntakeEvent` up
|
||||
* through `onEditIntake(event)`; this test pins that the dialog's
|
||||
* datetime-local input + skipped switch seed from the event passed in.
|
||||
*
|
||||
* `<Dialog>` wraps a Radix portal that `renderToStaticMarkup` does not
|
||||
* materialise, so we mock the dialog primitives to passthroughs.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="mock-dialog">{children}</div>
|
||||
),
|
||||
DialogContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogFooter: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogHeader: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog";
|
||||
|
||||
describe("IntakeEditDialog seed (G-1 §9 Q1)", () => {
|
||||
it("seeds the datetime-local input from the event's real takenAt", () => {
|
||||
// 2026-03-04T09:30 in local time — the dialog formats to
|
||||
// `YYYY-MM-DDTHH:mm` in the user's timezone.
|
||||
const taken = new Date(2026, 2, 4, 9, 30).toISOString();
|
||||
const html = renderToStaticMarkup(
|
||||
<IntakeEditDialog
|
||||
medicationId="med-1"
|
||||
event={{ id: "evt-1", takenAt: taken, skipped: false }}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('value="2026-03-04T09:30"');
|
||||
});
|
||||
|
||||
it("seeds the skipped switch from a skipped event (no stub default)", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<IntakeEditDialog
|
||||
medicationId="med-1"
|
||||
event={{ id: "evt-2", takenAt: null, skipped: true }}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
// Radix Switch reflects checked state via data-state.
|
||||
expect(html).toContain('data-state="checked"');
|
||||
});
|
||||
|
||||
it("renders nothing when no event is selected", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<IntakeEditDialog medicationId="med-1" event={null} onClose={() => {}} />,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -124,7 +124,7 @@ const mounjaro: Glp1Medication = {
|
||||
};
|
||||
|
||||
describe("medication card symmetry — Ramipril vs Mounjaro", () => {
|
||||
it("both cards render History + Pencil header buttons", () => {
|
||||
it("both cards render the detail-nav + Pencil header buttons", () => {
|
||||
const client = makeClient();
|
||||
seedCompliance(client, ramipril.id);
|
||||
seedCompliance(client, mounjaro.id);
|
||||
@@ -140,7 +140,10 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
|
||||
);
|
||||
|
||||
for (const html of [ramiprilHtml, mounjaroHtml]) {
|
||||
expect(html).toContain("lucide-history");
|
||||
// v1.5.6 F-1 M-1 — the detail-nav icon swapped from the
|
||||
// history glyph to a neutral chevron now that it routes to the
|
||||
// detail page rather than the history sub-route.
|
||||
expect(html).toContain("lucide-chevron-right");
|
||||
expect(html).toContain("lucide-pencil");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -26,6 +26,35 @@ vi.mock("@/lib/i18n/context", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// `<DropdownMenu>` wraps a Radix portal that `renderToStaticMarkup`
|
||||
// does not materialise while closed; mock the primitives to
|
||||
// passthroughs so the trigger + menu items are reachable in static
|
||||
// markup and the `onSelect` wiring is assertable (G-1 §4).
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="mock-dropdown">{children}</div>
|
||||
),
|
||||
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="mock-dropdown-trigger">{children}</div>
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="mock-dropdown-content">{children}</div>
|
||||
),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onSelect?: () => void;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<button onClick={onSelect} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import { MedicationDetailHeader } from "@/components/medications/medication-detail-header";
|
||||
|
||||
function render(props: {
|
||||
@@ -40,7 +69,8 @@ function render(props: {
|
||||
dose={props.dose ?? "5 mg"}
|
||||
active={props.active ?? true}
|
||||
endsOn={props.endsOn}
|
||||
onEdit={() => {}}
|
||||
onEditPlan={() => {}}
|
||||
onOpenAdvanced={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -79,6 +109,22 @@ describe("MedicationDetailHeader (D-3 §9.1)", () => {
|
||||
expect(headingIndex).toBeLessThan(buttonIndex);
|
||||
});
|
||||
|
||||
it("renders the two-option Bearbeiten picker (G-1 §4)", () => {
|
||||
const html = render({ active: true });
|
||||
expect(html).toContain("medications.detail.edit.planOption");
|
||||
expect(html).toContain("medications.detail.edit.advancedOption");
|
||||
expect(html).toContain('data-slot="medication-detail-edit-plan"');
|
||||
expect(html).toContain('data-slot="medication-detail-edit-advanced"');
|
||||
});
|
||||
|
||||
it("orders Plan bearbeiten above Erweiterte Einstellungen", () => {
|
||||
const html = render({ active: true });
|
||||
const plan = html.indexOf("medications.detail.edit.planOption");
|
||||
const advanced = html.indexOf("medications.detail.edit.advancedOption");
|
||||
expect(plan).toBeGreaterThan(-1);
|
||||
expect(advanced).toBeGreaterThan(plan);
|
||||
});
|
||||
|
||||
it("status pill text always renders (no colour-only state)", () => {
|
||||
// Regardless of theme support the label MUST be in the DOM so a
|
||||
// screen reader announces "Aktiv" even when the dot's hue is
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.5.6 G-1 §5 — Advanced settings sheet.
|
||||
*
|
||||
* Hosts the three settings sections the detail page no longer renders
|
||||
* inline: Notifications, Settings (API tokens / CSV stub / Phasen /
|
||||
* grace) and the destructive zone, stacked top → bottom in
|
||||
* routine → rare → destructive order inside a `<ResponsiveSheet>`.
|
||||
*
|
||||
* The Phasen button is sibling-swapped, not nested: when the user
|
||||
* opens it, `onRequestPhaseSheet` closes this sheet first and lets the
|
||||
* parent open the phase sheet so the modal stack never exceeds two
|
||||
* (G-1 §5 / D-3 §10 invariant 7). Each moved section saves itself, so
|
||||
* the footer slot stays empty. On a non-navigating destructive success
|
||||
* the sheet closes; the Tier 3b delete already navigates to
|
||||
* `/medications`.
|
||||
*/
|
||||
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { NotificationsSection } from "@/components/medications/sections/notifications-section";
|
||||
import { SettingsSection } from "@/components/medications/sections/settings-section";
|
||||
import { DestructiveZoneSection } from "@/components/medications/sections/destructive-zone-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export interface AdvancedSettingsSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
medicationId: string;
|
||||
medicationName: string;
|
||||
treatmentClass?: string;
|
||||
active: boolean;
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
notificationsEnabled: boolean;
|
||||
reminderGraceMinutes: number | null;
|
||||
intakeCount: number;
|
||||
/**
|
||||
* Sibling-swap the phase sheet: the parent closes this sheet and
|
||||
* opens `<PhaseConfigSheet>` so the two never stack (G-1 §5).
|
||||
*/
|
||||
onRequestPhaseSheet?: () => void;
|
||||
}
|
||||
|
||||
export function AdvancedSettingsSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
medicationId,
|
||||
medicationName,
|
||||
treatmentClass,
|
||||
active,
|
||||
startsOn,
|
||||
endsOn,
|
||||
notificationsEnabled,
|
||||
reminderGraceMinutes,
|
||||
intakeCount,
|
||||
onRequestPhaseSheet,
|
||||
}: AdvancedSettingsSheetProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("medications.detail.advanced.title")}
|
||||
className="sm:max-w-lg"
|
||||
bodyClassName="gap-6"
|
||||
>
|
||||
<div
|
||||
className="space-y-6"
|
||||
data-slot="advanced-settings-sheet-body"
|
||||
>
|
||||
{/* routine */}
|
||||
<NotificationsSection
|
||||
medicationId={medicationId}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
/>
|
||||
|
||||
{/* rare */}
|
||||
<SettingsSection
|
||||
medicationId={medicationId}
|
||||
medicationName={medicationName}
|
||||
treatmentClass={treatmentClass}
|
||||
startsOn={startsOn}
|
||||
endsOn={endsOn}
|
||||
reminderGraceMinutes={reminderGraceMinutes}
|
||||
onRequestPhaseSheet={onRequestPhaseSheet}
|
||||
/>
|
||||
|
||||
{/* destructive */}
|
||||
<DestructiveZoneSection
|
||||
medicationId={medicationId}
|
||||
medicationName={medicationName}
|
||||
active={active}
|
||||
intakeCount={intakeCount}
|
||||
onAfterAction={() => onOpenChange(false)}
|
||||
/>
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,8 @@
|
||||
* Renders the medication's cadence as a plain-language line (e.g.
|
||||
* "Alle 12 h nach letzter Einnahme", "Alle 2 Wochen, mittwochs",
|
||||
* "Monatlich am 15.") with an optional course-window sub-line, and an
|
||||
* edit pencil that opens the wizard with `landingIntent: "cadence"`.
|
||||
* optional edit pencil whose `onEdit` the caller wires (suppressed via
|
||||
* `hideEdit` on the read-only detail surface).
|
||||
* For one-shot medications the line collapses to
|
||||
* `Einmalig am DD.MM.` and the edit affordance is suppressed (the
|
||||
* header pencil owns the wizard route per D-3 §6 one-shot variant).
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronRight,
|
||||
CircleCheck,
|
||||
Flame,
|
||||
History,
|
||||
Loader2,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
@@ -337,7 +337,7 @@ export function Glp1MedicationCard({
|
||||
aria-label={t("medications.openDetailPage")}
|
||||
>
|
||||
<Link href={`/medications/${medication.id}`}>
|
||||
<History className="h-4 w-4" />
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
|
||||
type IntakeSource = "WEB" | "API" | "REMINDER" | "IMPORT";
|
||||
|
||||
interface IntakeEvent {
|
||||
export interface IntakeEvent {
|
||||
id: string;
|
||||
medicationId: string;
|
||||
scheduledFor: string;
|
||||
@@ -108,9 +108,12 @@ interface IntakeHistoryListV2Props {
|
||||
pageSize?: number;
|
||||
/**
|
||||
* v1.5.5 F-1 C-2 — per-row Bearbeiten callback. When provided the
|
||||
* row renders a kebab `<DropdownMenu>` with an Edit action.
|
||||
* row renders a kebab `<DropdownMenu>` with an Edit action. v1.5.6
|
||||
* G-1 §9 Q1: threads the whole `IntakeEvent` (not just the id) so
|
||||
* the edit dialog seeds from the row's real `takenAt` / `skipped`
|
||||
* instead of an empty stub.
|
||||
*/
|
||||
onEditIntake?: (eventId: string) => void;
|
||||
onEditIntake?: (event: IntakeEvent) => void;
|
||||
/**
|
||||
* v1.5.5 F-1 C-2 — per-row Löschen callback. When provided the row
|
||||
* renders a kebab `<DropdownMenu>` with a Delete action.
|
||||
@@ -380,7 +383,7 @@ export function IntakeHistoryListV2({
|
||||
<DropdownMenuContent align="end">
|
||||
{onEditIntake && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onEditIntake(event.id)}
|
||||
onSelect={() => onEditIntake(event)}
|
||||
data-slot="intake-history-row-edit"
|
||||
>
|
||||
{t("medications.detail.intake.rowActions.edit")}
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
Flame,
|
||||
Pencil,
|
||||
Loader2,
|
||||
History,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
@@ -275,7 +275,7 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
|
||||
aria-label={t("medications.openDetailPage")}
|
||||
>
|
||||
<Link href={`/medications/${medication.id}`}>
|
||||
<History className="h-4 w-4" />
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -18,10 +18,16 @@
|
||||
* destructive zone, not on the header (E-2 M-1).
|
||||
*/
|
||||
|
||||
import { Pencil } from "lucide-react";
|
||||
import { ChevronDown, Pencil } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export interface MedicationDetailHeaderProps {
|
||||
@@ -30,11 +36,15 @@ export interface MedicationDetailHeaderProps {
|
||||
active: boolean;
|
||||
endsOn?: string | null;
|
||||
/**
|
||||
* Fires when the user taps the Bearbeiten pencil. The detail page
|
||||
* routes this to the wizard with `landingIntent: "name"` so the
|
||||
* user lands on Step 1.
|
||||
* v1.5.6 G-1 §4 — "Plan bearbeiten" opens the wizard (mode="edit",
|
||||
* Step 1). Fired from the Bearbeiten dropdown's first item.
|
||||
*/
|
||||
onEdit: () => void;
|
||||
onEditPlan: () => void;
|
||||
/**
|
||||
* v1.5.6 G-1 §4 — "Erweiterte Einstellungen" opens the
|
||||
* `<AdvancedSettingsSheet>`. Fired from the dropdown's second item.
|
||||
*/
|
||||
onOpenAdvanced: () => void;
|
||||
}
|
||||
|
||||
type Status = "active" | "paused" | "ended";
|
||||
@@ -58,7 +68,8 @@ export function MedicationDetailHeader({
|
||||
dose,
|
||||
active,
|
||||
endsOn,
|
||||
onEdit,
|
||||
onEditPlan,
|
||||
onOpenAdvanced,
|
||||
}: MedicationDetailHeaderProps) {
|
||||
const { t } = useTranslations();
|
||||
const status = resolveStatus(active, endsOn);
|
||||
@@ -98,16 +109,34 @@ export function MedicationDetailHeader({
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="medication-detail-edit-button"
|
||||
>
|
||||
<Pencil aria-hidden="true" className="h-4 w-4" />
|
||||
<span>{t("common.edit")}</span>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="medication-detail-edit-button"
|
||||
>
|
||||
<Pencil aria-hidden="true" className="h-4 w-4" />
|
||||
<span>{t("common.edit")}</span>
|
||||
<ChevronDown aria-hidden="true" className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={onEditPlan}
|
||||
data-slot="medication-detail-edit-plan"
|
||||
>
|
||||
{t("medications.detail.edit.planOption")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={onOpenAdvanced}
|
||||
data-slot="medication-detail-edit-advanced"
|
||||
>
|
||||
{t("medications.detail.edit.advancedOption")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* v1.5.6 G-1 §5 — phase sibling-swap wiring on `<SettingsSection>`.
|
||||
*
|
||||
* When the hosting `<AdvancedSettingsSheet>` passes
|
||||
* `onRequestPhaseSheet`, the section must NOT mount its own
|
||||
* `<PhaseConfigSheet>` — the parent orchestrates the swap (close the
|
||||
* advanced sheet, then open the phase sheet) so the two never stack.
|
||||
* Without the callback the section keeps owning its own phase sheet,
|
||||
* preserving the standalone-surface fallback.
|
||||
*
|
||||
* `<PhaseConfigSheet>` is mocked to a marker so its presence /
|
||||
* absence is observable in static markup regardless of open-state.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
vi.mock("@/components/medications/sections/api-tokens-row", () => ({
|
||||
ApiTokensRow: () => <div data-slot="mock-api-tokens" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/medications/sections/phase-config-sheet", () => ({
|
||||
PhaseConfigSheet: () => <div data-slot="mock-phase-config-sheet" />,
|
||||
}));
|
||||
|
||||
import { SettingsSection } from "@/components/medications/sections/settings-section";
|
||||
|
||||
function makeClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, staleTime: Number.POSITIVE_INFINITY },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={makeClient()}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// GLP-1 + a set course window so the Phasen row + button mount.
|
||||
const glp1Props = {
|
||||
medicationId: "med-1",
|
||||
medicationName: "Test Drug",
|
||||
treatmentClass: "GLP1",
|
||||
startsOn: "2026-01-01",
|
||||
endsOn: "2026-06-01",
|
||||
reminderGraceMinutes: 30,
|
||||
};
|
||||
|
||||
describe("SettingsSection phase sibling-swap (G-1 §5)", () => {
|
||||
it("does NOT self-mount the phase sheet when onRequestPhaseSheet is provided", () => {
|
||||
const html = render(
|
||||
<SettingsSection {...glp1Props} onRequestPhaseSheet={() => {}} />,
|
||||
);
|
||||
expect(html).toContain("medication-detail-phase-management-button");
|
||||
expect(html).not.toContain("mock-phase-config-sheet");
|
||||
});
|
||||
|
||||
it("self-mounts the phase sheet on the standalone surface (no callback)", () => {
|
||||
const html = render(<SettingsSection {...glp1Props} />);
|
||||
expect(html).toContain("medication-detail-phase-management-button");
|
||||
expect(html).toContain("mock-phase-config-sheet");
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,13 @@ export interface DestructiveZoneSectionProps {
|
||||
medicationName: string;
|
||||
active: boolean;
|
||||
intakeCount: number;
|
||||
/**
|
||||
* v1.5.6 G-1 §5 — fires after a non-navigating destructive success
|
||||
* (Tier 1 pause/resume, Tier 2 end, Tier 3a purge) so a hosting
|
||||
* sheet can close itself. Tier 3b delete navigates instead and does
|
||||
* not call this. Absent on the standalone surface.
|
||||
*/
|
||||
onAfterAction?: () => void;
|
||||
}
|
||||
|
||||
const PAUSE_SWITCH_ID = "medication-detail-pause-switch";
|
||||
@@ -66,6 +73,7 @@ export function DestructiveZoneSection({
|
||||
medicationName,
|
||||
active,
|
||||
intakeCount,
|
||||
onAfterAction,
|
||||
}: DestructiveZoneSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
@@ -107,6 +115,7 @@ export function DestructiveZoneSection({
|
||||
? t("medications.detail.zone.pause.pausedToast")
|
||||
: t("medications.detail.zone.pause.resumedToast"),
|
||||
);
|
||||
onAfterAction?.();
|
||||
} catch {
|
||||
setPaused(previous);
|
||||
toast.error(t("medications.detail.zone.pause.failed"));
|
||||
@@ -132,6 +141,7 @@ export function DestructiveZoneSection({
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
toast.success(t("medications.detail.zone.end.toast"));
|
||||
setEndDialogOpen(false);
|
||||
onAfterAction?.();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.zone.end.failed"));
|
||||
} finally {
|
||||
@@ -154,6 +164,7 @@ export function DestructiveZoneSection({
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
toast.success(t("medications.detail.zone.purge.toast"));
|
||||
setPurgeDialogOpen(false);
|
||||
onAfterAction?.();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.zone.purge.failed"));
|
||||
} finally {
|
||||
|
||||
@@ -40,7 +40,10 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog";
|
||||
import { IntakeHistoryListV2 } from "@/components/medications/intake-history-list-v2";
|
||||
import {
|
||||
IntakeHistoryListV2,
|
||||
type IntakeEvent,
|
||||
} from "@/components/medications/intake-history-list-v2";
|
||||
import { IntakeImportDialog } from "@/components/medications/intake-import-dialog";
|
||||
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -61,7 +64,7 @@ export function IntakeHistoryPreview({
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingEvent, setEditingEvent] = useState<IntakeEvent | null>(null);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false);
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
@@ -206,7 +209,7 @@ export function IntakeHistoryPreview({
|
||||
<IntakeHistoryListV2
|
||||
medicationId={medicationId}
|
||||
pageSize={14}
|
||||
onEditIntake={setEditingId}
|
||||
onEditIntake={setEditingEvent}
|
||||
onDeleteIntake={setPendingDeleteId}
|
||||
selection={{
|
||||
mode: "multi",
|
||||
@@ -226,19 +229,25 @@ export function IntakeHistoryPreview({
|
||||
</div>
|
||||
</MedicationDetailSection>
|
||||
|
||||
<IntakeImportDialog
|
||||
medicationId={importOpen ? medicationId : null}
|
||||
onClose={() => onImportOpenChange(false)}
|
||||
/>
|
||||
{importOpen && (
|
||||
<IntakeImportDialog
|
||||
medicationId={medicationId}
|
||||
onClose={() => onImportOpenChange(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<IntakeEditDialog
|
||||
medicationId={medicationId}
|
||||
event={
|
||||
editingId
|
||||
? { id: editingId, takenAt: null, skipped: false }
|
||||
editingEvent
|
||||
? {
|
||||
id: editingEvent.id,
|
||||
takenAt: editingEvent.takenAt,
|
||||
skipped: editingEvent.skipped,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onClose={() => setEditingId(null)}
|
||||
onClose={() => setEditingEvent(null)}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
|
||||
@@ -40,6 +40,15 @@ export interface SettingsSectionProps {
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
reminderGraceMinutes?: number | null;
|
||||
/**
|
||||
* v1.5.6 G-1 §5 — sibling-swap the phase sheet. When provided, the
|
||||
* Phasen button hands control to the parent (which closes the
|
||||
* hosting sheet before opening `<PhaseConfigSheet>`) instead of the
|
||||
* self-managed `phaseSheetOpen` state, so the two sheets never
|
||||
* stack. Absent on the standalone surface, where the section owns
|
||||
* its own phase sheet.
|
||||
*/
|
||||
onRequestPhaseSheet?: () => void;
|
||||
}
|
||||
|
||||
export function SettingsSection({
|
||||
@@ -49,6 +58,7 @@ export function SettingsSection({
|
||||
startsOn,
|
||||
endsOn,
|
||||
reminderGraceMinutes,
|
||||
onRequestPhaseSheet,
|
||||
}: SettingsSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -136,7 +146,11 @@ export function SettingsSection({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPhaseSheetOpen(true)}
|
||||
onClick={() =>
|
||||
onRequestPhaseSheet
|
||||
? onRequestPhaseSheet()
|
||||
: setPhaseSheetOpen(true)
|
||||
}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="medication-detail-phase-management-button"
|
||||
>
|
||||
@@ -196,7 +210,7 @@ export function SettingsSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showPhases && (
|
||||
{showPhases && !onRequestPhaseSheet && (
|
||||
<PhaseConfigSheet
|
||||
medicationId={medicationId}
|
||||
open={phaseSheetOpen}
|
||||
|
||||
@@ -87,14 +87,6 @@ export interface MedicationWizardDialogProps {
|
||||
mode: "create" | "edit";
|
||||
/** Required when `mode === "edit"`. */
|
||||
initial?: MedicationPayload;
|
||||
/**
|
||||
* v1.5.5 C-E2-2 — call-site intent that drives the landing step on
|
||||
* an edit-open. The detail page's cadence-summary row passes
|
||||
* `"cadence"` so the user lands on Step 5; the header pencil passes
|
||||
* `"name"` so the user lands on Step 1. Defaults to `undefined`
|
||||
* which keeps the legacy 1-or-8 heuristic.
|
||||
*/
|
||||
landingIntent?: "cadence" | "summary" | "name";
|
||||
/** Fires with the medication id on a successful create / save. */
|
||||
onSuccess?: (id: string) => void;
|
||||
}
|
||||
@@ -113,14 +105,13 @@ const STEP_ICONS = {
|
||||
type StepNumber = keyof typeof STEP_ICONS;
|
||||
|
||||
export function MedicationWizardDialog(props: MedicationWizardDialogProps) {
|
||||
// Key the inner state container on (open + mode + medication id +
|
||||
// landingIntent) so every open gets a fresh state tree AND every
|
||||
// change of intent re-runs the initial-step decision. React unmounts
|
||||
// the inner component when the key changes, which sidesteps the
|
||||
// "setState in useEffect" anti-pattern that resetting on open via an
|
||||
// effect would otherwise trigger. The outer shell stays mounted so
|
||||
// the dialog's close animation runs cleanly.
|
||||
const stateKey = `${props.open ? "open" : "closed"}:${props.mode}:${props.initial?.id ?? "new"}:${props.landingIntent ?? "default"}`;
|
||||
// Key the inner state container on (open + mode + medication id) so
|
||||
// every open gets a fresh state tree. React unmounts the inner
|
||||
// component when the key changes, which sidesteps the "setState in
|
||||
// useEffect" anti-pattern that resetting on open via an effect would
|
||||
// otherwise trigger. The outer shell stays mounted so the dialog's
|
||||
// close animation runs cleanly.
|
||||
const stateKey = `${props.open ? "open" : "closed"}:${props.mode}:${props.initial?.id ?? "new"}`;
|
||||
return <WizardDialogShell key={stateKey} {...props} />;
|
||||
}
|
||||
|
||||
@@ -129,7 +120,6 @@ function WizardDialogShell({
|
||||
onOpenChange,
|
||||
mode,
|
||||
initial,
|
||||
landingIntent,
|
||||
onSuccess,
|
||||
}: MedicationWizardDialogProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
@@ -144,7 +134,7 @@ function WizardDialogShell({
|
||||
const [step, setStep] = useState<StepNumber>(() => {
|
||||
if (mode === "edit" && initial) {
|
||||
const hydrated = hydrateWizardPayload(initial);
|
||||
return landingStepForEdit(hydrated, landingIntent) as StepNumber;
|
||||
return landingStepForEdit(hydrated) as StepNumber;
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
|
||||
@@ -722,63 +722,6 @@ describe("compose-mode — multi-schedule encoder + hydrator", () => {
|
||||
expect(landingStepForEdit(single)).toBe(1);
|
||||
});
|
||||
|
||||
// v1.5.5 C-E2-2 — `landingStepForEdit` gains an `intent` arg so the
|
||||
// detail-page cadence-summary row can drop the user directly on
|
||||
// Step 5 instead of bouncing them through Step 1 first. One-shot
|
||||
// payloads fall back to Step 8 because the cadence picker is not on
|
||||
// the one-shot path.
|
||||
it("lands on Step 5 for the cadence intent", () => {
|
||||
const payload = hydrateWizardPayload({
|
||||
id: "m_cadence",
|
||||
name: "Test",
|
||||
dose: "10",
|
||||
category: "OTHER",
|
||||
treatmentClass: "GENERIC",
|
||||
notificationsEnabled: true,
|
||||
startsOn: new Date(Date.UTC(2026, 4, 28)),
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
schedules: [
|
||||
{
|
||||
id: "sch_a",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
timesOfDay: ["08:00"],
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(landingStepForEdit(payload, "cadence")).toBe(5);
|
||||
expect(landingStepForEdit(payload, "name")).toBe(1);
|
||||
expect(landingStepForEdit(payload, "summary")).toBe(8);
|
||||
});
|
||||
|
||||
it("falls back to Step 8 on cadence intent for a one-shot medication", () => {
|
||||
const payload = hydrateWizardPayload({
|
||||
id: "m_oneshot",
|
||||
name: "Test",
|
||||
dose: "10",
|
||||
category: "OTHER",
|
||||
treatmentClass: "GENERIC",
|
||||
notificationsEnabled: true,
|
||||
startsOn: new Date(Date.UTC(2026, 4, 28)),
|
||||
endsOn: null,
|
||||
oneShot: true,
|
||||
schedules: [
|
||||
{
|
||||
id: "sch_a",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
timesOfDay: ["08:00"],
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(landingStepForEdit(payload, "cadence")).toBe(8);
|
||||
});
|
||||
|
||||
it("removeSchedule refuses when schedules.length === 1", () => {
|
||||
const payload = emptyWizardPayload();
|
||||
expect(payload.schedules).toHaveLength(1);
|
||||
|
||||
@@ -879,36 +879,12 @@ export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload
|
||||
}
|
||||
|
||||
/**
|
||||
* Decision the dialog uses to pick the landing step on edit-hydrate.
|
||||
*
|
||||
* Three call-site intents:
|
||||
*
|
||||
* - `"name"` (default header pencil): land on Step 1 so the user
|
||||
* edits the medication's name, dose, treatment-class as a single
|
||||
* top-down walk. Multi-schedule medications still jump to the
|
||||
* summary list because the muscle memory there is "see every
|
||||
* parallel schedule first".
|
||||
* - `"cadence"`: land directly on Step 5 (the cadence picker). The
|
||||
* v1.5.5 detail page's cadence-summary row uses this so a tap on
|
||||
* the row's edit pencil drops the user one screen away from the
|
||||
* cadence they wanted to change, not two screens away after the
|
||||
* intro + summary.
|
||||
* - undefined: legacy behaviour — Step 1 for single-schedule, Step 8
|
||||
* for multi-schedule.
|
||||
*
|
||||
* One-shot medications collapse Step 5 out of the path, so a
|
||||
* `"cadence"` intent on a one-shot payload falls back to Step 8
|
||||
* (the summary, where the user can flip the one-shot toggle).
|
||||
* Decision the dialog uses to pick the landing step on edit-hydrate:
|
||||
* Step 1 for a single-schedule medication (a straight top-down walk),
|
||||
* Step 8 (the summary list) for a multi-schedule one, where the muscle
|
||||
* memory is "see every parallel schedule first".
|
||||
*/
|
||||
export function landingStepForEdit(
|
||||
payload: WizardPayload,
|
||||
intent?: "cadence" | "summary" | "name",
|
||||
): number {
|
||||
if (intent === "cadence") {
|
||||
return payload.mode === "oneShot" ? 8 : 5;
|
||||
}
|
||||
if (intent === "summary") return 8;
|
||||
if (intent === "name") return 1;
|
||||
export function landingStepForEdit(payload: WizardPayload): number {
|
||||
return payload.schedules.length > 1 ? 8 : 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
|
||||
import {
|
||||
getCachedCodexSlug,
|
||||
@@ -283,7 +284,7 @@ export class CodexClient implements AIProvider {
|
||||
): Promise<Response> {
|
||||
const sessionId = randomUUID();
|
||||
const threadId = randomUUID();
|
||||
return fetch(CODEX_ENDPOINT, {
|
||||
return safeFetch(CODEX_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -320,7 +321,10 @@ export class CodexClient implements AIProvider {
|
||||
// Required field; empty array when not asking for reasoning.
|
||||
include: [],
|
||||
}),
|
||||
});
|
||||
// SSE streaming completion — match the 60 s budget the other AI
|
||||
// clients use so a long generation is not clipped by the 15 s
|
||||
// default while still bounding a tar-pit upstream.
|
||||
}, { timeoutMs: 60_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
|
||||
/**
|
||||
* Codex / ChatGPT-OAuth flow — implemented against `docs/codex-protocol-spec.md`,
|
||||
@@ -173,7 +174,7 @@ export interface DeviceCodeStart {
|
||||
}
|
||||
|
||||
export async function requestDeviceCode(): Promise<DeviceCodeStart> {
|
||||
const res = await fetch(`${ISSUER}/api/accounts/deviceauth/usercode`, {
|
||||
const res = await safeFetch(`${ISSUER}/api/accounts/deviceauth/usercode`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ client_id: getCodexClientId() }),
|
||||
@@ -214,7 +215,7 @@ export async function pollDeviceCode(params: {
|
||||
deviceAuthId: string;
|
||||
userCode: string;
|
||||
}): Promise<DevicePollResult> {
|
||||
const res = await fetch(`${ISSUER}/api/accounts/deviceauth/token`, {
|
||||
const res = await safeFetch(`${ISSUER}/api/accounts/deviceauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -240,7 +241,7 @@ export async function pollDeviceCode(params: {
|
||||
// Standard PKCE exchange against the OAuth token endpoint, with the
|
||||
// device-auth callback URI Hydra associates with this authorization
|
||||
// code internally.
|
||||
const tokenRes = await fetch(`${ISSUER}/oauth/token`, {
|
||||
const tokenRes = await safeFetch(`${ISSUER}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
@@ -276,7 +277,7 @@ export async function pollDeviceCode(params: {
|
||||
export async function refreshDeviceTokens(
|
||||
refreshToken: string,
|
||||
): Promise<CodexCreds> {
|
||||
const res = await fetch(`${ISSUER}/oauth/token`, {
|
||||
const res = await safeFetch(`${ISSUER}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -100,6 +100,13 @@ import {
|
||||
drainPerSampleCumulative,
|
||||
DRAIN_CUMULATIVE_CUTOFF_HOURS,
|
||||
} from "@/lib/measurements/drain-per-sample-cumulative";
|
||||
import {
|
||||
STEP_CONSOLIDATION_QUEUE,
|
||||
STEP_CONSOLIDATION_CONCURRENCY,
|
||||
runStepConsolidationForUser,
|
||||
enqueueBootTimeStepConsolidation,
|
||||
type StepConsolidationPayload,
|
||||
} from "@/lib/jobs/step-consolidation";
|
||||
import { expireStaleInUseItems } from "@/lib/medications/inventory/service";
|
||||
import { rotateLegacyMoodLogSecrets } from "@/lib/moodlog-secret";
|
||||
import { probeIntegrationStatusNullBuckets } from "@/lib/jobs/integration-status-null-probe";
|
||||
@@ -1868,6 +1875,14 @@ export async function startReminderWorker() {
|
||||
// rollup tier. Discovery enqueues one job per user with intake
|
||||
// events but no rollup coverage; idempotent across reboots.
|
||||
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
|
||||
// v1.5.6 — boot-time legacy step consolidation. Discovery enqueues
|
||||
// one job per user still holding live pre-v1.5.0 granular step
|
||||
// rows; the per-user pass collapses them into one daily total and
|
||||
// soft-deletes the originals. Idempotent across reboots — a
|
||||
// consolidated user drops off the discovery list. The queue MUST be
|
||||
// registered here or pg-boss never provisions it and the boot
|
||||
// enqueue silently never drains.
|
||||
STEP_CONSOLIDATION_QUEUE,
|
||||
// v1.4.37 W7c — explicit createQueue is required before the
|
||||
// nightly schedule below registers (pg-boss v12 contract). Without
|
||||
// this entry the drain schedule silently no-ops and the
|
||||
@@ -2173,6 +2188,27 @@ export async function startReminderWorker() {
|
||||
},
|
||||
);
|
||||
|
||||
// v1.5.6 — legacy step consolidation worker. The boot enqueue helper
|
||||
// below sends one job per user still holding live pre-v1.5.0 granular
|
||||
// step rows; this handler collapses them into one daily-total row per
|
||||
// calendar day and soft-deletes the originals. Serial concurrency so
|
||||
// the populator never crowds the dashboard request pool.
|
||||
await boss.work<StepConsolidationPayload>(
|
||||
STEP_CONSOLIDATION_QUEUE,
|
||||
{ localConcurrency: STEP_CONSOLIDATION_CONCURRENCY },
|
||||
async (jobs) => {
|
||||
for (const job of jobs) {
|
||||
const { userId } = job.data;
|
||||
const { daysConsolidated, legacyRowsSoftDeleted } =
|
||||
await runStepConsolidationForUser(userId);
|
||||
workerLog(
|
||||
"info",
|
||||
`[step-consolidation] user=${userId} days=${daysConsolidated} legacyRowsSoftDeleted=${legacyRowsSoftDeleted}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// v1.4.39 W-MOOD — mood-rollup per-bucket worker. Folds the
|
||||
// WEEK / MONTH / YEAR buckets that the mood-entry write hooks
|
||||
// enqueue; the DAY bucket runs synchronously in the hook itself.
|
||||
@@ -2320,6 +2356,36 @@ export async function startReminderWorker() {
|
||||
);
|
||||
}
|
||||
|
||||
// v1.5.6 — fire-and-forget boot discovery for the legacy step
|
||||
// consolidation pass. Finds every user still holding live pre-v1.5.0
|
||||
// granular step rows and enqueues one consolidation job per account.
|
||||
// Idempotent across reboots: consolidated legacy rows are
|
||||
// soft-deleted, so the `deleted_at IS NULL` discovery predicate drops
|
||||
// them and the user falls off the list. Errors are returned through
|
||||
// the helper's result value — the worker boot never fails because of
|
||||
// a consolidation miss.
|
||||
try {
|
||||
const { enqueued, skipped, error } =
|
||||
await enqueueBootTimeStepConsolidation();
|
||||
if (error) {
|
||||
workerLog(
|
||||
"error",
|
||||
`[step-consolidation] boot discovery failed: ${error}`,
|
||||
);
|
||||
} else {
|
||||
workerLog(
|
||||
"info",
|
||||
`[step-consolidation] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
workerLog(
|
||||
"error",
|
||||
"[step-consolidation] boot discovery threw an unexpected error",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
// v1.4.39 W-MOOD — fire-and-forget boot discovery for the mood
|
||||
// rollup tier. Mirrors the v1.4.35.1 measurement-rollup pattern:
|
||||
// one job per user with mood entries but no rollup coverage.
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* v1.5.6 — pg-boss queue + boot-time converging backfill for the
|
||||
* legacy step consolidation pass. Modelled on the
|
||||
* `rollup-full-backfill` boot-time pattern: a discovery query enqueues
|
||||
* one job per user still holding live legacy step rows, the per-user
|
||||
* handler runs `consolidateLegacySteps`, and the pass is idempotent
|
||||
* across reboots (consolidated rows are soft-deleted so they drop off
|
||||
* the discovery list).
|
||||
*
|
||||
* The queue name MUST be registered in `allQueues` in
|
||||
* `src/lib/jobs/reminder-worker.ts` so pg-boss provisions it at boot;
|
||||
* an unregistered queue silently never drains.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { consolidateLegacySteps } from "@/lib/measurements/consolidate-legacy-steps";
|
||||
|
||||
export const STEP_CONSOLIDATION_QUEUE = "step-consolidation";
|
||||
|
||||
/**
|
||||
* Serial concurrency — the populator walks every legacy step row for a
|
||||
* user and writes in a transaction per day; concurrency-1 keeps it from
|
||||
* crowding the request pool, matching `ROLLUP_FULL_BACKFILL_CONCURRENCY`.
|
||||
*/
|
||||
export const STEP_CONSOLIDATION_CONCURRENCY = 1;
|
||||
|
||||
export interface StepConsolidationPayload {
|
||||
userId: string;
|
||||
enqueuedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user queue handler. Runs the consolidation for one account and
|
||||
* returns the summary totals so the worker can log them.
|
||||
*/
|
||||
export async function runStepConsolidationForUser(
|
||||
userId: string,
|
||||
): Promise<{ daysConsolidated: number; legacyRowsSoftDeleted: number }> {
|
||||
const summary = await consolidateLegacySteps(prisma, {
|
||||
userId,
|
||||
log: () => {
|
||||
// Silent inside the queue handler — the worker logs the totals.
|
||||
},
|
||||
});
|
||||
// Stable wide-event action name (`<surface>.<noun>.<verb>`). No-ops
|
||||
// outside a request/job event context.
|
||||
annotate({
|
||||
action: {
|
||||
name: "measurement.step.consolidate",
|
||||
details: {
|
||||
days: summary.totals.daysConsolidated,
|
||||
legacy_rows_soft_deleted: summary.totals.legacyRowsSoftDeleted,
|
||||
days_folded_into_existing: summary.totals.daysFoldedIntoExisting,
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
daysConsolidated: summary.totals.daysConsolidated,
|
||||
legacyRowsSoftDeleted: summary.totals.legacyRowsSoftDeleted,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-time discovery. Finds every user with at least one LIVE legacy
|
||||
* step row (an `ACTIVITY_STEPS` row whose `externalId` is NULL or does
|
||||
* NOT start with the daily-stats prefix, and that is not tombstoned)
|
||||
* and enqueues one consolidation job per account.
|
||||
*
|
||||
* Idempotent across reboots: once a user's legacy rows are
|
||||
* soft-deleted, the `deleted_at IS NULL` predicate drops them from the
|
||||
* discovery set and the user falls off the list. pg-boss `singletonKey`
|
||||
* coalesces duplicate sends within the queue so a fast restart while a
|
||||
* job is queued doesn't double up.
|
||||
*
|
||||
* Best-effort: errors are returned through the result value so the
|
||||
* worker boot never fails because of a consolidation miss.
|
||||
*/
|
||||
export async function enqueueBootTimeStepConsolidation(): Promise<{
|
||||
enqueued: number;
|
||||
skipped: number;
|
||||
error: string | null;
|
||||
}> {
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) {
|
||||
return { enqueued: 0, skipped: 0, error: null };
|
||||
}
|
||||
|
||||
try {
|
||||
// The daily-total externalId shape is `stats:<HK>:<YYYY-MM-DD>`. A
|
||||
// live row is a legacy candidate when its externalId does NOT match
|
||||
// that prefix (NULL externalId — a manual/legacy row — also
|
||||
// qualifies because `LIKE` against NULL is NULL/false). Splice-free:
|
||||
// the prefix is a constant compile-time literal, not user input.
|
||||
const users = await prisma.$queryRaw<Array<{ id: string }>>`
|
||||
SELECT DISTINCT m."user_id" AS id
|
||||
FROM measurements m
|
||||
WHERE m."type" = 'ACTIVITY_STEPS'
|
||||
AND m."deleted_at" IS NULL
|
||||
AND (
|
||||
m."external_id" IS NULL
|
||||
OR m."external_id" NOT LIKE 'stats:HKQuantityTypeIdentifierStepCount:%'
|
||||
)
|
||||
`;
|
||||
|
||||
if (users.length === 0) {
|
||||
return { enqueued: 0, skipped: 0, error: null };
|
||||
}
|
||||
|
||||
let enqueued = 0;
|
||||
let skipped = 0;
|
||||
for (const { id } of users) {
|
||||
const payload: StepConsolidationPayload = {
|
||||
userId: id,
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
};
|
||||
const jobId = await boss.send(STEP_CONSOLIDATION_QUEUE, payload, {
|
||||
retryLimit: 3,
|
||||
retryDelay: 60,
|
||||
retryBackoff: true,
|
||||
singletonKey: `step-consolidation|${id}`,
|
||||
});
|
||||
if (jobId) {
|
||||
enqueued += 1;
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
return { enqueued, skipped, error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
enqueued: 0,
|
||||
skipped: 0,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,9 @@ async function flushLokiBuffer(): Promise<void> {
|
||||
headers,
|
||||
body: JSON.stringify({ streams }),
|
||||
},
|
||||
{ timeoutMs: 10_000 },
|
||||
// Operator-configured Loki endpoint — pin the connect-time IP
|
||||
// against DNS rebinding.
|
||||
{ timeoutMs: 10_000, requirePublicHost: true },
|
||||
);
|
||||
} catch {
|
||||
// Events gehen verloren — akzeptabel fuer Logging
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* v1.5.6 — unit coverage for the extractable bucketing + summation
|
||||
* helpers behind the legacy step-consolidation pass. The DB-touching
|
||||
* end-to-end contract lives in
|
||||
* `tests/integration/consolidate-legacy-steps.test.ts`.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
bucketLegacyStepRows,
|
||||
sumLegacyStepValues,
|
||||
STEP_DAILY_STATS_PREFIX,
|
||||
} from "../consolidate-legacy-steps";
|
||||
import type { PerSampleRow } from "../drain-per-sample-cumulative";
|
||||
|
||||
function row(
|
||||
partial: Partial<PerSampleRow> & { measuredAt: Date },
|
||||
): PerSampleRow {
|
||||
return {
|
||||
id: partial.id ?? "id-1",
|
||||
type: partial.type ?? "ACTIVITY_STEPS",
|
||||
value: partial.value ?? 100,
|
||||
measuredAt: partial.measuredAt,
|
||||
externalId: partial.externalId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("bucketLegacyStepRows", () => {
|
||||
it("groups rows by the user's calendar day", () => {
|
||||
const tz = "Europe/Berlin";
|
||||
const rows = [
|
||||
row({ id: "a", measuredAt: new Date("2026-05-16T08:00:00.000Z") }),
|
||||
row({ id: "b", measuredAt: new Date("2026-05-16T20:00:00.000Z") }),
|
||||
row({ id: "c", measuredAt: new Date("2026-05-17T06:00:00.000Z") }),
|
||||
];
|
||||
const byDay = bucketLegacyStepRows(rows, tz);
|
||||
expect(byDay.get("2026-05-16")).toHaveLength(2);
|
||||
expect(byDay.get("2026-05-17")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips rows already in the daily-stats shape", () => {
|
||||
const tz = "Europe/Berlin";
|
||||
const rows = [
|
||||
row({
|
||||
id: "total",
|
||||
externalId: `${STEP_DAILY_STATS_PREFIX}2026-05-16`,
|
||||
measuredAt: new Date("2026-05-16T10:00:00.000Z"),
|
||||
}),
|
||||
row({ id: "legacy", measuredAt: new Date("2026-05-16T08:00:00.000Z") }),
|
||||
];
|
||||
const byDay = bucketLegacyStepRows(rows, tz);
|
||||
expect(byDay.get("2026-05-16")).toHaveLength(1);
|
||||
expect(byDay.get("2026-05-16")?.[0].id).toBe("legacy");
|
||||
});
|
||||
|
||||
it("buckets a late-evening UTC sample into the next local day", () => {
|
||||
// 23:30 UTC on 2026-05-16 is 01:30 on 2026-05-17 in Berlin (UTC+2).
|
||||
const byDay = bucketLegacyStepRows(
|
||||
[row({ id: "late", measuredAt: new Date("2026-05-16T23:30:00.000Z") })],
|
||||
"Europe/Berlin",
|
||||
);
|
||||
expect(byDay.has("2026-05-17")).toBe(true);
|
||||
expect(byDay.has("2026-05-16")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sumLegacyStepValues", () => {
|
||||
it("sums the values in a bucket", () => {
|
||||
const rows = [
|
||||
row({ value: 1200, measuredAt: new Date("2026-05-16T08:00:00.000Z") }),
|
||||
row({ value: 3400, measuredAt: new Date("2026-05-16T14:00:00.000Z") }),
|
||||
row({ value: 800, measuredAt: new Date("2026-05-16T20:00:00.000Z") }),
|
||||
];
|
||||
expect(sumLegacyStepValues(rows)).toBe(5400);
|
||||
});
|
||||
|
||||
it("returns 0 for an empty bucket", () => {
|
||||
expect(sumLegacyStepValues([])).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* v1.5.6 — consolidate pre-v1.5.0 legacy step rows into one daily-total
|
||||
* row per user per calendar day.
|
||||
*
|
||||
* Pre-v1.5.0, HealthKit step samples landed at sampling granularity —
|
||||
* hundreds-to-thousands of `ACTIVITY_STEPS` rows per day. Post-v1.5.0,
|
||||
* iOS emits one overwrite-able daily-total row carrying the
|
||||
* `stats:HKQuantityTypeIdentifierStepCount:YYYY-MM-DD` externalId
|
||||
* (see `src/app/api/measurements/batch/route.ts` stats-overwrite logic).
|
||||
* This pass collapses the legacy granular rows into the same canonical
|
||||
* daily shape so historical step data reads consistently.
|
||||
*
|
||||
* Per user × calendar day (anchored to `User.timezone`):
|
||||
* 1. SELECT live (`deletedAt IS NULL`) `ACTIVITY_STEPS` rows whose
|
||||
* `externalId` does NOT start with the daily-stats prefix.
|
||||
* 2. Group into per-day buckets in the user's timezone.
|
||||
* 3. SUM the legacy values for the day.
|
||||
* 4. UPSERT the canonical daily-total row keyed by
|
||||
* `stats:HKQuantityTypeIdentifierStepCount:<dateKey>`. If a daily
|
||||
* total already exists for the day (post-v1.5.0 iOS already wrote
|
||||
* one), the existing total is the source of truth — it is NOT
|
||||
* overwritten and the legacy sum is NOT folded in (avoids double-
|
||||
* counting: the post-v1.5.0 row already represents the true daily
|
||||
* total HealthKit computed). The legacy granular rows are still
|
||||
* soft-deleted so they stop polluting the per-sample read path.
|
||||
* 5. SOFT-DELETE (set `deletedAt`) the legacy granular rows — they
|
||||
* stay in the table as an audit/backup trail. Sampling-granularity
|
||||
* loss is explicitly accepted.
|
||||
*
|
||||
* Idempotent: the discovery query (`enqueueBootTimeStepConsolidation`)
|
||||
* matches only users still holding live legacy step rows, so a second
|
||||
* run converges to zero work. Within a run, soft-deleted legacy rows
|
||||
* are excluded from the scan, so a re-run after a partial pass never
|
||||
* re-aggregates an already-consolidated day.
|
||||
*
|
||||
* Runs on pg-boss (`STEP_CONSOLIDATION_QUEUE`), not a CLI — the
|
||||
* production standalone image strips `tsx`. Modelled on the
|
||||
* `rollup-full-backfill` boot-time converging-backfill pattern.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
import { dailyStatsExternalId } from "./apple-health-mapping";
|
||||
import {
|
||||
canonicalDailyTimestamp,
|
||||
dayKeyForUserTz,
|
||||
type PerSampleRow,
|
||||
} from "./drain-per-sample-cumulative";
|
||||
|
||||
/** HealthLog measurement type that carries step counts. */
|
||||
const STEP_TYPE = "ACTIVITY_STEPS" as const;
|
||||
|
||||
/** Canonical HealthKit identifier for the step type. */
|
||||
const STEP_HK_IDENTIFIER = "HKQuantityTypeIdentifierStepCount";
|
||||
|
||||
/**
|
||||
* Daily-total externalId prefix. Legacy rows that do NOT start with
|
||||
* this are candidates for consolidation; the daily-total row this pass
|
||||
* mints carries the full `stats:HKQuantityTypeIdentifierStepCount:<day>`
|
||||
* externalId.
|
||||
*/
|
||||
export const STEP_DAILY_STATS_PREFIX = `stats:${STEP_HK_IDENTIFIER}:`;
|
||||
|
||||
/** Per-(user, day) action summary. */
|
||||
export interface StepConsolidationBucket {
|
||||
userId: string;
|
||||
/** Calendar-day key in the user's timezone (`YYYY-MM-DD`). */
|
||||
dateKey: string;
|
||||
/** Number of legacy granular rows folded for this day. */
|
||||
legacyRowCount: number;
|
||||
/** SUM of the legacy granular values for the day. */
|
||||
legacySum: number;
|
||||
/** Whether a `stats:` daily total already existed for this day. */
|
||||
hadExistingTotal: boolean;
|
||||
/** ISO-8601 canonical timestamp (local-noon of the user's day). */
|
||||
canonicalTimestamp: string;
|
||||
/** Resulting daily-total `externalId`. */
|
||||
externalId: string;
|
||||
}
|
||||
|
||||
export interface StepConsolidationSummary {
|
||||
/** Did the run actually write to the DB (false for `dryRun`). */
|
||||
dryRun: boolean;
|
||||
/** Per-user-day buckets the pass rewrote (or would rewrite). */
|
||||
buckets: StepConsolidationBucket[];
|
||||
totals: {
|
||||
usersScanned: number;
|
||||
daysConsolidated: number;
|
||||
legacyRowsSoftDeleted: number;
|
||||
dailyRowsUpserted: number;
|
||||
/** Days skipped because a `stats:` total already existed. */
|
||||
daysFoldedIntoExisting: number;
|
||||
/**
|
||||
* Days skipped because the per-day mint hit a unique-constraint
|
||||
* collision (P2002) and the transaction rolled back. Counted, logged,
|
||||
* and stepped over so a single poison day can never strand the rest
|
||||
* of the pass — the pass stays converging for every other day.
|
||||
*/
|
||||
daysSkippedOnConflict: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** True for a Prisma unique-constraint violation (P2002). */
|
||||
function isUniqueConstraintViolation(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as { code?: unknown }).code === "P2002"
|
||||
);
|
||||
}
|
||||
|
||||
export interface StepConsolidationOptions {
|
||||
/** Limit the pass to a single user. Default = every user. */
|
||||
userId?: string;
|
||||
/** Preview-only mode — no DB writes. */
|
||||
dryRun?: boolean;
|
||||
/** Logger sink — defaults to `console.log`. */
|
||||
log?: (line: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group legacy step rows into per-day buckets in the user's timezone.
|
||||
* Rows already in the daily-stats shape are skipped — they are the
|
||||
* canonical total, not a legacy sample. Exposed for unit testing the
|
||||
* bucketing semantics without booting Prisma.
|
||||
*/
|
||||
export function bucketLegacyStepRows(
|
||||
rows: readonly PerSampleRow[],
|
||||
tz: string,
|
||||
): Map<string, PerSampleRow[]> {
|
||||
const byDay = new Map<string, PerSampleRow[]>();
|
||||
for (const row of rows) {
|
||||
if (
|
||||
row.externalId !== null &&
|
||||
row.externalId.startsWith(STEP_DAILY_STATS_PREFIX)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const key = dayKeyForUserTz(row.measuredAt, tz);
|
||||
const slot = byDay.get(key) ?? [];
|
||||
slot.push(row);
|
||||
byDay.set(key, slot);
|
||||
}
|
||||
return byDay;
|
||||
}
|
||||
|
||||
/** SUM the values in a per-day legacy bucket. */
|
||||
export function sumLegacyStepValues(rows: readonly PerSampleRow[]): number {
|
||||
let acc = 0;
|
||||
for (const row of rows) acc += row.value;
|
||||
return acc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the step consolidation. Idempotent — re-invocation after a
|
||||
* successful pass reports zero days consolidated because the legacy
|
||||
* rows are soft-deleted (and so excluded from the live scan).
|
||||
*
|
||||
* Does NOT enforce any auth gate — the queue handler owns that concern.
|
||||
*/
|
||||
export async function consolidateLegacySteps(
|
||||
prismaClient: PrismaClient,
|
||||
options: StepConsolidationOptions = {},
|
||||
): Promise<StepConsolidationSummary> {
|
||||
const dryRun = options.dryRun ?? false;
|
||||
const log = options.log ?? ((line) => console.log(line));
|
||||
|
||||
const users = options.userId
|
||||
? await prismaClient.user.findMany({
|
||||
where: { id: options.userId },
|
||||
select: { id: true, timezone: true },
|
||||
})
|
||||
: await prismaClient.user.findMany({
|
||||
select: { id: true, timezone: true },
|
||||
});
|
||||
|
||||
const summary: StepConsolidationSummary = {
|
||||
dryRun,
|
||||
buckets: [],
|
||||
totals: {
|
||||
usersScanned: users.length,
|
||||
daysConsolidated: 0,
|
||||
legacyRowsSoftDeleted: 0,
|
||||
dailyRowsUpserted: 0,
|
||||
daysFoldedIntoExisting: 0,
|
||||
daysSkippedOnConflict: 0,
|
||||
},
|
||||
};
|
||||
|
||||
for (const user of users) {
|
||||
const tz =
|
||||
user.timezone && user.timezone.length > 0
|
||||
? user.timezone
|
||||
: "Europe/Berlin";
|
||||
|
||||
// Live legacy step rows whose externalId is NOT the daily-stats
|
||||
// shape. The `stats:` daily-total row (if present) is read
|
||||
// separately below per day; we exclude it here so it is never
|
||||
// soft-deleted. Soft-deleted rows (`deletedAt IS NOT NULL`) are
|
||||
// excluded so a re-run after a partial pass never re-aggregates.
|
||||
const legacyRows = (await prismaClient.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: STEP_TYPE,
|
||||
deletedAt: null,
|
||||
NOT: { externalId: { startsWith: STEP_DAILY_STATS_PREFIX } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
externalId: true,
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
})) as PerSampleRow[];
|
||||
|
||||
if (legacyRows.length === 0) continue;
|
||||
|
||||
log(`[step-consolidation] user=${user.id} tz=${tz} legacyRows=${legacyRows.length}${dryRun ? " (dry-run)" : ""}`);
|
||||
|
||||
const byDay = bucketLegacyStepRows(legacyRows, tz);
|
||||
|
||||
for (const [dateKey, dayRows] of byDay) {
|
||||
if (dayRows.length === 0) continue;
|
||||
|
||||
const legacySum = sumLegacyStepValues(dayRows);
|
||||
const canonicalTs = canonicalDailyTimestamp(dateKey, tz);
|
||||
const externalId = dailyStatsExternalId(STEP_HK_IDENTIFIER, dateKey);
|
||||
|
||||
// Does a post-v1.5.0 daily total already exist for this day? If so
|
||||
// it is the source of truth (HealthKit's own daily aggregate) —
|
||||
// we do NOT overwrite it and do NOT add the legacy sum on top
|
||||
// (that would double-count). We still soft-delete the legacy rows.
|
||||
// `source` is deliberately omitted from this probe (the upsert below
|
||||
// pins `source: "MANUAL"`): a daily total written by iOS lands as
|
||||
// `APPLE_HEALTH`, and matching it here is what prevents minting a
|
||||
// second MANUAL total on top of it. The asymmetry with the upsert
|
||||
// `where` is intentional, not a bug.
|
||||
const existingTotal = await prismaClient.measurement.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: STEP_TYPE,
|
||||
externalId,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const hadExistingTotal = existingTotal !== null;
|
||||
|
||||
const bucket: StepConsolidationBucket = {
|
||||
userId: user.id,
|
||||
dateKey,
|
||||
legacyRowCount: dayRows.length,
|
||||
legacySum,
|
||||
hadExistingTotal,
|
||||
canonicalTimestamp: canonicalTs.toISOString(),
|
||||
externalId,
|
||||
};
|
||||
summary.buckets.push(bucket);
|
||||
summary.totals.daysConsolidated += 1;
|
||||
if (hadExistingTotal) summary.totals.daysFoldedIntoExisting += 1;
|
||||
|
||||
const ids = dayRows.map((r) => r.id);
|
||||
|
||||
if (!dryRun) {
|
||||
try {
|
||||
await prismaClient.$transaction(async (tx) => {
|
||||
if (!hadExistingTotal) {
|
||||
// Mint the canonical daily-total row. The unique index
|
||||
// (userId, type, source, externalId) makes the upsert
|
||||
// idempotent across re-runs. Source is MANUAL — these are
|
||||
// historical rows whose original sampling source is no
|
||||
// longer meaningful once collapsed; the externalId carries
|
||||
// the canonical daily-stats shape. Built field-by-field
|
||||
// (no spread) per the no-mass-assignment convention.
|
||||
await tx.measurement.upsert({
|
||||
where: {
|
||||
userId_type_source_externalId: {
|
||||
userId: user.id,
|
||||
type: STEP_TYPE,
|
||||
source: "MANUAL",
|
||||
externalId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
type: STEP_TYPE,
|
||||
value: legacySum,
|
||||
unit: "steps",
|
||||
source: "MANUAL",
|
||||
measuredAt: canonicalTs,
|
||||
externalId,
|
||||
},
|
||||
update: {
|
||||
value: legacySum,
|
||||
measuredAt: canonicalTs,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
summary.totals.dailyRowsUpserted += 1;
|
||||
}
|
||||
|
||||
// Soft-delete the legacy granular rows in the same
|
||||
// transaction. Tombstone, never hard-delete — they remain as
|
||||
// an audit/backup trail. `deletedAt` excludes them from every
|
||||
// live read and from this pass's own re-run discovery.
|
||||
const del = await tx.measurement.updateMany({
|
||||
where: { id: { in: ids }, deletedAt: null },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
summary.totals.legacyRowsSoftDeleted += del.count;
|
||||
});
|
||||
} catch (err) {
|
||||
// A pre-existing row can collide on the second unique index
|
||||
// (userId, type, measuredAt, source, sleepStage) when a MANUAL
|
||||
// step row already sits at this day's canonical-noon instant
|
||||
// with a null externalId — the mint's `create` branch fires
|
||||
// (the upsert keys on externalId, which differs) and trips that
|
||||
// index, rolling back the soft-delete too. Skipping the day
|
||||
// (rather than aborting the whole user) keeps the pass
|
||||
// converging: the legacy rows stay live but every other day
|
||||
// still consolidates, and the run does not re-throw on every
|
||||
// boot. Non-P2002 errors are genuine failures — rethrow so
|
||||
// pg-boss retries.
|
||||
if (!isUniqueConstraintViolation(err)) throw err;
|
||||
summary.totals.daysConsolidated -= 1;
|
||||
summary.totals.daysSkippedOnConflict += 1;
|
||||
summary.buckets.pop();
|
||||
log(
|
||||
`[step-consolidation] user=${user.id} day=${dateKey} skipped — unique-constraint conflict on mint`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!hadExistingTotal) summary.totals.dailyRowsUpserted += 1;
|
||||
summary.totals.legacyRowsSoftDeleted += ids.length;
|
||||
}
|
||||
}
|
||||
|
||||
log(
|
||||
`[step-consolidation] user=${user.id} complete days=${byDay.size}${dryRun ? " (dry-run)" : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
log(
|
||||
`[step-consolidation] done — usersScanned=${summary.totals.usersScanned} daysConsolidated=${summary.totals.daysConsolidated} legacyRowsSoftDeleted=${summary.totals.legacyRowsSoftDeleted} dailyRowsUpserted=${summary.totals.dailyRowsUpserted} daysFoldedIntoExisting=${summary.totals.daysFoldedIntoExisting} daysSkippedOnConflict=${summary.totals.daysSkippedOnConflict}${dryRun ? " (dry-run)" : ""}`,
|
||||
);
|
||||
|
||||
return summary;
|
||||
}
|
||||
@@ -123,14 +123,20 @@ async function tryEnvelope(
|
||||
config: ParsedGlitchtipDsn,
|
||||
body: string,
|
||||
): Promise<GlitchtipDeliveryResult> {
|
||||
const response = await safeFetch(config.envelopeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/x-sentry-envelope",
|
||||
const response = await safeFetch(
|
||||
config.envelopeUrl,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/x-sentry-envelope",
|
||||
},
|
||||
body,
|
||||
cache: "no-store",
|
||||
},
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
// Operator-configured GlitchTip host carrying the redacted wide-event
|
||||
// payload — pin the connect-time IP against DNS rebinding.
|
||||
{ requirePublicHost: true },
|
||||
);
|
||||
|
||||
if (response.ok)
|
||||
return { ok: true, method: "envelope", status: response.status };
|
||||
@@ -152,14 +158,19 @@ async function tryStoreQuery(
|
||||
target.searchParams.set("sentry_version", "7");
|
||||
target.searchParams.set("sentry_client", "healthlog/1.0");
|
||||
|
||||
const response = await safeFetch(target, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
const response = await safeFetch(
|
||||
target,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
// Operator-configured GlitchTip host — pin the connect-time IP.
|
||||
{ requirePublicHost: true },
|
||||
);
|
||||
|
||||
if (response.ok)
|
||||
return { ok: true, method: "store-query", status: response.status };
|
||||
@@ -177,15 +188,20 @@ async function tryStoreHeader(
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<GlitchtipDeliveryResult> {
|
||||
const sentryAuth = `Sentry sentry_version=7, sentry_key=${config.publicKey}, sentry_client=healthlog/1.0`;
|
||||
const response = await safeFetch(config.storeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-sentry-auth": sentryAuth,
|
||||
const response = await safeFetch(
|
||||
config.storeUrl,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-sentry-auth": sentryAuth,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
// Operator-configured GlitchTip host — pin the connect-time IP.
|
||||
{ requirePublicHost: true },
|
||||
);
|
||||
|
||||
if (response.ok)
|
||||
return { ok: true, method: "store-header", status: response.status };
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
MEDICATION_TREATMENT_CLASS_VALUES,
|
||||
} from "@/lib/validations/medication";
|
||||
import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt";
|
||||
import { INSIGHTS_TILE_IDS } from "@/lib/insights-layout";
|
||||
|
||||
/**
|
||||
* Common envelopes — every HealthLog API response wraps payload in
|
||||
@@ -786,6 +787,29 @@ medicationExtractionSchema.meta({
|
||||
"Citation-guarded partial extraction of medication scheduling fields. Every field is optional; the wizard merges what is present onto the form state and leaves the rest blank. `name` and `dose` are post-validated against the original free-text and dropped when not substring-matched, so the wizard cannot silently land a hallucinated brand or dose. `cadenceKind` / `doseUnit` / `weekdays` are closed enums; numeric fields are clamped to the wizard's wire bounds.",
|
||||
});
|
||||
|
||||
// Insights tile layout — mirrors the Zod schema in
|
||||
// `src/app/api/insights/layout/route.ts`. The tile-id enum is derived
|
||||
// from the same `INSIGHTS_TILE_IDS` source so the contract cannot drift.
|
||||
const insightsLayoutSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
tiles: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.enum(INSIGHTS_TILE_IDS),
|
||||
visible: z.boolean(),
|
||||
order: z.number().int().min(0).max(99),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(50),
|
||||
})
|
||||
.meta({
|
||||
id: "InsightsLayoutBody",
|
||||
description:
|
||||
"Per-user Insights tile layout: an ordered list of tiles with a visibility flag. `version` is the layout schema version; tile ids are a closed enum derived from the server's tile registry.",
|
||||
});
|
||||
|
||||
// ── Standard 401 / 422 / 429 responses ───────────────────────────────
|
||||
|
||||
const stdResponses = {
|
||||
@@ -1579,6 +1603,69 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/layout": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Read the calling user's Insights tile layout",
|
||||
description:
|
||||
"Returns the per-user Insights tile layout (visibility + order). Falls back to the default layout when the user has not customised it. Mirrors the dashboard-widgets contract.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The resolved layout (custom or default).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(insightsLayoutSchema, "InsightsLayout"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["Insights"],
|
||||
summary: "Replace the calling user's Insights tile layout",
|
||||
description:
|
||||
"Persists the full tile layout. The normalised layout is returned. Invalid bodies return the multi-issue 422 envelope, matching the dashboard-widgets route.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: insightsLayoutSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Layout saved; the normalised layout is echoed back.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(insightsLayoutSchema, "InsightsLayoutSaved"),
|
||||
},
|
||||
},
|
||||
},
|
||||
// 422 (multi-issue validation envelope) comes from stdResponses.
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Insights"],
|
||||
summary: "Reset the calling user's Insights tile layout",
|
||||
description:
|
||||
"Clears the persisted layout and returns the default layout. Idempotent.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Layout reset; the default layout is returned.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightsLayoutSchema,
|
||||
"InsightsLayoutReset",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/chat/messages/{id}/feedback": {
|
||||
post: {
|
||||
tags: ["Insights"],
|
||||
|
||||
@@ -100,9 +100,10 @@ export interface UseAnalyticsQueryOptions {
|
||||
async function fetchAnalytics(
|
||||
slice: AnalyticsSlice,
|
||||
): Promise<AnalyticsRawPayload> {
|
||||
const url =
|
||||
slice === "summaries" ? "/api/analytics?slice=summaries" : "/api/analytics";
|
||||
const res = await fetch(url);
|
||||
const res =
|
||||
slice === "summaries"
|
||||
? await fetch("/api/analytics?slice=summaries")
|
||||
: await fetch("/api/analytics");
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load analytics (${res.status})`);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Docs: https://developer.withings.com/api-reference
|
||||
*/
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import {
|
||||
WithingsApiError,
|
||||
classifyWithingsResponse,
|
||||
@@ -109,7 +110,7 @@ export async function exchangeCode(
|
||||
});
|
||||
|
||||
const start = performance.now();
|
||||
const res = await fetch(WITHINGS_TOKEN_URL, {
|
||||
const res = await safeFetch(WITHINGS_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: params.toString(),
|
||||
@@ -152,7 +153,7 @@ export async function refreshAccessToken(
|
||||
});
|
||||
|
||||
const start = performance.now();
|
||||
const res = await fetch(WITHINGS_TOKEN_URL, {
|
||||
const res = await safeFetch(WITHINGS_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: params.toString(),
|
||||
@@ -269,7 +270,7 @@ export async function fetchMeasurements(
|
||||
});
|
||||
|
||||
const pageStart = performance.now();
|
||||
const res = await fetch(WITHINGS_MEASURE_URL, {
|
||||
const res = await safeFetch(WITHINGS_MEASURE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
@@ -346,7 +347,7 @@ export async function subscribeWebhook(
|
||||
});
|
||||
|
||||
const start = performance.now();
|
||||
const res = await fetch(WITHINGS_NOTIFY_URL, {
|
||||
const res = await safeFetch(WITHINGS_NOTIFY_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
@@ -397,7 +398,7 @@ export async function unsubscribeWebhook(
|
||||
});
|
||||
|
||||
const start = performance.now();
|
||||
const res = await fetch(WITHINGS_NOTIFY_URL, {
|
||||
const res = await safeFetch(WITHINGS_NOTIFY_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
|
||||
@@ -37,6 +37,7 @@ import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import {
|
||||
collapseToTypeDayKeys,
|
||||
@@ -148,7 +149,7 @@ export async function fetchWithingsActivity(
|
||||
offset: String(offset),
|
||||
});
|
||||
const pageStart = performance.now();
|
||||
const res = await fetch(WITHINGS_MEASURE_URL, {
|
||||
const res = await safeFetch(WITHINGS_MEASURE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
|
||||
@@ -35,6 +35,7 @@ import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import {
|
||||
collapseToTypeDayKeys,
|
||||
@@ -118,7 +119,7 @@ export async function fetchWithingsSleep(
|
||||
});
|
||||
|
||||
const pageStart = performance.now();
|
||||
const res = await fetch(WITHINGS_SLEEP_URL, {
|
||||
const res = await safeFetch(WITHINGS_SLEEP_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
|
||||
Reference in new issue
Block a user