mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge w11/codesplits into release/v1.18.11
This commit is contained in:
commit
94ce9e5901
25 files changed
+3774
-3632
No files matched your search
@@ -0,0 +1,58 @@
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* v1.4.37 — silent browser-zone auto-seed for the timezone picker.
|
||||
*
|
||||
* Until v1.4.37 the picker carried a "Browser-Zeitzone übernehmen" /
|
||||
* "Use browser timezone" button so the user could overwrite the
|
||||
* Europe/Berlin seed with their detected zone on demand. The button
|
||||
* was visually noisy next to the picker on mobile and almost every
|
||||
* user wants the browser zone anyway, so the affordance retired and
|
||||
* the bootstrap effect seeds the form for them.
|
||||
*
|
||||
* Rules:
|
||||
*
|
||||
* - If the stored value is anything other than the Europe/Berlin
|
||||
* default, respect it. The user explicitly picked it.
|
||||
* - If the stored value is the Europe/Berlin default but the
|
||||
* browser actually IS in Berlin, leave it alone — the picker
|
||||
* stays on Berlin and the next save is a no-op.
|
||||
* - If the stored value is the default AND the browser reports a
|
||||
* non-Berlin zone, pre-fill the picker with the detected zone.
|
||||
* The form's existing submit handler persists the change on the
|
||||
* next save; no toast, no banner, no opt-in.
|
||||
*
|
||||
* The bootstrap deliberately runs inline during render (the strict
|
||||
* `react-hooks/set-state-in-effect` rule outlaws setState in an
|
||||
* effect for this hydration shape), so this helper has to stay
|
||||
* pure — no DOM access, no `useState`. The detected browser zone is
|
||||
* passed in by the caller via `detectBrowserTimezone()`.
|
||||
*/
|
||||
export function resolveInitialTimezone(
|
||||
storedTimezone: string | null | undefined,
|
||||
detectedBrowserTimezone: string,
|
||||
): string {
|
||||
const stored = storedTimezone || DEFAULT_TIMEZONE;
|
||||
const shouldAutoSeed =
|
||||
stored === DEFAULT_TIMEZONE &&
|
||||
detectedBrowserTimezone.length > 0 &&
|
||||
detectedBrowserTimezone !== DEFAULT_TIMEZONE;
|
||||
return shouldAutoSeed ? detectedBrowserTimezone : stored;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.4 — settings status hints store the i18n KEY (+ params), not
|
||||
* the translated string: a locale switch re-renders the hint in the
|
||||
* new language instead of freezing the old-language snapshot. Server-
|
||||
* provided error text (which has no key) rides `text` verbatim.
|
||||
*/
|
||||
export type StatusMessage =
|
||||
| { key: string; params?: Record<string, string | number> }
|
||||
| { text: string };
|
||||
|
||||
export function statusText(
|
||||
msg: StatusMessage,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
return "key" in msg ? t(msg.key, msg.params) : msg.text;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { ImageUp, Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { statusText, type StatusMessage } from "./account-section-utils";
|
||||
|
||||
// Accepted upload types + byte cap mirror the server contract in
|
||||
// `src/app/api/user/avatar/route.ts` so the client rejects an obvious
|
||||
// bad file before the round-trip. The server still re-validates by
|
||||
// magic-byte sniff — this is a UX shortcut, not the security boundary.
|
||||
const ACCEPTED_AVATAR_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
||||
const AVATAR_MAX_UPLOAD_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
function getAvatarInitials(name: string): string {
|
||||
return name
|
||||
.split(/[\s._-]+/)
|
||||
.slice(0, 2)
|
||||
.map((s) => s[0]?.toUpperCase() ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function AvatarSection() {
|
||||
const { t } = useTranslations();
|
||||
const { user, refetch } = useAuth();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [msg, setMsg] = useState<StatusMessage | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
const res = await apiFetchRaw("/api/user/avatar", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
if (res.status === 415)
|
||||
throw new Error(t("settings.avatar.invalidType"));
|
||||
if (res.status === 413) throw new Error(t("settings.avatar.tooLarge"));
|
||||
throw new Error(json.error || t("settings.avatar.error"));
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setMsgType("success");
|
||||
setMsg({ key: "settings.avatar.uploaded" });
|
||||
await refetch();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setMsgType("error");
|
||||
setMsg({ text: err.message });
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiFetchRaw("/api/user/avatar", { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
throw new Error(json.error || t("settings.avatar.error"));
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setMsgType("success");
|
||||
setMsg({ key: "settings.avatar.removed" });
|
||||
await refetch();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setMsgType("error");
|
||||
setMsg({ text: err.message });
|
||||
},
|
||||
});
|
||||
|
||||
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
// Reset so re-selecting the same file still fires `onChange`.
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
if (!ACCEPTED_AVATAR_TYPES.includes(file.type)) {
|
||||
setMsgType("error");
|
||||
setMsg({ key: "settings.avatar.invalidType" });
|
||||
return;
|
||||
}
|
||||
if (file.size > AVATAR_MAX_UPLOAD_BYTES) {
|
||||
setMsgType("error");
|
||||
setMsg({ key: "settings.avatar.tooLarge" });
|
||||
return;
|
||||
}
|
||||
upload.mutate(file);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
const avatarUrl = user.avatarUrl ?? null;
|
||||
const busy = upload.isPending || remove.isPending;
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={ImageUp}
|
||||
title={t("settings.avatar.title")}
|
||||
className="mb-4"
|
||||
/>
|
||||
<p className="text-muted-foreground mb-4 pl-7 text-sm">
|
||||
{t("settings.avatar.description")}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 pl-7">
|
||||
<Avatar className="size-16">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
|
||||
<AvatarFallback className="text-base">
|
||||
{getAvatarInitials(user.username)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleFile}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={busy}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{upload.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<ImageUp className="h-4 w-4" />
|
||||
)}
|
||||
{avatarUrl
|
||||
? t("settings.avatar.replace")
|
||||
: t("settings.avatar.upload")}
|
||||
</Button>
|
||||
{avatarUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive min-h-11 sm:min-h-9"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
remove.mutate();
|
||||
}}
|
||||
>
|
||||
{remove.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.avatar.remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-3 text-xs">
|
||||
{t("settings.avatar.hint")}
|
||||
</p>
|
||||
{msg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`mt-3 text-sm ${
|
||||
msgType === "success" ? "text-success" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{statusText(msg, t)}
|
||||
</p>
|
||||
)}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
+12
-517
@@ -1,32 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ImageUp,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Save,
|
||||
Shield,
|
||||
Trash2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { KeyRound, Loader2, Save, Shield, User } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -42,11 +19,9 @@ import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { AboutMeSection } from "@/components/settings/about-me-section";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
@@ -54,78 +29,17 @@ import { TimezonePicker } from "@/components/settings/timezone-picker";
|
||||
import { TimeFormatSelect } from "@/components/settings/time-format-select";
|
||||
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
|
||||
import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card";
|
||||
import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { detectBrowserTimezone } from "@/lib/tz/format";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
resolveInitialTimezone,
|
||||
statusText,
|
||||
type StatusMessage,
|
||||
} from "./account-section-utils";
|
||||
import { PasskeyListSection } from "./passkey-list-section";
|
||||
import { AvatarSection } from "./avatar-section";
|
||||
|
||||
interface PasskeyInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
credentialDeviceType: string;
|
||||
credentialBackedUp: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// v1.4.27 MB7 / CF-52 — the in-file `NATIVE_SELECT_CLASS` constant
|
||||
// retired; the shared `<NativeSelect>` primitive owns the visual
|
||||
// contract now. Existing `<select className={NATIVE_SELECT_CLASS}>`
|
||||
// call sites in this file swapped to `<NativeSelect>` below.
|
||||
|
||||
/**
|
||||
* v1.4.37 — silent browser-zone auto-seed for the timezone picker.
|
||||
*
|
||||
* Until v1.4.37 the picker carried a "Browser-Zeitzone übernehmen" /
|
||||
* "Use browser timezone" button so the user could overwrite the
|
||||
* Europe/Berlin seed with their detected zone on demand. The button
|
||||
* was visually noisy next to the picker on mobile and almost every
|
||||
* user wants the browser zone anyway, so the affordance retired and
|
||||
* the bootstrap effect seeds the form for them.
|
||||
*
|
||||
* Rules:
|
||||
*
|
||||
* - If the stored value is anything other than the Europe/Berlin
|
||||
* default, respect it. The user explicitly picked it.
|
||||
* - If the stored value is the Europe/Berlin default but the
|
||||
* browser actually IS in Berlin, leave it alone — the picker
|
||||
* stays on Berlin and the next save is a no-op.
|
||||
* - If the stored value is the default AND the browser reports a
|
||||
* non-Berlin zone, pre-fill the picker with the detected zone.
|
||||
* The form's existing submit handler persists the change on the
|
||||
* next save; no toast, no banner, no opt-in.
|
||||
*
|
||||
* The bootstrap deliberately runs inline during render (the strict
|
||||
* `react-hooks/set-state-in-effect` rule outlaws setState in an
|
||||
* effect for this hydration shape), so this helper has to stay
|
||||
* pure — no DOM access, no `useState`. The detected browser zone is
|
||||
* passed in by the caller via `detectBrowserTimezone()`.
|
||||
*/
|
||||
export function resolveInitialTimezone(
|
||||
storedTimezone: string | null | undefined,
|
||||
detectedBrowserTimezone: string,
|
||||
): string {
|
||||
const stored = storedTimezone || DEFAULT_TIMEZONE;
|
||||
const shouldAutoSeed =
|
||||
stored === DEFAULT_TIMEZONE &&
|
||||
detectedBrowserTimezone.length > 0 &&
|
||||
detectedBrowserTimezone !== DEFAULT_TIMEZONE;
|
||||
return shouldAutoSeed ? detectedBrowserTimezone : stored;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.4 — settings status hints store the i18n KEY (+ params), not
|
||||
* the translated string: a locale switch re-renders the hint in the
|
||||
* new language instead of freezing the old-language snapshot. Server-
|
||||
* provided error text (which has no key) rides `text` verbatim.
|
||||
*/
|
||||
type StatusMessage =
|
||||
| { key: string; params?: Record<string, string | number> }
|
||||
| { text: string };
|
||||
|
||||
function statusText(
|
||||
msg: StatusMessage,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
return "key" in msg ? t(msg.key, msg.params) : msg.text;
|
||||
}
|
||||
export { resolveInitialTimezone } from "./account-section-utils";
|
||||
|
||||
export function AccountSection() {
|
||||
const { t, locale, setLocale, pendingLocale } = useTranslations();
|
||||
@@ -764,422 +678,3 @@ export function AccountSection() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [deleteMsg, setDeleteMsg] = useState<string | null>(null);
|
||||
|
||||
const { data: passkeys } = useQuery({
|
||||
queryKey: queryKeys.passkeys(),
|
||||
queryFn: async () => {
|
||||
return apiGet<PasskeyInfo[]>("/api/auth/passkeys");
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const deletePasskey = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiDelete(`/api/auth/passkeys/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
|
||||
setDeleteMsg(null);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setDeleteMsg(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const DEVICE_TYPE_LABELS: Record<string, string> = {
|
||||
singleDevice: t("settings.singleDevice"),
|
||||
multiDevice: t("settings.multiDevice"),
|
||||
};
|
||||
|
||||
if (!passkeys || passkeys.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{t("settings.passkeys")}</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.noPasskeys")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("settings.registeredPasskeys")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.passkeysDescription")}
|
||||
</p>
|
||||
{/* Phase A5 / B-mobile HIGH: the passkey table previously
|
||||
rendered with `min-w-[620px]` inside `overflow-x-auto`,
|
||||
which on a 393px viewport hid the right-most column —
|
||||
including the destructive delete action. Render the desktop
|
||||
table only at `≥ md`, and at `< md` paint a card-list where
|
||||
every passkey's name, device type, backup status, created
|
||||
date, and delete action are all visible without scrolling. */}
|
||||
{/* v1.4.33 — desktop table flips to card list at `lg` instead
|
||||
of `md` so iPad portrait (768 px = exactly the `md`
|
||||
inflection) lands on the card layout. The table needs
|
||||
~620 px of column width to read; below that it scrolls
|
||||
horizontally and the destructive delete column is the one
|
||||
that disappears. */}
|
||||
<div className="border-border mt-3 hidden overflow-x-auto rounded-lg border lg:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyName")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyDevice")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyBackup")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("settings.passkeyActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{passkeys.map((pk, idx) => (
|
||||
<tr key={pk.id} className={idx % 2 === 0 ? "bg-muted/20" : ""}>
|
||||
<td className="px-3 py-2 font-medium">{pk.name}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
|
||||
pk.credentialDeviceType}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
<Badge
|
||||
variant={pk.credentialBackedUp ? "secondary" : "outline"}
|
||||
className="text-[11px]"
|
||||
>
|
||||
{pk.credentialBackedUp
|
||||
? t("settings.backedUp")
|
||||
: t("common.no")}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{formatDate(pk.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive h-8 w-8"
|
||||
disabled={deletePasskey.isPending}
|
||||
aria-label={t("settings.deletePasskey")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.deletePasskey")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.deletePasskeyDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deletePasskey.isPending}
|
||||
aria-busy={deletePasskey.isPending || undefined}
|
||||
onClick={() => deletePasskey.mutate(pk.id)}
|
||||
>
|
||||
{deletePasskey.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile + tablet card list — < lg only. Each passkey gets
|
||||
its own card so the delete action stays visible and
|
||||
tap-targetable (44 px) without the user having to
|
||||
horizontally scroll through a wide table. v1.4.33 bumped
|
||||
the breakpoint from `md` to `lg` so iPad portrait (768 px)
|
||||
stays on the card layout instead of flipping between
|
||||
layouts on rotation. */}
|
||||
<ul
|
||||
className="mt-3 space-y-2 lg:hidden"
|
||||
data-testid="passkeys-mobile-list"
|
||||
>
|
||||
{passkeys.map((pk) => (
|
||||
<li
|
||||
key={pk.id}
|
||||
className="bg-card border-border rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{pk.name}</p>
|
||||
{/* v1.4.27 MB7 / CF-75 — promote the device-type from
|
||||
plain text to an outline Badge so the mobile card
|
||||
list reads consistent with the desktop table's
|
||||
"Single-device / Multi-device" column. Sits on the
|
||||
same chip row as the backup status and date so all
|
||||
metadata reads as a single horizontal stride. */}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
|
||||
pk.credentialDeviceType}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={pk.credentialBackedUp ? "secondary" : "outline"}
|
||||
className="text-[11px]"
|
||||
>
|
||||
{pk.credentialBackedUp
|
||||
? t("settings.backedUp")
|
||||
: t("common.no")}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
{formatDate(pk.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive min-h-11 min-w-11"
|
||||
disabled={deletePasskey.isPending}
|
||||
aria-label={t("settings.deletePasskey")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.deletePasskey")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.deletePasskeyDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deletePasskey.isPending}
|
||||
aria-busy={deletePasskey.isPending || undefined}
|
||||
onClick={() => deletePasskey.mutate(pk.id)}
|
||||
>
|
||||
{deletePasskey.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{deleteMsg && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive mt-2 flex items-center gap-2 text-sm"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{deleteMsg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Accepted upload types + byte cap mirror the server contract in
|
||||
// `src/app/api/user/avatar/route.ts` so the client rejects an obvious
|
||||
// bad file before the round-trip. The server still re-validates by
|
||||
// magic-byte sniff — this is a UX shortcut, not the security boundary.
|
||||
const ACCEPTED_AVATAR_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
||||
const AVATAR_MAX_UPLOAD_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
function getAvatarInitials(name: string): string {
|
||||
return name
|
||||
.split(/[\s._-]+/)
|
||||
.slice(0, 2)
|
||||
.map((s) => s[0]?.toUpperCase() ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
function AvatarSection() {
|
||||
const { t } = useTranslations();
|
||||
const { user, refetch } = useAuth();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [msg, setMsg] = useState<StatusMessage | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
const res = await apiFetchRaw("/api/user/avatar", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
if (res.status === 415)
|
||||
throw new Error(t("settings.avatar.invalidType"));
|
||||
if (res.status === 413) throw new Error(t("settings.avatar.tooLarge"));
|
||||
throw new Error(json.error || t("settings.avatar.error"));
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setMsgType("success");
|
||||
setMsg({ key: "settings.avatar.uploaded" });
|
||||
await refetch();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setMsgType("error");
|
||||
setMsg({ text: err.message });
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiFetchRaw("/api/user/avatar", { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
throw new Error(json.error || t("settings.avatar.error"));
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setMsgType("success");
|
||||
setMsg({ key: "settings.avatar.removed" });
|
||||
await refetch();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setMsgType("error");
|
||||
setMsg({ text: err.message });
|
||||
},
|
||||
});
|
||||
|
||||
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
// Reset so re-selecting the same file still fires `onChange`.
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
if (!ACCEPTED_AVATAR_TYPES.includes(file.type)) {
|
||||
setMsgType("error");
|
||||
setMsg({ key: "settings.avatar.invalidType" });
|
||||
return;
|
||||
}
|
||||
if (file.size > AVATAR_MAX_UPLOAD_BYTES) {
|
||||
setMsgType("error");
|
||||
setMsg({ key: "settings.avatar.tooLarge" });
|
||||
return;
|
||||
}
|
||||
upload.mutate(file);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
const avatarUrl = user.avatarUrl ?? null;
|
||||
const busy = upload.isPending || remove.isPending;
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={ImageUp}
|
||||
title={t("settings.avatar.title")}
|
||||
className="mb-4"
|
||||
/>
|
||||
<p className="text-muted-foreground mb-4 pl-7 text-sm">
|
||||
{t("settings.avatar.description")}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 pl-7">
|
||||
<Avatar className="size-16">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
|
||||
<AvatarFallback className="text-base">
|
||||
{getAvatarInitials(user.username)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleFile}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={busy}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{upload.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<ImageUp className="h-4 w-4" />
|
||||
)}
|
||||
{avatarUrl
|
||||
? t("settings.avatar.replace")
|
||||
: t("settings.avatar.upload")}
|
||||
</Button>
|
||||
{avatarUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive min-h-11 sm:min-h-9"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
remove.mutate();
|
||||
}}
|
||||
>
|
||||
{remove.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.avatar.remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-3 text-xs">
|
||||
{t("settings.avatar.hint")}
|
||||
</p>
|
||||
{msg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`mt-3 text-sm ${
|
||||
msgType === "success" ? "text-success" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{statusText(msg, t)}
|
||||
</p>
|
||||
)}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
interface PasskeyInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
credentialDeviceType: string;
|
||||
credentialBackedUp: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function PasskeyListSection({
|
||||
isAuthenticated,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [deleteMsg, setDeleteMsg] = useState<string | null>(null);
|
||||
|
||||
const { data: passkeys } = useQuery({
|
||||
queryKey: queryKeys.passkeys(),
|
||||
queryFn: async () => {
|
||||
return apiGet<PasskeyInfo[]>("/api/auth/passkeys");
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const deletePasskey = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiDelete(`/api/auth/passkeys/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
|
||||
setDeleteMsg(null);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setDeleteMsg(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const DEVICE_TYPE_LABELS: Record<string, string> = {
|
||||
singleDevice: t("settings.singleDevice"),
|
||||
multiDevice: t("settings.multiDevice"),
|
||||
};
|
||||
|
||||
if (!passkeys || passkeys.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{t("settings.passkeys")}</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.noPasskeys")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("settings.registeredPasskeys")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.passkeysDescription")}
|
||||
</p>
|
||||
{/* Phase A5 / B-mobile HIGH: the passkey table previously
|
||||
rendered with `min-w-[620px]` inside `overflow-x-auto`,
|
||||
which on a 393px viewport hid the right-most column —
|
||||
including the destructive delete action. Render the desktop
|
||||
table only at `≥ md`, and at `< md` paint a card-list where
|
||||
every passkey's name, device type, backup status, created
|
||||
date, and delete action are all visible without scrolling. */}
|
||||
{/* v1.4.33 — desktop table flips to card list at `lg` instead
|
||||
of `md` so iPad portrait (768 px = exactly the `md`
|
||||
inflection) lands on the card layout. The table needs
|
||||
~620 px of column width to read; below that it scrolls
|
||||
horizontally and the destructive delete column is the one
|
||||
that disappears. */}
|
||||
<div className="border-border mt-3 hidden overflow-x-auto rounded-lg border lg:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyName")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyDevice")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyBackup")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("settings.passkeyActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{passkeys.map((pk, idx) => (
|
||||
<tr key={pk.id} className={idx % 2 === 0 ? "bg-muted/20" : ""}>
|
||||
<td className="px-3 py-2 font-medium">{pk.name}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
|
||||
pk.credentialDeviceType}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
<Badge
|
||||
variant={pk.credentialBackedUp ? "secondary" : "outline"}
|
||||
className="text-[11px]"
|
||||
>
|
||||
{pk.credentialBackedUp
|
||||
? t("settings.backedUp")
|
||||
: t("common.no")}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{formatDate(pk.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive h-8 w-8"
|
||||
disabled={deletePasskey.isPending}
|
||||
aria-label={t("settings.deletePasskey")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.deletePasskey")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.deletePasskeyDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deletePasskey.isPending}
|
||||
aria-busy={deletePasskey.isPending || undefined}
|
||||
onClick={() => deletePasskey.mutate(pk.id)}
|
||||
>
|
||||
{deletePasskey.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile + tablet card list — < lg only. Each passkey gets
|
||||
its own card so the delete action stays visible and
|
||||
tap-targetable (44 px) without the user having to
|
||||
horizontally scroll through a wide table. v1.4.33 bumped
|
||||
the breakpoint from `md` to `lg` so iPad portrait (768 px)
|
||||
stays on the card layout instead of flipping between
|
||||
layouts on rotation. */}
|
||||
<ul
|
||||
className="mt-3 space-y-2 lg:hidden"
|
||||
data-testid="passkeys-mobile-list"
|
||||
>
|
||||
{passkeys.map((pk) => (
|
||||
<li
|
||||
key={pk.id}
|
||||
className="bg-card border-border rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{pk.name}</p>
|
||||
{/* v1.4.27 MB7 / CF-75 — promote the device-type from
|
||||
plain text to an outline Badge so the mobile card
|
||||
list reads consistent with the desktop table's
|
||||
"Single-device / Multi-device" column. Sits on the
|
||||
same chip row as the backup status and date so all
|
||||
metadata reads as a single horizontal stride. */}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
|
||||
pk.credentialDeviceType}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={pk.credentialBackedUp ? "secondary" : "outline"}
|
||||
className="text-[11px]"
|
||||
>
|
||||
{pk.credentialBackedUp
|
||||
? t("settings.backedUp")
|
||||
: t("common.no")}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
{formatDate(pk.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive min-h-11 min-w-11"
|
||||
disabled={deletePasskey.isPending}
|
||||
aria-label={t("settings.deletePasskey")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.deletePasskey")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.deletePasskeyDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deletePasskey.isPending}
|
||||
aria-busy={deletePasskey.isPending || undefined}
|
||||
onClick={() => deletePasskey.mutate(pk.id)}
|
||||
>
|
||||
{deletePasskey.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{deleteMsg && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive mt-2 flex items-center gap-2 text-sm"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{deleteMsg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,920 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.15.7 — Settings → Export & Import → Import area.
|
||||
*
|
||||
* Web UI for the two existing import backends (issue #281: the routes
|
||||
* shipped without a surface). Two clearly-separated controls:
|
||||
*
|
||||
* 1. Apple Health `export.zip` — multipart POST to
|
||||
* `/api/import/apple-health-export` (field `file`), then poll
|
||||
* `GET /api/import/apple-health-export/[jobId]/status` on a
|
||||
* `refetchInterval` while the job runs, stopping on a terminal
|
||||
* state (`done` / `failed`). The upload streams server-side so a
|
||||
* multi-GB archive never buffers in the browser beyond the POST.
|
||||
* Idempotent on the file's SHA-256 — re-uploading the same archive
|
||||
* merges rather than duplicating.
|
||||
*
|
||||
* 2. Generic JSON import — upload a `.json` file OR paste JSON,
|
||||
* validated client-side for parseability, POSTed to `/api/import`.
|
||||
* A "Download example" button mints a small valid example as a Blob
|
||||
* (no static asset). Returns `{ measurements, moodEntries, skipped }`
|
||||
* counts.
|
||||
*
|
||||
* Both controls surface rate-limit (429) and error states cleanly and
|
||||
* announce progress to assistive tech via `aria-live` regions.
|
||||
*/
|
||||
|
||||
import { useCallback, useId, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
FileJson,
|
||||
FileSpreadsheet,
|
||||
Loader2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { CSV_EXAMPLE_COLUMNS } from "@/lib/import/csv-measurements";
|
||||
|
||||
/**
|
||||
* Upper bound for the paste textareas, mirroring the 16 MB server-side body
|
||||
* ceiling on `/api/import` and `/api/import/csv`. Caps an accidental over-paste
|
||||
* before it ever reaches the route and feeds the live character counter.
|
||||
*/
|
||||
const MAX_PASTE_CHARS = 16 * 1024 * 1024;
|
||||
|
||||
// ─────────────────────────── Section wrapper ───────────────────────────
|
||||
|
||||
export function ImportPanel() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-import-title"
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h2
|
||||
id="settings-section-import-title"
|
||||
className="text-base font-semibold tracking-tight"
|
||||
>
|
||||
{t("settings.sections.export.import.heading")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.export.import.description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<AppleHealthImportCard />
|
||||
<JsonImportCard />
|
||||
<CsvImportCard />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────── Card shell ───────────────────────────
|
||||
|
||||
interface ImportCardShellProps {
|
||||
testId: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function ImportCardShell({
|
||||
testId,
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: ImportCardShellProps) {
|
||||
return (
|
||||
<SettingsCard data-testid={testId} className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon
|
||||
className="text-muted-foreground h-5 w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 className="text-base font-semibold">{title}</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">{description}</p>
|
||||
<div className="mt-3 flex flex-1 flex-col gap-3">{children}</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────── Apple Health import ───────────────────────────
|
||||
|
||||
/** Terminal states the status poll stops on. */
|
||||
const TERMINAL_STATES: readonly string[] = ["done", "failed"];
|
||||
|
||||
interface JobStatus {
|
||||
jobId: string;
|
||||
status: string;
|
||||
progress: {
|
||||
currentPhase?: string;
|
||||
recordsRead?: number;
|
||||
rowsUpserted?: number;
|
||||
percent?: number | null;
|
||||
} | null;
|
||||
result: {
|
||||
totals?: { recordsRead?: number; rowsUpserted?: number };
|
||||
clinical?: { skipped?: number };
|
||||
} | null;
|
||||
failureReason: string | null;
|
||||
}
|
||||
|
||||
function AppleHealthImportCard() {
|
||||
const { t } = useTranslations();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const dropDescId = useId();
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: queryKeys.importJobStatus(jobId ?? "none"),
|
||||
enabled: jobId !== null,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as JobStatus | undefined;
|
||||
if (data && TERMINAL_STATES.includes(data.status)) return false;
|
||||
return 2000;
|
||||
},
|
||||
queryFn: async (): Promise<JobStatus> => {
|
||||
return apiGet<JobStatus>(
|
||||
`/api/import/apple-health-export/${jobId}/status`,
|
||||
{ credentials: "include" },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = useCallback(
|
||||
async (file: File) => {
|
||||
setUploadError(null);
|
||||
setUploading(true);
|
||||
setJobId(null);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await apiFetchRaw("/api/import/apple-health-export", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: form,
|
||||
});
|
||||
if (res.status === 429) {
|
||||
setUploadError(
|
||||
t("settings.sections.export.import.appleHealth.rateLimited"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (res.status === 413) {
|
||||
setUploadError(
|
||||
t("settings.sections.export.import.appleHealth.tooLarge"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
setUploadError(
|
||||
t("settings.sections.export.import.appleHealth.uploadFailed"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const body = (await res.json()).data as {
|
||||
jobId: string;
|
||||
status: string;
|
||||
};
|
||||
if (!body?.jobId) {
|
||||
setUploadError(
|
||||
t("settings.sections.export.import.appleHealth.uploadFailed"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setJobId(body.jobId);
|
||||
} catch {
|
||||
setUploadError(
|
||||
t("settings.sections.export.import.appleHealth.uploadFailed"),
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void upload(file);
|
||||
// Reset so picking the same file twice re-fires the change event.
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
function onDrop(e: React.DragEvent<HTMLDivElement>) {
|
||||
e.preventDefault();
|
||||
setDragActive(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) void upload(file);
|
||||
}
|
||||
|
||||
const status = statusQuery.data ?? null;
|
||||
const isRunning = status !== null && !TERMINAL_STATES.includes(status.status);
|
||||
const isDone = status?.status === "done";
|
||||
const isFailed = status?.status === "failed";
|
||||
const busy = uploading || isRunning;
|
||||
const percent = status?.progress?.percent ?? null;
|
||||
|
||||
return (
|
||||
<ImportCardShell
|
||||
testId="import-card-apple-health"
|
||||
icon={Upload}
|
||||
title={t("settings.sections.export.import.appleHealth.title")}
|
||||
description={t("settings.sections.export.import.appleHealth.description")}
|
||||
>
|
||||
{/* Keyboard-operable drop area. The hidden file input does the
|
||||
actual file selection; the div forwards Enter/Space to it. */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-describedby={dropDescId}
|
||||
aria-disabled={busy}
|
||||
onClick={() => !busy && fileInputRef.current?.click()}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === "Enter" || e.key === " ") && !busy) {
|
||||
e.preventDefault();
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (!busy) setDragActive(true);
|
||||
}}
|
||||
onDragLeave={() => setDragActive(false)}
|
||||
onDrop={(e) => !busy && onDrop(e)}
|
||||
className={cn(
|
||||
"border-border bg-muted/20 hover:bg-muted/40 focus-visible:ring-ring/50 flex min-h-24 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border border-dashed p-4 text-center transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
dragActive && "border-primary bg-primary/5",
|
||||
busy && "pointer-events-none opacity-60",
|
||||
)}
|
||||
>
|
||||
<Upload className="text-muted-foreground h-5 w-5" aria-hidden="true" />
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{t("settings.sections.export.import.appleHealth.dropLabel")}
|
||||
</span>
|
||||
<span id={dropDescId} className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.export.import.appleHealth.dropHint")}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
className="sr-only"
|
||||
aria-label={t(
|
||||
"settings.sections.export.import.appleHealth.fileInputLabel",
|
||||
)}
|
||||
onChange={onFileChange}
|
||||
/>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.export.import.appleHealth.idempotencyNote")}
|
||||
</p>
|
||||
|
||||
{/* Live progress / outcome — announced to assistive tech. */}
|
||||
<div aria-live="polite" className="space-y-2">
|
||||
{uploading && (
|
||||
<p className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
{t("settings.sections.export.import.appleHealth.uploading")}
|
||||
</p>
|
||||
)}
|
||||
{isRunning && (
|
||||
<div
|
||||
data-testid="import-apple-health-progress"
|
||||
className="space-y-1.5"
|
||||
>
|
||||
<p className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
{t(
|
||||
`settings.sections.export.import.appleHealth.phase.${status?.status ?? "queued"}`,
|
||||
)}
|
||||
</p>
|
||||
{typeof percent === "number" && <Progress value={percent} />}
|
||||
{typeof status?.progress?.rowsUpserted === "number" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.export.import.appleHealth.rowsImported", {
|
||||
count: status.progress.rowsUpserted,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isDone && (
|
||||
<div
|
||||
data-testid="import-apple-health-result"
|
||||
className="text-foreground flex items-start gap-2 text-xs"
|
||||
>
|
||||
<CheckCircle2
|
||||
className="text-success mt-0.5 h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{t("settings.sections.export.import.appleHealth.doneSummary", {
|
||||
imported: status?.result?.totals?.rowsUpserted ?? 0,
|
||||
read: status?.result?.totals?.recordsRead ?? 0,
|
||||
skipped: status?.result?.clinical?.skipped ?? 0,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(isFailed || uploadError) && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive flex items-start gap-2 text-sm"
|
||||
>
|
||||
<AlertCircle
|
||||
className="mt-0.5 h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{uploadError ??
|
||||
status?.failureReason ??
|
||||
t("settings.sections.export.import.appleHealth.failed")}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={busy}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
data-testid="import-action-apple-health"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.sections.export.import.appleHealth.choose")}
|
||||
</Button>
|
||||
</div>
|
||||
</ImportCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────── Generic JSON import ───────────────────────────
|
||||
|
||||
/**
|
||||
* Small valid example payload, minted as a downloadable Blob by the
|
||||
* "Download example" button and used by the docs. Exported so a test can
|
||||
* assert it stays a valid import body — the button and the route schema
|
||||
* must never drift.
|
||||
*/
|
||||
export const EXAMPLE_IMPORT = {
|
||||
measurements: [
|
||||
{
|
||||
type: "WEIGHT",
|
||||
value: 80.5,
|
||||
unit: "kg",
|
||||
measuredAt: "2026-05-01T08:00:00.000Z",
|
||||
source: "manual",
|
||||
notes: "morning",
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value: 120,
|
||||
unit: "mmHg",
|
||||
measuredAt: "2026-05-01T08:05:00.000Z",
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
value: 80,
|
||||
unit: "mmHg",
|
||||
measuredAt: "2026-05-01T08:05:00.000Z",
|
||||
},
|
||||
],
|
||||
moodEntries: [
|
||||
{
|
||||
date: "2026-05-01",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
tags: "work,exercise",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface JsonImportResult {
|
||||
measurements: number;
|
||||
moodEntries: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side parse guard for the JSON-import textarea. Returns the
|
||||
* parsed value when the text is valid JSON, otherwise a failure marker —
|
||||
* we never POST an unparseable body. Exported for unit testing so the
|
||||
* guard is covered without a browser.
|
||||
*/
|
||||
export function parseImportJson(
|
||||
text: string,
|
||||
): { ok: true; value: unknown } | { ok: false } {
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(text) };
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
function JsonImportCard() {
|
||||
const { t } = useTranslations();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [text, setText] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<JsonImportResult | null>(null);
|
||||
const textareaId = useId();
|
||||
|
||||
async function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const content = await file.text();
|
||||
setText(content);
|
||||
} catch {
|
||||
setError(t("settings.sections.export.import.json.readFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
function downloadExample() {
|
||||
const blob = new Blob([JSON.stringify(EXAMPLE_IMPORT, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "healthlog-import-example.json";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
setError(null);
|
||||
setResult(null);
|
||||
const parsed = parseImportJson(text);
|
||||
if (!parsed.ok) {
|
||||
setError(t("settings.sections.export.import.json.invalidJson"));
|
||||
return;
|
||||
}
|
||||
const payload = parsed.value;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 429) {
|
||||
setError(t("settings.sections.export.import.json.rateLimited"));
|
||||
return;
|
||||
}
|
||||
if (res.status === 422) {
|
||||
const body = await res.json().catch(() => null);
|
||||
setError(
|
||||
body?.error ??
|
||||
t("settings.sections.export.import.json.invalidPayload"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
setError(t("settings.sections.export.import.json.failed"));
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()).data as JsonImportResult;
|
||||
setResult({
|
||||
measurements: data?.measurements ?? 0,
|
||||
moodEntries: data?.moodEntries ?? 0,
|
||||
skipped: data?.skipped ?? 0,
|
||||
});
|
||||
} catch {
|
||||
setError(t("settings.sections.export.import.json.failed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ImportCardShell
|
||||
testId="import-card-json"
|
||||
icon={FileJson}
|
||||
title={t("settings.sections.export.import.json.title")}
|
||||
description={t("settings.sections.export.import.json.description")}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={textareaId} className="text-xs">
|
||||
{t("settings.sections.export.import.json.pasteLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={textareaId}
|
||||
data-testid="import-json-textarea"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={5}
|
||||
maxLength={MAX_PASTE_CHARS}
|
||||
spellCheck={false}
|
||||
placeholder='{"measurements":[…],"moodEntries":[…]}'
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground text-right text-xs tabular-nums">
|
||||
{t("settings.sections.export.import.charCount", {
|
||||
used: text.length,
|
||||
max: MAX_PASTE_CHARS,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
className="sr-only"
|
||||
aria-label={t("settings.sections.export.import.json.fileInputLabel")}
|
||||
onChange={onFileChange}
|
||||
/>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.export.import.json.schemaHint")}{" "}
|
||||
<Link
|
||||
href="/docs/integrations/data-import"
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
{t("settings.sections.export.import.json.docsLink")}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div aria-live="polite" className="space-y-2">
|
||||
{result && (
|
||||
<p
|
||||
data-testid="import-json-result"
|
||||
className="text-foreground flex items-start gap-2 text-xs"
|
||||
>
|
||||
<CheckCircle2
|
||||
className="text-success mt-0.5 h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{t("settings.sections.export.import.json.resultSummary", {
|
||||
measurements: result.measurements,
|
||||
moods: result.moodEntries,
|
||||
skipped: result.skipped,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive flex items-start gap-2 text-sm"
|
||||
>
|
||||
<AlertCircle
|
||||
className="mt-0.5 h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{error}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
data-testid="import-json-choose-file"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{t("settings.sections.export.import.json.uploadFile")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={downloadExample}
|
||||
data-testid="import-json-download-example"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{t("settings.sections.export.import.json.downloadExample")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={busy || text.trim().length === 0}
|
||||
onClick={handleImport}
|
||||
data-testid="import-action-json"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<FileJson className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.sections.export.import.json.import")}
|
||||
</Button>
|
||||
</div>
|
||||
</ImportCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────── CSV import ───────────────────────────
|
||||
|
||||
/**
|
||||
* Downloadable CSV example. Header is order-independent server-side, but the
|
||||
* example pins the documented order so the docs + the route schema never
|
||||
* drift. Exported so a test can assert it stays a valid header.
|
||||
*/
|
||||
export const EXAMPLE_CSV = [
|
||||
CSV_EXAMPLE_COLUMNS.join(","),
|
||||
"WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,morning,",
|
||||
"BLOOD_GLUCOSE,5.3,mmol/L,2026-05-01T08:05:00+02:00,FASTING,,meter-001",
|
||||
"BLOOD_PRESSURE_SYS,120,mmHg,2026-05-01T08:05:00+02:00,,,",
|
||||
].join("\n");
|
||||
|
||||
interface CsvRowResult {
|
||||
line: number;
|
||||
status: "inserted" | "updated" | "skipped";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface CsvImportResult {
|
||||
inserted: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
dryRun: boolean;
|
||||
rows: CsvRowResult[];
|
||||
}
|
||||
|
||||
function CsvImportCard() {
|
||||
const { t } = useTranslations();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [text, setText] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<CsvImportResult | null>(null);
|
||||
const textareaId = useId();
|
||||
|
||||
async function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const content = await file.text();
|
||||
setText(content);
|
||||
} catch {
|
||||
setError(t("settings.sections.export.import.csv.readFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
function downloadExample() {
|
||||
const blob = new Blob([EXAMPLE_CSV], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "healthlog-import-example.csv";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function send(dryRun: boolean) {
|
||||
setError(null);
|
||||
setResult(null);
|
||||
if (text.trim().length === 0) {
|
||||
setError(t("settings.sections.export.import.csv.empty"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiFetchRaw(
|
||||
`/api/import/csv${dryRun ? "?dryRun=1" : ""}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/csv" },
|
||||
credentials: "include",
|
||||
body: text,
|
||||
},
|
||||
);
|
||||
if (res.status === 429) {
|
||||
setError(t("settings.sections.export.import.csv.rateLimited"));
|
||||
return;
|
||||
}
|
||||
if (res.status === 413) {
|
||||
setError(t("settings.sections.export.import.csv.tooLarge"));
|
||||
return;
|
||||
}
|
||||
if (res.status === 422) {
|
||||
const body = await res.json().catch(() => null);
|
||||
setError(
|
||||
body?.error ??
|
||||
t("settings.sections.export.import.csv.invalidPayload"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
setError(t("settings.sections.export.import.csv.failed"));
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()).data as CsvImportResult;
|
||||
setResult(data);
|
||||
} catch {
|
||||
setError(t("settings.sections.export.import.csv.failed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const errorRows = result?.rows.filter((r) => r.status === "skipped") ?? [];
|
||||
|
||||
return (
|
||||
<ImportCardShell
|
||||
testId="import-card-csv"
|
||||
icon={FileSpreadsheet}
|
||||
title={t("settings.sections.export.import.csv.title")}
|
||||
description={t("settings.sections.export.import.csv.description")}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={textareaId} className="text-xs">
|
||||
{t("settings.sections.export.import.csv.pasteLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={textareaId}
|
||||
data-testid="import-csv-textarea"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={5}
|
||||
maxLength={MAX_PASTE_CHARS}
|
||||
spellCheck={false}
|
||||
placeholder="type,value,unit,measuredAt,…"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground text-right text-xs tabular-nums">
|
||||
{t("settings.sections.export.import.charCount", {
|
||||
used: text.length,
|
||||
max: MAX_PASTE_CHARS,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="sr-only"
|
||||
aria-label={t("settings.sections.export.import.csv.fileInputLabel")}
|
||||
onChange={onFileChange}
|
||||
/>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.export.import.csv.schemaHint")}{" "}
|
||||
<Link
|
||||
href="/docs/integrations/data-import"
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
{t("settings.sections.export.import.csv.docsLink")}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div aria-live="polite" className="space-y-2">
|
||||
{result && (
|
||||
<div data-testid="import-csv-result" className="space-y-1.5">
|
||||
<p className="text-foreground flex items-start gap-2 text-xs">
|
||||
<CheckCircle2
|
||||
className="text-success mt-0.5 h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{result.dryRun
|
||||
? t("settings.sections.export.import.csv.previewSummary", {
|
||||
inserted: result.inserted,
|
||||
skipped: result.skipped,
|
||||
})
|
||||
: t("settings.sections.export.import.csv.resultSummary", {
|
||||
inserted: result.inserted,
|
||||
updated: result.updated,
|
||||
skipped: result.skipped,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
{errorRows.length > 0 && (
|
||||
<ul className="text-muted-foreground max-h-32 space-y-0.5 overflow-auto text-xs">
|
||||
{errorRows.slice(0, 50).map((r) => (
|
||||
<li key={r.line}>
|
||||
{t("settings.sections.export.import.csv.rowError", {
|
||||
line: r.line,
|
||||
reason: r.reason ?? "skipped",
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive flex items-start gap-2 text-sm"
|
||||
>
|
||||
<AlertCircle
|
||||
className="mt-0.5 h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{error}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
data-testid="import-csv-choose-file"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{t("settings.sections.export.import.csv.uploadFile")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={downloadExample}
|
||||
data-testid="import-csv-download-example"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{t("settings.sections.export.import.csv.downloadExample")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={busy || text.trim().length === 0}
|
||||
onClick={() => void send(true)}
|
||||
data-testid="import-csv-preview"
|
||||
>
|
||||
{busy && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.sections.export.import.csv.preview")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={busy || text.trim().length === 0}
|
||||
onClick={() => void send(false)}
|
||||
data-testid="import-action-csv"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<FileSpreadsheet className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.sections.export.import.csv.import")}
|
||||
</Button>
|
||||
</div>
|
||||
</ImportCardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useId, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertCircle, CheckCircle2, Loader2, Upload } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { ImportCardShell } from "./import-card-shell";
|
||||
|
||||
/** Terminal states the status poll stops on. */
|
||||
const TERMINAL_STATES: readonly string[] = ["done", "failed"];
|
||||
|
||||
interface JobStatus {
|
||||
jobId: string;
|
||||
status: string;
|
||||
progress: {
|
||||
currentPhase?: string;
|
||||
recordsRead?: number;
|
||||
rowsUpserted?: number;
|
||||
percent?: number | null;
|
||||
} | null;
|
||||
result: {
|
||||
totals?: { recordsRead?: number; rowsUpserted?: number };
|
||||
clinical?: { skipped?: number };
|
||||
} | null;
|
||||
failureReason: string | null;
|
||||
}
|
||||
|
||||
export function AppleHealthImportCard() {
|
||||
const { t } = useTranslations();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const dropDescId = useId();
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: queryKeys.importJobStatus(jobId ?? "none"),
|
||||
enabled: jobId !== null,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as JobStatus | undefined;
|
||||
if (data && TERMINAL_STATES.includes(data.status)) return false;
|
||||
return 2000;
|
||||
},
|
||||
queryFn: async (): Promise<JobStatus> => {
|
||||
return apiGet<JobStatus>(
|
||||
`/api/import/apple-health-export/${jobId}/status`,
|
||||
{ credentials: "include" },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = useCallback(
|
||||
async (file: File) => {
|
||||
setUploadError(null);
|
||||
setUploading(true);
|
||||
setJobId(null);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await apiFetchRaw("/api/import/apple-health-export", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: form,
|
||||
});
|
||||
if (res.status === 429) {
|
||||
setUploadError(
|
||||
t("settings.sections.export.import.appleHealth.rateLimited"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (res.status === 413) {
|
||||
setUploadError(
|
||||
t("settings.sections.export.import.appleHealth.tooLarge"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
setUploadError(
|
||||
t("settings.sections.export.import.appleHealth.uploadFailed"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const body = (await res.json()).data as {
|
||||
jobId: string;
|
||||
status: string;
|
||||
};
|
||||
if (!body?.jobId) {
|
||||
setUploadError(
|
||||
t("settings.sections.export.import.appleHealth.uploadFailed"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setJobId(body.jobId);
|
||||
} catch {
|
||||
setUploadError(
|
||||
t("settings.sections.export.import.appleHealth.uploadFailed"),
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void upload(file);
|
||||
// Reset so picking the same file twice re-fires the change event.
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
function onDrop(e: React.DragEvent<HTMLDivElement>) {
|
||||
e.preventDefault();
|
||||
setDragActive(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) void upload(file);
|
||||
}
|
||||
|
||||
const status = statusQuery.data ?? null;
|
||||
const isRunning = status !== null && !TERMINAL_STATES.includes(status.status);
|
||||
const isDone = status?.status === "done";
|
||||
const isFailed = status?.status === "failed";
|
||||
const busy = uploading || isRunning;
|
||||
const percent = status?.progress?.percent ?? null;
|
||||
|
||||
return (
|
||||
<ImportCardShell
|
||||
testId="import-card-apple-health"
|
||||
icon={Upload}
|
||||
title={t("settings.sections.export.import.appleHealth.title")}
|
||||
description={t("settings.sections.export.import.appleHealth.description")}
|
||||
>
|
||||
{/* Keyboard-operable drop area. The hidden file input does the
|
||||
actual file selection; the div forwards Enter/Space to it. */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-describedby={dropDescId}
|
||||
aria-disabled={busy}
|
||||
onClick={() => !busy && fileInputRef.current?.click()}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === "Enter" || e.key === " ") && !busy) {
|
||||
e.preventDefault();
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (!busy) setDragActive(true);
|
||||
}}
|
||||
onDragLeave={() => setDragActive(false)}
|
||||
onDrop={(e) => !busy && onDrop(e)}
|
||||
className={cn(
|
||||
"border-border bg-muted/20 hover:bg-muted/40 focus-visible:ring-ring/50 flex min-h-24 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border border-dashed p-4 text-center transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
dragActive && "border-primary bg-primary/5",
|
||||
busy && "pointer-events-none opacity-60",
|
||||
)}
|
||||
>
|
||||
<Upload className="text-muted-foreground h-5 w-5" aria-hidden="true" />
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{t("settings.sections.export.import.appleHealth.dropLabel")}
|
||||
</span>
|
||||
<span id={dropDescId} className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.export.import.appleHealth.dropHint")}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
className="sr-only"
|
||||
aria-label={t(
|
||||
"settings.sections.export.import.appleHealth.fileInputLabel",
|
||||
)}
|
||||
onChange={onFileChange}
|
||||
/>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.export.import.appleHealth.idempotencyNote")}
|
||||
</p>
|
||||
|
||||
{/* Live progress / outcome — announced to assistive tech. */}
|
||||
<div aria-live="polite" className="space-y-2">
|
||||
{uploading && (
|
||||
<p className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
{t("settings.sections.export.import.appleHealth.uploading")}
|
||||
</p>
|
||||
)}
|
||||
{isRunning && (
|
||||
<div
|
||||
data-testid="import-apple-health-progress"
|
||||
className="space-y-1.5"
|
||||
>
|
||||
<p className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
{t(
|
||||
`settings.sections.export.import.appleHealth.phase.${status?.status ?? "queued"}`,
|
||||
)}
|
||||
</p>
|
||||
{typeof percent === "number" && <Progress value={percent} />}
|
||||
{typeof status?.progress?.rowsUpserted === "number" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.export.import.appleHealth.rowsImported", {
|
||||
count: status.progress.rowsUpserted,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isDone && (
|
||||
<div
|
||||
data-testid="import-apple-health-result"
|
||||
className="text-foreground flex items-start gap-2 text-xs"
|
||||
>
|
||||
<CheckCircle2
|
||||
className="text-success mt-0.5 h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{t("settings.sections.export.import.appleHealth.doneSummary", {
|
||||
imported: status?.result?.totals?.rowsUpserted ?? 0,
|
||||
read: status?.result?.totals?.recordsRead ?? 0,
|
||||
skipped: status?.result?.clinical?.skipped ?? 0,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(isFailed || uploadError) && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive flex items-start gap-2 text-sm"
|
||||
>
|
||||
<AlertCircle
|
||||
className="mt-0.5 h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{uploadError ??
|
||||
status?.failureReason ??
|
||||
t("settings.sections.export.import.appleHealth.failed")}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={busy}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
data-testid="import-action-apple-health"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.sections.export.import.appleHealth.choose")}
|
||||
</Button>
|
||||
</div>
|
||||
</ImportCardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Upper bound for the paste textareas, mirroring the 16 MB server-side body
|
||||
* ceiling on `/api/import` and `/api/import/csv`. Caps an accidental over-paste
|
||||
* before it ever reaches the route and feeds the live character counter.
|
||||
*/
|
||||
export const MAX_PASTE_CHARS = 16 * 1024 * 1024;
|
||||
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
FileSpreadsheet,
|
||||
Loader2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { ImportCardShell } from "./import-card-shell";
|
||||
import { MAX_PASTE_CHARS } from "./constants";
|
||||
import { EXAMPLE_CSV } from "./import-examples";
|
||||
|
||||
interface CsvRowResult {
|
||||
line: number;
|
||||
status: "inserted" | "updated" | "skipped";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface CsvImportResult {
|
||||
inserted: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
dryRun: boolean;
|
||||
rows: CsvRowResult[];
|
||||
}
|
||||
|
||||
export function CsvImportCard() {
|
||||
const { t } = useTranslations();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [text, setText] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<CsvImportResult | null>(null);
|
||||
const textareaId = useId();
|
||||
|
||||
async function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const content = await file.text();
|
||||
setText(content);
|
||||
} catch {
|
||||
setError(t("settings.sections.export.import.csv.readFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
function downloadExample() {
|
||||
const blob = new Blob([EXAMPLE_CSV], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "healthlog-import-example.csv";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function send(dryRun: boolean) {
|
||||
setError(null);
|
||||
setResult(null);
|
||||
if (text.trim().length === 0) {
|
||||
setError(t("settings.sections.export.import.csv.empty"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiFetchRaw(
|
||||
`/api/import/csv${dryRun ? "?dryRun=1" : ""}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/csv" },
|
||||
credentials: "include",
|
||||
body: text,
|
||||
},
|
||||
);
|
||||
if (res.status === 429) {
|
||||
setError(t("settings.sections.export.import.csv.rateLimited"));
|
||||
return;
|
||||
}
|
||||
if (res.status === 413) {
|
||||
setError(t("settings.sections.export.import.csv.tooLarge"));
|
||||
return;
|
||||
}
|
||||
if (res.status === 422) {
|
||||
const body = await res.json().catch(() => null);
|
||||
setError(
|
||||
body?.error ??
|
||||
t("settings.sections.export.import.csv.invalidPayload"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
setError(t("settings.sections.export.import.csv.failed"));
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()).data as CsvImportResult;
|
||||
setResult(data);
|
||||
} catch {
|
||||
setError(t("settings.sections.export.import.csv.failed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const errorRows = result?.rows.filter((r) => r.status === "skipped") ?? [];
|
||||
|
||||
return (
|
||||
<ImportCardShell
|
||||
testId="import-card-csv"
|
||||
icon={FileSpreadsheet}
|
||||
title={t("settings.sections.export.import.csv.title")}
|
||||
description={t("settings.sections.export.import.csv.description")}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={textareaId} className="text-xs">
|
||||
{t("settings.sections.export.import.csv.pasteLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={textareaId}
|
||||
data-testid="import-csv-textarea"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={5}
|
||||
maxLength={MAX_PASTE_CHARS}
|
||||
spellCheck={false}
|
||||
placeholder="type,value,unit,measuredAt,…"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground text-right text-xs tabular-nums">
|
||||
{t("settings.sections.export.import.charCount", {
|
||||
used: text.length,
|
||||
max: MAX_PASTE_CHARS,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="sr-only"
|
||||
aria-label={t("settings.sections.export.import.csv.fileInputLabel")}
|
||||
onChange={onFileChange}
|
||||
/>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.export.import.csv.schemaHint")}{" "}
|
||||
<Link
|
||||
href="/docs/integrations/data-import"
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
{t("settings.sections.export.import.csv.docsLink")}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div aria-live="polite" className="space-y-2">
|
||||
{result && (
|
||||
<div data-testid="import-csv-result" className="space-y-1.5">
|
||||
<p className="text-foreground flex items-start gap-2 text-xs">
|
||||
<CheckCircle2
|
||||
className="text-success mt-0.5 h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{result.dryRun
|
||||
? t("settings.sections.export.import.csv.previewSummary", {
|
||||
inserted: result.inserted,
|
||||
skipped: result.skipped,
|
||||
})
|
||||
: t("settings.sections.export.import.csv.resultSummary", {
|
||||
inserted: result.inserted,
|
||||
updated: result.updated,
|
||||
skipped: result.skipped,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
{errorRows.length > 0 && (
|
||||
<ul className="text-muted-foreground max-h-32 space-y-0.5 overflow-auto text-xs">
|
||||
{errorRows.slice(0, 50).map((r) => (
|
||||
<li key={r.line}>
|
||||
{t("settings.sections.export.import.csv.rowError", {
|
||||
line: r.line,
|
||||
reason: r.reason ?? "skipped",
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive flex items-start gap-2 text-sm"
|
||||
>
|
||||
<AlertCircle
|
||||
className="mt-0.5 h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{error}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
data-testid="import-csv-choose-file"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{t("settings.sections.export.import.csv.uploadFile")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={downloadExample}
|
||||
data-testid="import-csv-download-example"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{t("settings.sections.export.import.csv.downloadExample")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={busy || text.trim().length === 0}
|
||||
onClick={() => void send(true)}
|
||||
data-testid="import-csv-preview"
|
||||
>
|
||||
{busy && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.sections.export.import.csv.preview")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={busy || text.trim().length === 0}
|
||||
onClick={() => void send(false)}
|
||||
data-testid="import-action-csv"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<FileSpreadsheet className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.sections.export.import.csv.import")}
|
||||
</Button>
|
||||
</div>
|
||||
</ImportCardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
|
||||
export interface ImportCardShellProps {
|
||||
testId: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ImportCardShell({
|
||||
testId,
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: ImportCardShellProps) {
|
||||
return (
|
||||
<SettingsCard data-testid={testId} className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon
|
||||
className="text-muted-foreground h-5 w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 className="text-base font-semibold">{title}</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">{description}</p>
|
||||
<div className="mt-3 flex flex-1 flex-col gap-3">{children}</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { CSV_EXAMPLE_COLUMNS } from "@/lib/import/csv-measurements";
|
||||
|
||||
/**
|
||||
* Small valid example payload, minted as a downloadable Blob by the
|
||||
* "Download example" button and used by the docs. Exported so a test can
|
||||
* assert it stays a valid import body — the button and the route schema
|
||||
* must never drift.
|
||||
*/
|
||||
export const EXAMPLE_IMPORT = {
|
||||
measurements: [
|
||||
{
|
||||
type: "WEIGHT",
|
||||
value: 80.5,
|
||||
unit: "kg",
|
||||
measuredAt: "2026-05-01T08:00:00.000Z",
|
||||
source: "manual",
|
||||
notes: "morning",
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value: 120,
|
||||
unit: "mmHg",
|
||||
measuredAt: "2026-05-01T08:05:00.000Z",
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
value: 80,
|
||||
unit: "mmHg",
|
||||
measuredAt: "2026-05-01T08:05:00.000Z",
|
||||
},
|
||||
],
|
||||
moodEntries: [
|
||||
{
|
||||
date: "2026-05-01",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
tags: "work,exercise",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Client-side parse guard for the JSON-import textarea. Returns the
|
||||
* parsed value when the text is valid JSON, otherwise a failure marker —
|
||||
* we never POST an unparseable body. Exported for unit testing so the
|
||||
* guard is covered without a browser.
|
||||
*/
|
||||
export function parseImportJson(
|
||||
text: string,
|
||||
): { ok: true; value: unknown } | { ok: false } {
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(text) };
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloadable CSV example. Header is order-independent server-side, but the
|
||||
* example pins the documented order so the docs + the route schema never
|
||||
* drift. Exported so a test can assert it stays a valid header.
|
||||
*/
|
||||
export const EXAMPLE_CSV = [
|
||||
CSV_EXAMPLE_COLUMNS.join(","),
|
||||
"WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,morning,",
|
||||
"BLOOD_GLUCOSE,5.3,mmol/L,2026-05-01T08:05:00+02:00,FASTING,,meter-001",
|
||||
"BLOOD_PRESSURE_SYS,120,mmHg,2026-05-01T08:05:00+02:00,,,",
|
||||
].join("\n");
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.15.7 — Settings → Export & Import → Import area.
|
||||
*
|
||||
* Web UI for the three import backends. Each source is a self-contained
|
||||
* card under this directory:
|
||||
*
|
||||
* 1. Apple Health `export.zip` — `apple-health-import-card.tsx`
|
||||
* (multipart POST + status poll).
|
||||
* 2. Generic JSON import — `json-import-card.tsx`
|
||||
* (file/paste, client-side parseability guard).
|
||||
* 3. CSV import — `csv-import-card.tsx`
|
||||
* (file/paste, dry-run preview).
|
||||
*
|
||||
* Both controls surface rate-limit (429) and error states cleanly and
|
||||
* announce progress to assistive tech via `aria-live` regions.
|
||||
*/
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { AppleHealthImportCard } from "./apple-health-import-card";
|
||||
import { JsonImportCard } from "./json-import-card";
|
||||
import { CsvImportCard } from "./csv-import-card";
|
||||
|
||||
export {
|
||||
EXAMPLE_IMPORT,
|
||||
EXAMPLE_CSV,
|
||||
parseImportJson,
|
||||
} from "./import-examples";
|
||||
|
||||
export function ImportPanel() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-import-title"
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h2
|
||||
id="settings-section-import-title"
|
||||
className="text-base font-semibold tracking-tight"
|
||||
>
|
||||
{t("settings.sections.export.import.heading")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.export.import.description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<AppleHealthImportCard />
|
||||
<JsonImportCard />
|
||||
<CsvImportCard />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
FileJson,
|
||||
Loader2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { ImportCardShell } from "./import-card-shell";
|
||||
import { MAX_PASTE_CHARS } from "./constants";
|
||||
import { EXAMPLE_IMPORT, parseImportJson } from "./import-examples";
|
||||
|
||||
interface JsonImportResult {
|
||||
measurements: number;
|
||||
moodEntries: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
export function JsonImportCard() {
|
||||
const { t } = useTranslations();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [text, setText] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<JsonImportResult | null>(null);
|
||||
const textareaId = useId();
|
||||
|
||||
async function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const content = await file.text();
|
||||
setText(content);
|
||||
} catch {
|
||||
setError(t("settings.sections.export.import.json.readFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
function downloadExample() {
|
||||
const blob = new Blob([JSON.stringify(EXAMPLE_IMPORT, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "healthlog-import-example.json";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
setError(null);
|
||||
setResult(null);
|
||||
const parsed = parseImportJson(text);
|
||||
if (!parsed.ok) {
|
||||
setError(t("settings.sections.export.import.json.invalidJson"));
|
||||
return;
|
||||
}
|
||||
const payload = parsed.value;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 429) {
|
||||
setError(t("settings.sections.export.import.json.rateLimited"));
|
||||
return;
|
||||
}
|
||||
if (res.status === 422) {
|
||||
const body = await res.json().catch(() => null);
|
||||
setError(
|
||||
body?.error ??
|
||||
t("settings.sections.export.import.json.invalidPayload"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
setError(t("settings.sections.export.import.json.failed"));
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()).data as JsonImportResult;
|
||||
setResult({
|
||||
measurements: data?.measurements ?? 0,
|
||||
moodEntries: data?.moodEntries ?? 0,
|
||||
skipped: data?.skipped ?? 0,
|
||||
});
|
||||
} catch {
|
||||
setError(t("settings.sections.export.import.json.failed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ImportCardShell
|
||||
testId="import-card-json"
|
||||
icon={FileJson}
|
||||
title={t("settings.sections.export.import.json.title")}
|
||||
description={t("settings.sections.export.import.json.description")}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={textareaId} className="text-xs">
|
||||
{t("settings.sections.export.import.json.pasteLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={textareaId}
|
||||
data-testid="import-json-textarea"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={5}
|
||||
maxLength={MAX_PASTE_CHARS}
|
||||
spellCheck={false}
|
||||
placeholder='{"measurements":[…],"moodEntries":[…]}'
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground text-right text-xs tabular-nums">
|
||||
{t("settings.sections.export.import.charCount", {
|
||||
used: text.length,
|
||||
max: MAX_PASTE_CHARS,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
className="sr-only"
|
||||
aria-label={t("settings.sections.export.import.json.fileInputLabel")}
|
||||
onChange={onFileChange}
|
||||
/>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.export.import.json.schemaHint")}{" "}
|
||||
<Link
|
||||
href="/docs/integrations/data-import"
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
{t("settings.sections.export.import.json.docsLink")}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div aria-live="polite" className="space-y-2">
|
||||
{result && (
|
||||
<p
|
||||
data-testid="import-json-result"
|
||||
className="text-foreground flex items-start gap-2 text-xs"
|
||||
>
|
||||
<CheckCircle2
|
||||
className="text-success mt-0.5 h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{t("settings.sections.export.import.json.resultSummary", {
|
||||
measurements: result.measurements,
|
||||
moods: result.moodEntries,
|
||||
skipped: result.skipped,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive flex items-start gap-2 text-sm"
|
||||
>
|
||||
<AlertCircle
|
||||
className="mt-0.5 h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{error}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
data-testid="import-json-choose-file"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{t("settings.sections.export.import.json.uploadFile")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={downloadExample}
|
||||
data-testid="import-json-download-example"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{t("settings.sections.export.import.json.downloadExample")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={busy || text.trim().length === 0}
|
||||
onClick={handleImport}
|
||||
data-testid="import-action-json"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<FileJson className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.sections.export.import.json.import")}
|
||||
</Button>
|
||||
</div>
|
||||
</ImportCardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* OpenAPI route table — insights. Barrel preserving the original module path.
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `../index.ts`.
|
||||
*/
|
||||
export { insightsPaths } from "./paths";
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* OpenAPI path table — dashboard snapshot, comprehensive insights, analytics range, metric status, derived metrics, correlations.
|
||||
*
|
||||
* Schema declarations live in `./schemas`; this module is the path orchestrator.
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { consentRequiredResponse, dataEnvelope, stdResponses } from "../shared";
|
||||
import {
|
||||
insightsComprehensiveResponse,
|
||||
metricStatusQuery,
|
||||
metricStatusResponse,
|
||||
derivedMetricQuery,
|
||||
derivedMetricResponse,
|
||||
derivedBatchQuery,
|
||||
derivedBatchResponse,
|
||||
correlationDiscoveryResponse,
|
||||
insightStatusQuery,
|
||||
insightStatusResponse,
|
||||
medicationComplianceStatusResponse,
|
||||
analyticsRangeQuery,
|
||||
analyticsRangeResponse,
|
||||
insightsPregenerateRequest,
|
||||
insightsPregenerateResponse,
|
||||
dashboardSnapshotResponse,
|
||||
} from "./schemas";
|
||||
|
||||
export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/dashboard/snapshot": {
|
||||
get: {
|
||||
tags: ["Dashboard"],
|
||||
summary: "Unified dashboard first-paint snapshot",
|
||||
description:
|
||||
"Assembles every above-the-fold tile field in one round-trip from the rollup / mood / widget helpers plus a read-only lift of the pre-generated daily briefing. Two-phase: `tiles` always present, `extras` nullable on a rollup-coverage miss. No LLM is reachable from this path. Cookie or Bearer auth.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Dashboard snapshot.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
dashboardSnapshotResponse,
|
||||
"DashboardSnapshotResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/comprehensive": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Comprehensive AI insights bundle",
|
||||
description:
|
||||
"Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard annotations. Strict-schema validated server-side. Requires an active ConsentReceipt when the resolved provider chain egresses via the operator's server-managed key (see POST /api/consent/ai).",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Insights bundle.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightsComprehensiveResponse,
|
||||
"InsightsComprehensiveResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...consentRequiredResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/pregenerate": {
|
||||
post: {
|
||||
tags: ["Insights"],
|
||||
summary: "Warm all AI assessments for the calling user",
|
||||
description:
|
||||
"v1.8.7.1 — enqueue a full warm of every AI assessment for the authenticated user (comprehensive insight + the seven specialised status cards + every data-bearing generic metric assessment) in the active locale, so the read-only status GETs serve cached text instantly. Returns immediately; the generation runs out of band on the worker. Empty metrics and provider-less accounts never trigger an LLM call. Short anti-spam bucket (`insights-warm:<userId>`, one warm per 3 minutes) → 429 on a tight loop. Auth via cookie or Bearer; `userId` is taken from the session, never the body.",
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: {
|
||||
"application/json": { schema: insightsPregenerateRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"Warm accepted and enqueued. The work runs on the worker; poll the read-only status routes for the text.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightsPregenerateResponse,
|
||||
"InsightsPregenerateResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/analytics/range": {
|
||||
get: {
|
||||
tags: ["Analytics"],
|
||||
summary: "Single-metric period-over-period range delta",
|
||||
description:
|
||||
"v1.9.0 — returns the current-window aggregate, the previous comparable window, and the composed delta for ONE metric type over a `7d` / `30d` / `90d` / `1y` range. Single-type by construction (the metric page is single-metric), so the read is one rollup-tier call covering the trailing 2N days sliced into the two halves — no per-type fan-out. Additive route; the `/api/analytics` envelope is unchanged. Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: analyticsRangeQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Current + previous window aggregates and the delta.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
analyticsRangeResponse,
|
||||
"AnalyticsRangeResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/blood-pressure-status": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Blood-pressure assessment",
|
||||
description:
|
||||
"Data-driven plain-language assessment of the user's recent blood-pressure readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: insightStatusQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Assessment envelope (fresh, cached, or preparing).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightStatusResponse,
|
||||
"BloodPressureStatusResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/pulse-status": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Pulse assessment",
|
||||
description:
|
||||
"Data-driven plain-language assessment of the user's recent resting-pulse readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: insightStatusQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Assessment envelope (fresh, cached, or preparing).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightStatusResponse,
|
||||
"PulseStatusResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/weight-status": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Weight assessment",
|
||||
description:
|
||||
"Data-driven plain-language assessment of the user's recent weight trend. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: insightStatusQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Assessment envelope (fresh, cached, or preparing).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightStatusResponse,
|
||||
"WeightStatusResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/bmi-status": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "BMI assessment",
|
||||
description:
|
||||
"Data-driven plain-language assessment of the user's body-mass index. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: insightStatusQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Assessment envelope (fresh, cached, or preparing).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightStatusResponse,
|
||||
"BmiStatusResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/mood-status": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Mood assessment",
|
||||
description:
|
||||
"Data-driven plain-language assessment of the user's recent mood entries. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: insightStatusQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Assessment envelope (fresh, cached, or preparing).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightStatusResponse,
|
||||
"MoodStatusResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/medication-compliance-status": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Medication-compliance assessment",
|
||||
description:
|
||||
"Data-driven plain-language assessment of the user's medication compliance — an overall `summary` plus a per-medication note array. Read-only: a cache miss warms a generation out of band and serves the last-good envelope meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: insightStatusQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"Compliance assessment envelope (fresh, cached, or preparing).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationComplianceStatusResponse,
|
||||
"MedicationComplianceStatusResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/metric-status": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Generic per-HealthKit-metric assessment",
|
||||
description:
|
||||
"v1.8.7.1 — data-driven plain-language assessment for any registered HealthKit metric (resting heart rate, sleep, glucose, body composition, gait, audio exposure, …). One generic route covering ~30 metric pages via archetype prompt templates + per-metric metadata. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). An unknown `metric` 422s against the closed registry enum. Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: metricStatusQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Assessment envelope (fresh, cached, or preparing).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
metricStatusResponse,
|
||||
"MetricStatusResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/derived": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Derived wellness metric (compute-once)",
|
||||
description:
|
||||
"v1.10.0 — the compute-once `Derived<T>` value for any registered derived wellness metric (personal typical-range vitals baseline, cardio-fitness band, vascular-age delta, sleep score, readiness, coincident-deviation flag). One generic route over a closed registry enum; an unknown `metric` 422s. Pure compute over the rollup tier with a per-type live fallback on a coverage miss — no LLM call, no narrative, no cache table. Returns the flat `Derived<T>` union so the native client can decode one stable shape and combine values across metrics. Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: derivedMetricQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The flat derived-metric value (ok or insufficient).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
derivedMetricResponse,
|
||||
"DerivedMetricResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/derived/batch": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Derived wellness metrics (batched compute-once)",
|
||||
description:
|
||||
"v1.10.0 — resolve several derived wellness metrics in ONE request. The `metrics` CSV names the metrics (a `metric:type` token sub-targets a VITALS_BASELINE vital); the server fans out under a bounded limiter with the profile loaded once and returns a map keyed by the per-request token. Collapses the Insights cold-mount fan-out of 14+ independent single-metric requests — the pool-starvation class that surfaces as a hang-then-recover. The single-metric route stays for the per-score detail pages. Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: derivedBatchQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The map of derived-metric values, keyed by token.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
derivedBatchResponse,
|
||||
"DerivedBatchResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/correlations": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Correlation discovery (FDR-controlled)",
|
||||
description:
|
||||
"v1.10.0 — scans a curated behaviour × outcome matrix (daylight / mood / glucose / BP / steps × sleep / HRV / resting HR / weight), lag-joins each behaviour day to the next day's outcome, runs Pearson with the exact Student-t p-value, and applies Benjamini-Hochberg FDR control across every tested pair. Only statistically-defensible pairs surface, each carrying n, r, p, and the BH-adjusted q. Descriptive, never causal. Gated by the operator `correlations` assistant surface. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The discovered correlations + the tested-pair count.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
correlationDiscoveryResponse,
|
||||
"CorrelationDiscoveryResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -6,7 +6,6 @@
|
||||
* runtime request parsing, so the wire contract stays single-source.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
|
||||
import {
|
||||
@@ -14,9 +13,8 @@ import {
|
||||
VITALS_BASELINE_TYPES,
|
||||
} from "@/lib/insights/derived/registry";
|
||||
import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta";
|
||||
import { consentRequiredResponse, dataEnvelope, stdResponses } from "./shared";
|
||||
|
||||
const insightsComprehensiveResponse = z
|
||||
export const insightsComprehensiveResponse = z
|
||||
.object({
|
||||
summary: z.string(),
|
||||
recommendations: z.array(z.record(z.string(), z.unknown())),
|
||||
@@ -46,7 +44,7 @@ const insightsComprehensiveResponse = z
|
||||
// spec, the route, and the cache scope cannot drift. The seven
|
||||
// specialised metrics (weight / blood-pressure / pulse / bmi / mood /
|
||||
// medication-compliance) keep their own routes and are NOT accepted here.
|
||||
const metricStatusQuery = z
|
||||
export const metricStatusQuery = z
|
||||
.object({
|
||||
metric: z
|
||||
.enum(METRIC_STATUS_IDS as [string, ...string[]])
|
||||
@@ -60,7 +58,7 @@ const metricStatusQuery = z
|
||||
})
|
||||
.meta({ id: "MetricStatusQuery" });
|
||||
|
||||
const metricStatusResponse = z
|
||||
export const metricStatusResponse = z
|
||||
.object({
|
||||
hasProvider: z
|
||||
.boolean()
|
||||
@@ -109,7 +107,7 @@ const metricStatusResponse = z
|
||||
// derived from the same registry the route validates against, so spec +
|
||||
// route + cache scope cannot drift. `type` sub-targets the single vital
|
||||
// a baseline metric (VITALS_BASELINE) bands over.
|
||||
const derivedMetricQuery = z
|
||||
export const derivedMetricQuery = z
|
||||
.object({
|
||||
metric: z
|
||||
.enum(DERIVED_METRIC_IDS as [string, ...string[]])
|
||||
@@ -125,7 +123,7 @@ const derivedMetricQuery = z
|
||||
})
|
||||
.meta({ id: "DerivedMetricQuery" });
|
||||
|
||||
const derivedCoverage = z
|
||||
export const derivedCoverage = z
|
||||
.object({
|
||||
requiredInputs: z
|
||||
.number()
|
||||
@@ -149,7 +147,7 @@ const derivedCoverage = z
|
||||
})
|
||||
.meta({ id: "DerivedCoverage" });
|
||||
|
||||
const derivedConfidence = z
|
||||
export const derivedConfidence = z
|
||||
.object({
|
||||
score: z
|
||||
.number()
|
||||
@@ -162,7 +160,7 @@ const derivedConfidence = z
|
||||
})
|
||||
.meta({ id: "DerivedConfidence" });
|
||||
|
||||
const derivedProvenance = z
|
||||
export const derivedProvenance = z
|
||||
.object({
|
||||
inputs: z
|
||||
.array(z.string())
|
||||
@@ -184,7 +182,7 @@ const derivedProvenance = z
|
||||
|
||||
// v1.13.2 — per-derived-SCORE assessment text. Additive, non-breaking field
|
||||
// on the derived response; the iOS field-name contract is LOCKED.
|
||||
const derivedAssessment = z
|
||||
export const derivedAssessment = z
|
||||
.object({
|
||||
text: z
|
||||
.string()
|
||||
@@ -202,7 +200,7 @@ const derivedAssessment = z
|
||||
})
|
||||
.meta({ id: "DerivedAssessment" });
|
||||
|
||||
const derivedMetricResponse = z
|
||||
export const derivedMetricResponse = z
|
||||
.object({
|
||||
metric: z
|
||||
.enum(DERIVED_METRIC_IDS as [string, ...string[]])
|
||||
@@ -245,7 +243,7 @@ const derivedMetricResponse = z
|
||||
// or more `metric` / `metric:type` tokens; the route fans out server-side
|
||||
// under a bounded limiter with the profile loaded once, collapsing the
|
||||
// dashboard's cold-mount fan-out of N single-metric requests into one.
|
||||
const derivedBatchQuery = z
|
||||
export const derivedBatchQuery = z
|
||||
.object({
|
||||
metrics: z
|
||||
.string()
|
||||
@@ -257,7 +255,7 @@ const derivedBatchQuery = z
|
||||
})
|
||||
.meta({ id: "DerivedBatchQuery" });
|
||||
|
||||
const derivedBatchResponse = z
|
||||
export const derivedBatchResponse = z
|
||||
.object({
|
||||
metrics: z
|
||||
.record(z.string(), derivedMetricResponse)
|
||||
@@ -273,7 +271,7 @@ const derivedBatchResponse = z
|
||||
|
||||
// v1.10.0 — FDR-controlled correlation discovery result. One discovered,
|
||||
// statistically-defensible behaviour → next-day-outcome pair.
|
||||
const discoveredCorrelation = z
|
||||
export const discoveredCorrelation = z
|
||||
.object({
|
||||
behaviour: z
|
||||
.string()
|
||||
@@ -301,7 +299,7 @@ const discoveredCorrelation = z
|
||||
})
|
||||
.meta({ id: "DiscoveredCorrelation" });
|
||||
|
||||
const correlationDiscoveryResponse = z
|
||||
export const correlationDiscoveryResponse = z
|
||||
.object({
|
||||
discovered: z
|
||||
.array(discoveredCorrelation)
|
||||
@@ -325,7 +323,7 @@ const correlationDiscoveryResponse = z
|
||||
// The seven specialised `*-status` routes accept an optional locale
|
||||
// override (the metric is fixed by the route path, unlike the generic
|
||||
// metric-status route which carries it as a query field).
|
||||
const insightStatusQuery = z
|
||||
export const insightStatusQuery = z
|
||||
.object({
|
||||
locale: z
|
||||
.enum(["de", "en"])
|
||||
@@ -338,7 +336,7 @@ const insightStatusQuery = z
|
||||
// routes (blood-pressure, pulse, weight, bmi, mood). Same envelope as
|
||||
// the generic metric-status card minus the `insufficient` flag, which is
|
||||
// metric-status-only. Read-only + stale-while-revalidate.
|
||||
const insightStatusResponse = z
|
||||
export const insightStatusResponse = z
|
||||
.object({
|
||||
hasProvider: z
|
||||
.boolean()
|
||||
@@ -380,7 +378,7 @@ const insightStatusResponse = z
|
||||
// The medication-compliance route carries a richer envelope than the
|
||||
// other six: a `summary` narrative plus a per-medication `text` array,
|
||||
// instead of a single `text` field.
|
||||
const medicationComplianceStatusResponse = z
|
||||
export const medicationComplianceStatusResponse = z
|
||||
.object({
|
||||
hasProvider: z
|
||||
.boolean()
|
||||
@@ -437,7 +435,7 @@ const medicationComplianceStatusResponse = z
|
||||
"Medication-compliance assessment envelope. Unlike the other six specialised cards it carries a `summary` plus a per-medication `text` array rather than a single `text` field. Read-only + stale-while-revalidate.",
|
||||
});
|
||||
|
||||
const analyticsRangeQuery = z
|
||||
export const analyticsRangeQuery = z
|
||||
.object({
|
||||
type: measurementTypeEnum.describe(
|
||||
"The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s.",
|
||||
@@ -450,7 +448,7 @@ const analyticsRangeQuery = z
|
||||
})
|
||||
.meta({ id: "AnalyticsRangeQuery" });
|
||||
|
||||
const analyticsWindowAggregate = z
|
||||
export const analyticsWindowAggregate = z
|
||||
.object({
|
||||
count: z.number().int().describe("Reading count composed across buckets."),
|
||||
min: z.number().nullable().describe("Window minimum; null when empty."),
|
||||
@@ -468,7 +466,7 @@ const analyticsWindowAggregate = z
|
||||
})
|
||||
.meta({ id: "AnalyticsWindowAggregate" });
|
||||
|
||||
const analyticsRangeResponse = z
|
||||
export const analyticsRangeResponse = z
|
||||
.object({
|
||||
range: z
|
||||
.enum(ANALYTICS_RANGES)
|
||||
@@ -507,13 +505,13 @@ const analyticsRangeResponse = z
|
||||
"Single-metric period-over-period aggregate. Reads the current and previous comparable windows from the WMY rollup tier and composes a count-weighted-mean delta. `count/min/max/mean/sum` are linearly composable across buckets; SD/slope/r² are intentionally excluded (not composable).",
|
||||
});
|
||||
|
||||
const insightsPregenerateRequest = z.object({}).meta({
|
||||
export const insightsPregenerateRequest = z.object({}).meta({
|
||||
id: "InsightsPregenerateRequest",
|
||||
description:
|
||||
"No body fields. The user is taken from the session / Bearer and the locale from the session; the warm covers every assessment for that user.",
|
||||
});
|
||||
|
||||
const insightsPregenerateResponse = z
|
||||
export const insightsPregenerateResponse = z
|
||||
.object({
|
||||
queued: z
|
||||
.boolean()
|
||||
@@ -535,7 +533,7 @@ const insightsPregenerateResponse = z
|
||||
// blocks are typed loosely (`z.record`) to match the comprehensive
|
||||
// response style above — the strict shapes live in their own Zod
|
||||
// modules and the iOS client does not consume this web-only route.
|
||||
const dataSummaryRecord = z.record(z.string(), z.unknown());
|
||||
export const dataSummaryRecord = z.record(z.string(), z.unknown());
|
||||
|
||||
// v1.17.0 — server-authoritative glucose clinical panel. Mirrors
|
||||
// `GlucoseClinicalMetrics` from `@/lib/analytics/glucose-metrics`: the
|
||||
@@ -543,7 +541,7 @@ const dataSummaryRecord = z.record(z.string(), z.unknown());
|
||||
// J-index + LBGI/HBGI tier, gated by a `stillLearning` flag so a thin
|
||||
// spot-data window is never asserted as a clinical AGP. The iOS client
|
||||
// renders these numbers verbatim and never re-derives them.
|
||||
const glucoseClinicalSchema = z
|
||||
export const glucoseClinicalSchema = z
|
||||
.object({
|
||||
stillLearning: z.boolean(),
|
||||
stillLearningReason: z.string().nullable(),
|
||||
@@ -591,7 +589,7 @@ const glucoseClinicalSchema = z
|
||||
"Server-authoritative glucose clinical panel over the trailing 30-day window. Figures from sparse spot data are a SPOT-READING ESTIMATE (a % of readings), not a CGM time-in-range AGP; `isSpotEstimate` is derived from reading density (true below ~hourly, false for a continuous CGM stream such as Nightscout) and `stillLearning` gates assertion when the window has too few readings or too short a span. `distribution` carries the Battelino 2019 TIR/TBR/TAR fractions (level-2 nested in level-1) plus minutes-of-a-day equivalents; `gmi` (Bergenstal 2018) + `estimatedA1c` (Nathan 2008 ADAG) derive from the mean; `variability` is SD + CV% with the Monnier 2017 ≥36% instability flag; `advanced` is the disclosure tier — J-index (Wojcicki 1995) + LBGI/HBGI (Kovatchev hypo/hyper risk). All blocks are null when there are no readings; `advanced.jIndex` is null for a single-reading window.",
|
||||
});
|
||||
|
||||
const dashboardSnapshotResponse = z
|
||||
export const dashboardSnapshotResponse = z
|
||||
.object({
|
||||
user: z.object({
|
||||
username: z.string(),
|
||||
@@ -707,353 +705,3 @@ const dashboardSnapshotResponse = z
|
||||
description:
|
||||
"Unified above-the-fold dashboard payload. `tiles` always arrives (slim summaries + mood + resolved widget layout); `extras` (BD-in-target + per-context glucose) is null on a rollup-coverage miss so the strip never waits on the slowest read. `briefing` is lifted read-only from the pre-generated insight cache — never generated synchronously — and reports `ready` / `preparing` / `disabled` / `no-provider` via `briefingState` (`no-provider` = stale-or-missing cache with no AI provider configured anywhere, so no warm pass will fill it; stop polling and surface a connect-provider hint). A stale-but-parseable briefing is still delivered with `briefingStale: true`. `layoutCatalogue` (full 27-id widget catalogue) and `metricStates` (latest reading per metric, keyed by iOS `MetricKind` raw value) are additive cold-launch seeds for the native client; both derive in-process from data already fetched, adding no DB round-trip.",
|
||||
});
|
||||
|
||||
export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/dashboard/snapshot": {
|
||||
get: {
|
||||
tags: ["Dashboard"],
|
||||
summary: "Unified dashboard first-paint snapshot",
|
||||
description:
|
||||
"Assembles every above-the-fold tile field in one round-trip from the rollup / mood / widget helpers plus a read-only lift of the pre-generated daily briefing. Two-phase: `tiles` always present, `extras` nullable on a rollup-coverage miss. No LLM is reachable from this path. Cookie or Bearer auth.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Dashboard snapshot.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
dashboardSnapshotResponse,
|
||||
"DashboardSnapshotResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/comprehensive": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Comprehensive AI insights bundle",
|
||||
description:
|
||||
"Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard annotations. Strict-schema validated server-side. Requires an active ConsentReceipt when the resolved provider chain egresses via the operator's server-managed key (see POST /api/consent/ai).",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Insights bundle.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightsComprehensiveResponse,
|
||||
"InsightsComprehensiveResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...consentRequiredResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/pregenerate": {
|
||||
post: {
|
||||
tags: ["Insights"],
|
||||
summary: "Warm all AI assessments for the calling user",
|
||||
description:
|
||||
"v1.8.7.1 — enqueue a full warm of every AI assessment for the authenticated user (comprehensive insight + the seven specialised status cards + every data-bearing generic metric assessment) in the active locale, so the read-only status GETs serve cached text instantly. Returns immediately; the generation runs out of band on the worker. Empty metrics and provider-less accounts never trigger an LLM call. Short anti-spam bucket (`insights-warm:<userId>`, one warm per 3 minutes) → 429 on a tight loop. Auth via cookie or Bearer; `userId` is taken from the session, never the body.",
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: {
|
||||
"application/json": { schema: insightsPregenerateRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"Warm accepted and enqueued. The work runs on the worker; poll the read-only status routes for the text.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightsPregenerateResponse,
|
||||
"InsightsPregenerateResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/analytics/range": {
|
||||
get: {
|
||||
tags: ["Analytics"],
|
||||
summary: "Single-metric period-over-period range delta",
|
||||
description:
|
||||
"v1.9.0 — returns the current-window aggregate, the previous comparable window, and the composed delta for ONE metric type over a `7d` / `30d` / `90d` / `1y` range. Single-type by construction (the metric page is single-metric), so the read is one rollup-tier call covering the trailing 2N days sliced into the two halves — no per-type fan-out. Additive route; the `/api/analytics` envelope is unchanged. Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: analyticsRangeQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Current + previous window aggregates and the delta.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
analyticsRangeResponse,
|
||||
"AnalyticsRangeResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/blood-pressure-status": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Blood-pressure assessment",
|
||||
description:
|
||||
"Data-driven plain-language assessment of the user's recent blood-pressure readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: insightStatusQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Assessment envelope (fresh, cached, or preparing).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightStatusResponse,
|
||||
"BloodPressureStatusResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/pulse-status": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Pulse assessment",
|
||||
description:
|
||||
"Data-driven plain-language assessment of the user's recent resting-pulse readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: insightStatusQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Assessment envelope (fresh, cached, or preparing).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightStatusResponse,
|
||||
"PulseStatusResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/weight-status": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Weight assessment",
|
||||
description:
|
||||
"Data-driven plain-language assessment of the user's recent weight trend. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: insightStatusQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Assessment envelope (fresh, cached, or preparing).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightStatusResponse,
|
||||
"WeightStatusResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/bmi-status": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "BMI assessment",
|
||||
description:
|
||||
"Data-driven plain-language assessment of the user's body-mass index. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: insightStatusQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Assessment envelope (fresh, cached, or preparing).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightStatusResponse,
|
||||
"BmiStatusResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/mood-status": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Mood assessment",
|
||||
description:
|
||||
"Data-driven plain-language assessment of the user's recent mood entries. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: insightStatusQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Assessment envelope (fresh, cached, or preparing).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightStatusResponse,
|
||||
"MoodStatusResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/medication-compliance-status": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Medication-compliance assessment",
|
||||
description:
|
||||
"Data-driven plain-language assessment of the user's medication compliance — an overall `summary` plus a per-medication note array. Read-only: a cache miss warms a generation out of band and serves the last-good envelope meanwhile (stale-while-revalidate). Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: insightStatusQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"Compliance assessment envelope (fresh, cached, or preparing).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationComplianceStatusResponse,
|
||||
"MedicationComplianceStatusResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/metric-status": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Generic per-HealthKit-metric assessment",
|
||||
description:
|
||||
"v1.8.7.1 — data-driven plain-language assessment for any registered HealthKit metric (resting heart rate, sleep, glucose, body composition, gait, audio exposure, …). One generic route covering ~30 metric pages via archetype prompt templates + per-metric metadata. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). An unknown `metric` 422s against the closed registry enum. Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: metricStatusQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Assessment envelope (fresh, cached, or preparing).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
metricStatusResponse,
|
||||
"MetricStatusResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/derived": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Derived wellness metric (compute-once)",
|
||||
description:
|
||||
"v1.10.0 — the compute-once `Derived<T>` value for any registered derived wellness metric (personal typical-range vitals baseline, cardio-fitness band, vascular-age delta, sleep score, readiness, coincident-deviation flag). One generic route over a closed registry enum; an unknown `metric` 422s. Pure compute over the rollup tier with a per-type live fallback on a coverage miss — no LLM call, no narrative, no cache table. Returns the flat `Derived<T>` union so the native client can decode one stable shape and combine values across metrics. Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: derivedMetricQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The flat derived-metric value (ok or insufficient).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
derivedMetricResponse,
|
||||
"DerivedMetricResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/derived/batch": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Derived wellness metrics (batched compute-once)",
|
||||
description:
|
||||
"v1.10.0 — resolve several derived wellness metrics in ONE request. The `metrics` CSV names the metrics (a `metric:type` token sub-targets a VITALS_BASELINE vital); the server fans out under a bounded limiter with the profile loaded once and returns a map keyed by the per-request token. Collapses the Insights cold-mount fan-out of 14+ independent single-metric requests — the pool-starvation class that surfaces as a hang-then-recover. The single-metric route stays for the per-score detail pages. Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: derivedBatchQuery,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The map of derived-metric values, keyed by token.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
derivedBatchResponse,
|
||||
"DerivedBatchResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/correlations": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Correlation discovery (FDR-controlled)",
|
||||
description:
|
||||
"v1.10.0 — scans a curated behaviour × outcome matrix (daylight / mood / glucose / BP / steps × sleep / HRV / resting HR / weight), lag-joins each behaviour day to the next day's outcome, runs Pearson with the exact Student-t p-value, and applies Benjamini-Hochberg FDR control across every tested pair. Only statistically-defensible pairs surface, each carrying n, r, p, and the BH-adjusted q. Descriptive, never causal. Gated by the operator `correlations` assistant surface. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The discovered correlations + the tested-pair count.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
correlationDiscoveryResponse,
|
||||
"CorrelationDiscoveryResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* OpenAPI route table — medications. Barrel preserving the original module path.
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `../index.ts`.
|
||||
*/
|
||||
export { medicationResource } from "./schemas";
|
||||
export { medicationPaths } from "./paths";
|
||||
@@ -0,0 +1,676 @@
|
||||
/**
|
||||
* OpenAPI path table — medications CRUD, intake, cadence, compliance, AI extraction.
|
||||
*
|
||||
* Schema declarations live in `./schemas`; this module is the path orchestrator.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import {
|
||||
createMedicationSchema,
|
||||
updateMedicationSchema,
|
||||
intakeSchema,
|
||||
createInventoryItemSchema,
|
||||
updateInventoryItemSchema,
|
||||
} from "@/lib/validations/medication";
|
||||
import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt";
|
||||
import {
|
||||
scheduleRevisionCreateSchema,
|
||||
scheduleRevisionUpdateSchema,
|
||||
} from "@/lib/validations/schedule-revision";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "../shared";
|
||||
import {
|
||||
medicationListEntry,
|
||||
medicationDetailEntry,
|
||||
medicationInventoryItemResource,
|
||||
medicationIntakeEventResource,
|
||||
medicationCadenceResponse,
|
||||
medicationComplianceResponse,
|
||||
medicationComplianceSummaryEntry,
|
||||
scheduleRevisionResource,
|
||||
scheduleRevisionListResponse,
|
||||
medicationExtractRequest,
|
||||
medicationListLayoutSchema,
|
||||
} from "./schemas";
|
||||
|
||||
export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/medications": {
|
||||
get: {
|
||||
tags: ["Medications"],
|
||||
summary: "List medications for the calling user",
|
||||
description:
|
||||
"Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the count of today's actioned intake events (`todayEventCount`). The response is cached server-side for 60 s per user; writes flush the cache.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Medication list.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.array(medicationListEntry),
|
||||
"ListMedicationsResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Medications"],
|
||||
summary: "Create a medication with at least one schedule",
|
||||
description:
|
||||
"Validates the body against `CreateMedicationRequest`, applies the v1.5 cross-field invariants (one-shot consistency, recurring default `FREQ=DAILY`, `timesOfDay` dual-write), and creates the medication + its schedules in a single Prisma write. Audits as `medication.create`.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: createMedicationSchema } },
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Created medication with its schedules.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationDetailEntry,
|
||||
"CreateMedicationResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/layout": {
|
||||
get: {
|
||||
tags: ["Medications"],
|
||||
summary: "Read the calling user's medications list presentation",
|
||||
description:
|
||||
"Returns the per-user /medications presentation (card/table view + manual order). Falls back to the defaults (cards, empty order) when the user has not customised it. Mirrors the insights-layout contract.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The resolved presentation (custom or default).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationListLayoutSchema,
|
||||
"MedicationListLayoutResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["Medications"],
|
||||
summary: "Update the calling user's medications list presentation",
|
||||
description:
|
||||
"Field-scoped update: `view` and `order` are each optional, and whichever the body omits is preserved from the stored blob — a view toggle can never wipe the manual order and vice versa. The normalised presentation is returned. Invalid bodies return the multi-issue 422 envelope.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: medicationListLayoutSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"Presentation saved; the normalised blob is echoed back.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationListLayoutSchema,
|
||||
"MedicationListLayoutSaved",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Medications"],
|
||||
summary: "Reset the calling user's medications list presentation",
|
||||
description:
|
||||
"Clears the persisted presentation and returns the defaults (cards, empty order). Idempotent.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Presentation reset; the defaults are returned.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationListLayoutSchema,
|
||||
"MedicationListLayoutReset",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}": {
|
||||
get: {
|
||||
tags: ["Medications"],
|
||||
summary: "Fetch a single medication",
|
||||
description:
|
||||
"Returns the medication + its schedules + the joined `category`. Cross-user rows surface as 404 (existence channel sealed).",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Medication detail.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationDetailEntry,
|
||||
"GetMedicationResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["Medications"],
|
||||
summary: "Replace a medication (partial fields)",
|
||||
description:
|
||||
"Every field on the body is optional; omitted fields are left untouched. Supplying `schedules` REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping `active` to false stamps `pausedAt`; flipping back to true clears it. v1.5 invariants on the `schedules` array match `POST /api/medications`. Audits as `medication.update`.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: updateMedicationSchema } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Updated medication.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationDetailEntry,
|
||||
"UpdateMedicationResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Medications"],
|
||||
summary: "Delete a medication",
|
||||
description:
|
||||
"Cascades to the medication's schedules, intake events, dose changes, inventory rows, and side-effect logs. Revokes every API token scoped to `medication:<id>:ingest`. Audits as `medication.delete`.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Deletion succeeded.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ deleted: z.boolean() }),
|
||||
"DeleteMedicationResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/intake": {
|
||||
post: {
|
||||
tags: ["Medications"],
|
||||
summary: "Log an intake event for a medication",
|
||||
description:
|
||||
"Records a taken or skipped dose. Idempotent via the `Idempotency-Key` header AND the optional `idempotencyKey` body field (the route walks both paths); a re-post inside the 60 s server-side dedup window returns the original event. Non-skipped intakes auto-decrement pen inventory (best-effort), refresh the per-day compliance rollup, and — for `oneShot:true` medications — flip `active` to false.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: intakeSchema } },
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Intake event created.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationIntakeEventResource,
|
||||
"CreateMedicationIntakeResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"200": {
|
||||
description:
|
||||
"Idempotent replay — the original event is returned without creating a new row.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationIntakeEventResource,
|
||||
"ReplayMedicationIntakeResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/inventory": {
|
||||
get: {
|
||||
tags: ["Medications"],
|
||||
summary: "List a medication's supply containers",
|
||||
description:
|
||||
"Returns every inventory item (all states) for the medication, ordered by state, then `expiresAt`, then `createdAt`. Items count UNITS; divide by the medication's `unitsPerDose` for dose-level figures.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Inventory item list.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
items: z.array(medicationInventoryItemResource),
|
||||
meta: z.object({ total: z.number().int().nonnegative() }),
|
||||
}),
|
||||
"ListMedicationInventoryResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Medications"],
|
||||
summary: "Register a new supply container",
|
||||
description:
|
||||
"Creates an ACTIVE inventory item with `unitsRemaining = unitsTotal`. The request's `unitsTotal` field carries UNITS (1–1000). Rate-limited 30/min/user. Audits as `medication.inventory.create`.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: createInventoryItemSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Created inventory item.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationInventoryItemResource,
|
||||
"CreateMedicationInventoryItemResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/inventory/{itemId}": {
|
||||
patch: {
|
||||
tags: ["Medications"],
|
||||
summary: "Mutate a supply container",
|
||||
description:
|
||||
"Per-item operations: manual first-use (`markAsFirstUseAt`), used-up override (`markAsUsedUp`), printed-expiry correction, absolute remaining-unit correction (`unitsRemaining`, clamped to the item's capacity), notes. The canonical state machine re-derives the state after every mutation. Audits as `medication.inventory.update`.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string(), itemId: z.string() }),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: updateInventoryItemSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Updated inventory item.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationInventoryItemResource,
|
||||
"UpdateMedicationInventoryItemResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"Inventory item not found (or owned by another user / medication).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Medications"],
|
||||
summary: "Delete a supply container",
|
||||
description:
|
||||
"Hard-deletes the inventory item. The audit log captures the before-state (`medication.inventory.delete`) so a row can be reconstructed if needed. Consumption stamps on intake events that reference the item stay in place; a later restore skips the missing container.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string(), itemId: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Deletion succeeded.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ id: z.string(), deleted: z.boolean() }),
|
||||
"DeleteMedicationInventoryItemResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"Inventory item not found (or owned by another user / medication).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/extract": {
|
||||
post: {
|
||||
tags: ["Medications"],
|
||||
summary:
|
||||
"Extract scheduling fields from a free-text medication description",
|
||||
description:
|
||||
"Runs the user's free-text description through the Coach provider chain and returns a citation-guarded partial payload the wizard merges onto whatever the user already typed. `name` and `dose` are dropped when not substring-matched in the original text so the wizard cannot land a hallucinated brand or dose. `cadenceKind` / `doseUnit` / `weekdays` are closed enums; numeric fields are clamped. Rate-limited 10 requests / 5 minutes / user, gated against the daily Coach token budget.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: medicationExtractRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Citation-guarded partial extraction.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationExtractionSchema,
|
||||
"MedicationExtractResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"502": {
|
||||
description:
|
||||
"Upstream provider returned an empty, unparseable, or off-schema reply.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"503": {
|
||||
description:
|
||||
"No AI provider configured for the calling user (or operator).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/cadence": {
|
||||
get: {
|
||||
tags: ["Medications"],
|
||||
summary: "Cadence + compliance read for a medication",
|
||||
description:
|
||||
"Returns the expected-vs-actual dose timeline for the requested window plus the four compliance chip values that drive the detail-page section. Pure computation — no writes. Day boundaries are resolved in the user's IANA timezone so a Tokyo user and a Berlin user see the same chips for the same medication. The `days` query parameter caps at 180.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
query: z.object({
|
||||
days: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(180)
|
||||
.optional()
|
||||
.describe("Window size in days (default 30, max 180)."),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Cadence response.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationCadenceResponse,
|
||||
"GetMedicationCadenceResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/compliance": {
|
||||
get: {
|
||||
tags: ["Medications"],
|
||||
summary: "Batched adherence read for every medication of the caller",
|
||||
description:
|
||||
"Returns one compact adherence row per medication the caller owns (active + paused), ordered by `createdAt DESC` — the single round trip the medication cards consume instead of fanning out one `/api/medications/{id}/compliance` request per card. Each row carries the 7-/30-day summaries and the cadence-scaled display block; the per-day grid stays on the per-medication endpoint. Pure computation — no writes. Served through the same per-medication server cache as the per-id read, so the two endpoints warm each other.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "One adherence row per medication.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.array(medicationComplianceSummaryEntry),
|
||||
"ListMedicationComplianceResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/compliance": {
|
||||
get: {
|
||||
tags: ["Medications"],
|
||||
summary: "Adherence read for a medication",
|
||||
description:
|
||||
"Returns the 7- and 30-day adherence summaries, the per-day compliance grid for the history glyph track, and the two-row display block. Pure computation — no writes. Day boundaries are resolved in the user's IANA timezone, and the expected-dose denominator is cadence-aware (RRULE / rolling / one-shot / PRN / cyclic) and clamped to the medication's `createdAt`. Read `compliance30` for the headline 30-day taken-vs-expected percentage; build the per-day glyph track from `dailyCompliance` (draw a cell only where `due === true`).",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Compliance response.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationComplianceResponse,
|
||||
"GetMedicationComplianceResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/schedule-revisions": {
|
||||
get: {
|
||||
tags: ["Medications"],
|
||||
summary: "List a medication's archived schedule eras",
|
||||
description:
|
||||
"Returns every archived schedule era (newest first) plus `currentSince`, the instant the live plan took over. The dose-history ledger and compliance tallies already mint past days against these eras; this read powers the Zeitplan-tab history timeline.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Era list.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
scheduleRevisionListResponse,
|
||||
"ListScheduleRevisionsResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Medications"],
|
||||
summary: "Append a manual schedule era (pre-tracking history)",
|
||||
description:
|
||||
"Records that the medication dosed at the given daily times during `[validFrom, validUntil)` — history from before the schedule was edited in the app. The era must end at or before the start of the live plan and must not overlap an existing era; violations return 422. The snapshot is shaped exactly like a write-path archive (`FREQ=DAILY`, window pulled to the min/max of the times), so every historical surface reads it transparently. Audits as `medication.schedule_revision.created`.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: scheduleRevisionCreateSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Manual era created.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
scheduleRevisionResource,
|
||||
"CreateScheduleRevisionResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/schedule-revisions/{revisionId}": {
|
||||
patch: {
|
||||
tags: ["Medications"],
|
||||
summary: "Correct a recorded schedule era",
|
||||
description:
|
||||
"Replaces an era's bounds and daily times. A `MANUAL` era updates in place; an `ARCHIVED` era stays as the immutable audit record and the correction is minted as a superseding `MANUAL` revision that takes its place in every historical surface (the response carries the correction's id). Validation mirrors the sibling POST: the era must end at or before the start of the live plan and must not overlap another active era; violations return 422. An era that has already been corrected refuses with 409. Audits as `medication.schedule_revision.updated`.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string(), revisionId: z.string() }),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: scheduleRevisionUpdateSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Era corrected.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
scheduleRevisionResource,
|
||||
"UpdateScheduleRevisionResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"Medication or revision not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"The revision has already been superseded by a correction.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Medications"],
|
||||
summary: "Delete a manually added schedule era",
|
||||
description:
|
||||
"Removes a `MANUAL` era — one appended through the sibling POST, or a correction minted by PATCH (deleting a correction restores the archived original it superseded). Write-path archives (`source: ARCHIVED`) are immutable history and refuse with 409. Audits as `medication.schedule_revision.deleted`.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string(), revisionId: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Deletion succeeded.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ deleted: z.boolean() }),
|
||||
"DeleteScheduleRevisionResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"Medication or revision not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"The revision is a write-path archive (`ARCHIVED`) and cannot be deleted.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
+20
-674
@@ -6,13 +6,7 @@
|
||||
* runtime request parsing, so the wire contract stays single-source.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import {
|
||||
createMedicationSchema,
|
||||
updateMedicationSchema,
|
||||
intakeSchema,
|
||||
createInventoryItemSchema,
|
||||
updateInventoryItemSchema,
|
||||
MEDICATION_CATEGORY_VALUES,
|
||||
MEDICATION_CONTAINER_TYPE_VALUES,
|
||||
MEDICATION_TREATMENT_CLASS_VALUES,
|
||||
@@ -23,11 +17,6 @@ import {
|
||||
MEDICATION_ORDER_ID_MAX_LENGTH,
|
||||
MEDICATION_ORDER_MAX_ENTRIES,
|
||||
} from "@/lib/medication-list-layout";
|
||||
import {
|
||||
scheduleRevisionCreateSchema,
|
||||
scheduleRevisionUpdateSchema,
|
||||
} from "@/lib/validations/schedule-revision";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
|
||||
// ── Medications (v1.5 scheduling) ────────────────────────────────────
|
||||
//
|
||||
@@ -42,13 +31,13 @@ import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
// schema description AND enforced by the route + a DB CHECK constraint
|
||||
// so iOS code-gen surfaces the mutual exclusion.
|
||||
|
||||
const medicationCategoryEnum = z.enum(MEDICATION_CATEGORY_VALUES).meta({
|
||||
export const medicationCategoryEnum = z.enum(MEDICATION_CATEGORY_VALUES).meta({
|
||||
id: "MedicationCategory",
|
||||
description:
|
||||
"Clinical taxonomy stored in the `medication_categories` side-table. Orthogonal to `MedicationTreatmentClass`.",
|
||||
});
|
||||
|
||||
const medicationTreatmentClassEnum = z
|
||||
export const medicationTreatmentClassEnum = z
|
||||
.enum(MEDICATION_TREATMENT_CLASS_VALUES)
|
||||
.meta({
|
||||
id: "MedicationTreatmentClass",
|
||||
@@ -56,7 +45,7 @@ const medicationTreatmentClassEnum = z
|
||||
"Prisma-level treatment-class discriminator. `GLP1` unlocks the GLP-1 specialist surfaces (injection-site rotation, titration history, pen inventory, GLP-1-aware Coach).",
|
||||
});
|
||||
|
||||
const medicationScheduleResource = z
|
||||
export const medicationScheduleResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
medicationId: z.string(),
|
||||
@@ -228,7 +217,7 @@ export const medicationResource = z
|
||||
"Server-shaped medication row returned by GET / POST / PUT endpoints. Carries the v1.5 course-window fields (`startsOn`, `endsOn`, `oneShot`) at the medication level and the per-schedule cadence fields on the nested `schedules` array.",
|
||||
});
|
||||
|
||||
const medicationListEntry = medicationResource
|
||||
export const medicationListEntry = medicationResource
|
||||
.extend({
|
||||
category: medicationCategoryEnum,
|
||||
lastTakenAt: z.iso
|
||||
@@ -265,7 +254,7 @@ const medicationListEntry = medicationResource
|
||||
"List-row variant of the medication resource enriched with the joined `category`, `lastTakenAt`, `todayEventCount`, and the v1.16.10 aggregated stock fields (`stockUnitsRemaining`, `stockDosesRemaining`) the dashboard + iOS client consume. The base medication fields (`id`, `name`, `dose`, `treatmentClass`, `dosesPerUnit`, `active`, `notificationsEnabled`, `pausedAt`, `snoozedUntil`, `startsOn`, `endsOn`, `oneShot`, `createdAt`, `updatedAt`, `schedules`) are inlined; see the `Medication` component for their semantics.",
|
||||
});
|
||||
|
||||
const medicationDetailEntry = medicationResource
|
||||
export const medicationDetailEntry = medicationResource
|
||||
.extend({
|
||||
category: medicationCategoryEnum,
|
||||
})
|
||||
@@ -279,7 +268,7 @@ const medicationDetailEntry = medicationResource
|
||||
// bottle). Counts UNITS; the medication's `unitsPerDose` maps units to
|
||||
// doses. The intake consumption hook decrements `unitsRemaining` per
|
||||
// taken dose and stamps the intake event with what it consumed.
|
||||
const medicationInventoryItemResource = z
|
||||
export const medicationInventoryItemResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
@@ -330,7 +319,7 @@ const medicationInventoryItemResource = z
|
||||
"One supply container (pen / blister pack / bottle) of a medication. Counts UNITS — the medication's `unitsPerDose` maps units to doses. The intake write paths consume from the open container first, then first-expiry-first-out over unopened stock.",
|
||||
});
|
||||
|
||||
const medicationIntakeEventResource = z
|
||||
export const medicationIntakeEventResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
@@ -348,7 +337,7 @@ const medicationIntakeEventResource = z
|
||||
"Single dose log row. `takenAt` is non-null for confirmed intakes; `skipped:true` represents a deliberately-missed dose (no inventory consumption).",
|
||||
});
|
||||
|
||||
const medicationCadenceTimelinePoint = z
|
||||
export const medicationCadenceTimelinePoint = z
|
||||
.object({
|
||||
day: z.iso.datetime({ offset: true }),
|
||||
windowStart: z.iso.datetime({ offset: true }),
|
||||
@@ -362,7 +351,7 @@ const medicationCadenceTimelinePoint = z
|
||||
"One expected-vs-actual dose slot for the cadence timeline chart. `status` is one of `taken | skipped | missed | pending | future` and drives the chip colour.",
|
||||
});
|
||||
|
||||
const medicationCadenceChips = z
|
||||
export const medicationCadenceChips = z
|
||||
.object({
|
||||
adherenceRate: z.number(),
|
||||
streakDays: z.number().int().nonnegative(),
|
||||
@@ -375,7 +364,7 @@ const medicationCadenceChips = z
|
||||
"Four compliance summary values for the medication detail page chip row.",
|
||||
});
|
||||
|
||||
const medicationCadenceResponse = z
|
||||
export const medicationCadenceResponse = z
|
||||
.object({
|
||||
windowDays: z.number().int().positive(),
|
||||
anchorIso: z.iso.datetime({ offset: true }),
|
||||
@@ -395,7 +384,7 @@ const medicationCadenceResponse = z
|
||||
"Cadence + compliance read for a single medication. `next` is the upcoming-dose envelope (null when the course has ended or the rolling clock has no pinning intake yet); `timeline` walks the requested `windowDays` worth of slots in ascending time order.",
|
||||
});
|
||||
|
||||
const complianceResult = z
|
||||
export const complianceResult = z
|
||||
.object({
|
||||
totalExpected: z
|
||||
.number()
|
||||
@@ -433,7 +422,7 @@ const complianceResult = z
|
||||
"Rolling-window adherence summary. `compliance30` is the authoritative 'last 30 days, taken vs expected' read — clients should display `rate` and use `totalExpected` as the denominator rather than re-deriving it from the daily map.",
|
||||
});
|
||||
|
||||
const dailyComplianceEntry = z
|
||||
export const dailyComplianceEntry = z
|
||||
.object({
|
||||
expected: z
|
||||
.number()
|
||||
@@ -480,7 +469,7 @@ const dailyComplianceEntry = z
|
||||
"Per-day compliance cell with the timing breakdown that drives the history glyph track.",
|
||||
});
|
||||
|
||||
const complianceDisplay = z
|
||||
export const complianceDisplay = z
|
||||
.object({
|
||||
shortDays: z.number().int().positive(),
|
||||
longDays: z.number().int().positive(),
|
||||
@@ -584,7 +573,7 @@ const complianceDisplay = z
|
||||
"The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that.",
|
||||
});
|
||||
|
||||
const medicationComplianceResponse = z
|
||||
export const medicationComplianceResponse = z
|
||||
.object({
|
||||
compliance7: complianceResult,
|
||||
compliance30: complianceResult,
|
||||
@@ -601,7 +590,7 @@ const medicationComplianceResponse = z
|
||||
"Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year series used elsewhere for AI prompts does NOT apply here — this response is never downsampled.",
|
||||
});
|
||||
|
||||
const medicationComplianceSummaryEntry = z
|
||||
export const medicationComplianceSummaryEntry = z
|
||||
.object({
|
||||
medicationId: z.string(),
|
||||
compliance7: complianceResult,
|
||||
@@ -618,7 +607,7 @@ const medicationComplianceSummaryEntry = z
|
||||
// Archived eras come from two provenances: the wholesale-replace write
|
||||
// path (`ARCHIVED`, immutable) and the user-entered pre-tracking flow
|
||||
// (`MANUAL`, deletable through the `[revisionId]` DELETE).
|
||||
const scheduleRevisionEntrySummary = z
|
||||
export const scheduleRevisionEntrySummary = z
|
||||
.object({
|
||||
timesOfDay: z
|
||||
.array(z.string())
|
||||
@@ -637,7 +626,7 @@ const scheduleRevisionEntrySummary = z
|
||||
"Display summary of one archived schedule row inside an era. The full snapshot (windows, rrule, doseWindows, …) stays server-side; this projection carries what the timeline renders.",
|
||||
});
|
||||
|
||||
const scheduleRevisionResource = z
|
||||
export const scheduleRevisionResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
validFrom: z.iso.datetime().describe("Inclusive start instant of the era."),
|
||||
@@ -659,7 +648,7 @@ const scheduleRevisionResource = z
|
||||
"One archived schedule era covering `[validFrom, validUntil)`. The dose-history ledger, compliance tallies, and cadence chips mint past days against the era that was live then.",
|
||||
});
|
||||
|
||||
const scheduleRevisionListResponse = z.object({
|
||||
export const scheduleRevisionListResponse = z.object({
|
||||
currentSince: z.iso
|
||||
.datetime()
|
||||
.describe(
|
||||
@@ -676,7 +665,7 @@ const scheduleRevisionListResponse = z.object({
|
||||
// the user already typed. Citation-guarded (`name` and `dose` are
|
||||
// dropped when not substring-matched in the original text) and
|
||||
// closed-enum-validated.
|
||||
const medicationExtractRequest = z
|
||||
export const medicationExtractRequest = z
|
||||
.object({
|
||||
text: z
|
||||
.string()
|
||||
@@ -712,7 +701,7 @@ medicationExtractionSchema.meta({
|
||||
// v1.16.10 — medications list presentation (cards/table view + manual
|
||||
// order), persisted per user in its own `User` column following the
|
||||
// dashboard-widgets / insights-layout per-surface convention.
|
||||
const medicationListLayoutSchema = z
|
||||
export const medicationListLayoutSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
view: z
|
||||
@@ -734,646 +723,3 @@ const medicationListLayoutSchema = z
|
||||
description:
|
||||
"Per-user /medications presentation: the card/table view choice plus the manual medication order shared by both views. Mirrors the dashboard-widgets / insights-layout contract.",
|
||||
});
|
||||
|
||||
export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/medications": {
|
||||
get: {
|
||||
tags: ["Medications"],
|
||||
summary: "List medications for the calling user",
|
||||
description:
|
||||
"Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the count of today's actioned intake events (`todayEventCount`). The response is cached server-side for 60 s per user; writes flush the cache.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Medication list.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.array(medicationListEntry),
|
||||
"ListMedicationsResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Medications"],
|
||||
summary: "Create a medication with at least one schedule",
|
||||
description:
|
||||
"Validates the body against `CreateMedicationRequest`, applies the v1.5 cross-field invariants (one-shot consistency, recurring default `FREQ=DAILY`, `timesOfDay` dual-write), and creates the medication + its schedules in a single Prisma write. Audits as `medication.create`.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: createMedicationSchema } },
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Created medication with its schedules.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationDetailEntry,
|
||||
"CreateMedicationResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/layout": {
|
||||
get: {
|
||||
tags: ["Medications"],
|
||||
summary: "Read the calling user's medications list presentation",
|
||||
description:
|
||||
"Returns the per-user /medications presentation (card/table view + manual order). Falls back to the defaults (cards, empty order) when the user has not customised it. Mirrors the insights-layout contract.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The resolved presentation (custom or default).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationListLayoutSchema,
|
||||
"MedicationListLayoutResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["Medications"],
|
||||
summary: "Update the calling user's medications list presentation",
|
||||
description:
|
||||
"Field-scoped update: `view` and `order` are each optional, and whichever the body omits is preserved from the stored blob — a view toggle can never wipe the manual order and vice versa. The normalised presentation is returned. Invalid bodies return the multi-issue 422 envelope.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: medicationListLayoutSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"Presentation saved; the normalised blob is echoed back.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationListLayoutSchema,
|
||||
"MedicationListLayoutSaved",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Medications"],
|
||||
summary: "Reset the calling user's medications list presentation",
|
||||
description:
|
||||
"Clears the persisted presentation and returns the defaults (cards, empty order). Idempotent.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Presentation reset; the defaults are returned.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationListLayoutSchema,
|
||||
"MedicationListLayoutReset",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}": {
|
||||
get: {
|
||||
tags: ["Medications"],
|
||||
summary: "Fetch a single medication",
|
||||
description:
|
||||
"Returns the medication + its schedules + the joined `category`. Cross-user rows surface as 404 (existence channel sealed).",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Medication detail.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationDetailEntry,
|
||||
"GetMedicationResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["Medications"],
|
||||
summary: "Replace a medication (partial fields)",
|
||||
description:
|
||||
"Every field on the body is optional; omitted fields are left untouched. Supplying `schedules` REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping `active` to false stamps `pausedAt`; flipping back to true clears it. v1.5 invariants on the `schedules` array match `POST /api/medications`. Audits as `medication.update`.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: updateMedicationSchema } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Updated medication.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationDetailEntry,
|
||||
"UpdateMedicationResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Medications"],
|
||||
summary: "Delete a medication",
|
||||
description:
|
||||
"Cascades to the medication's schedules, intake events, dose changes, inventory rows, and side-effect logs. Revokes every API token scoped to `medication:<id>:ingest`. Audits as `medication.delete`.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Deletion succeeded.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ deleted: z.boolean() }),
|
||||
"DeleteMedicationResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/intake": {
|
||||
post: {
|
||||
tags: ["Medications"],
|
||||
summary: "Log an intake event for a medication",
|
||||
description:
|
||||
"Records a taken or skipped dose. Idempotent via the `Idempotency-Key` header AND the optional `idempotencyKey` body field (the route walks both paths); a re-post inside the 60 s server-side dedup window returns the original event. Non-skipped intakes auto-decrement pen inventory (best-effort), refresh the per-day compliance rollup, and — for `oneShot:true` medications — flip `active` to false.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: intakeSchema } },
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Intake event created.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationIntakeEventResource,
|
||||
"CreateMedicationIntakeResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"200": {
|
||||
description:
|
||||
"Idempotent replay — the original event is returned without creating a new row.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationIntakeEventResource,
|
||||
"ReplayMedicationIntakeResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/inventory": {
|
||||
get: {
|
||||
tags: ["Medications"],
|
||||
summary: "List a medication's supply containers",
|
||||
description:
|
||||
"Returns every inventory item (all states) for the medication, ordered by state, then `expiresAt`, then `createdAt`. Items count UNITS; divide by the medication's `unitsPerDose` for dose-level figures.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Inventory item list.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
items: z.array(medicationInventoryItemResource),
|
||||
meta: z.object({ total: z.number().int().nonnegative() }),
|
||||
}),
|
||||
"ListMedicationInventoryResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Medications"],
|
||||
summary: "Register a new supply container",
|
||||
description:
|
||||
"Creates an ACTIVE inventory item with `unitsRemaining = unitsTotal`. The request's `unitsTotal` field carries UNITS (1–1000). Rate-limited 30/min/user. Audits as `medication.inventory.create`.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: createInventoryItemSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Created inventory item.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationInventoryItemResource,
|
||||
"CreateMedicationInventoryItemResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/inventory/{itemId}": {
|
||||
patch: {
|
||||
tags: ["Medications"],
|
||||
summary: "Mutate a supply container",
|
||||
description:
|
||||
"Per-item operations: manual first-use (`markAsFirstUseAt`), used-up override (`markAsUsedUp`), printed-expiry correction, absolute remaining-unit correction (`unitsRemaining`, clamped to the item's capacity), notes. The canonical state machine re-derives the state after every mutation. Audits as `medication.inventory.update`.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string(), itemId: z.string() }),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: updateInventoryItemSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Updated inventory item.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationInventoryItemResource,
|
||||
"UpdateMedicationInventoryItemResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"Inventory item not found (or owned by another user / medication).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Medications"],
|
||||
summary: "Delete a supply container",
|
||||
description:
|
||||
"Hard-deletes the inventory item. The audit log captures the before-state (`medication.inventory.delete`) so a row can be reconstructed if needed. Consumption stamps on intake events that reference the item stay in place; a later restore skips the missing container.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string(), itemId: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Deletion succeeded.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ id: z.string(), deleted: z.boolean() }),
|
||||
"DeleteMedicationInventoryItemResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"Inventory item not found (or owned by another user / medication).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/extract": {
|
||||
post: {
|
||||
tags: ["Medications"],
|
||||
summary:
|
||||
"Extract scheduling fields from a free-text medication description",
|
||||
description:
|
||||
"Runs the user's free-text description through the Coach provider chain and returns a citation-guarded partial payload the wizard merges onto whatever the user already typed. `name` and `dose` are dropped when not substring-matched in the original text so the wizard cannot land a hallucinated brand or dose. `cadenceKind` / `doseUnit` / `weekdays` are closed enums; numeric fields are clamped. Rate-limited 10 requests / 5 minutes / user, gated against the daily Coach token budget.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: medicationExtractRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Citation-guarded partial extraction.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationExtractionSchema,
|
||||
"MedicationExtractResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"502": {
|
||||
description:
|
||||
"Upstream provider returned an empty, unparseable, or off-schema reply.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"503": {
|
||||
description:
|
||||
"No AI provider configured for the calling user (or operator).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/cadence": {
|
||||
get: {
|
||||
tags: ["Medications"],
|
||||
summary: "Cadence + compliance read for a medication",
|
||||
description:
|
||||
"Returns the expected-vs-actual dose timeline for the requested window plus the four compliance chip values that drive the detail-page section. Pure computation — no writes. Day boundaries are resolved in the user's IANA timezone so a Tokyo user and a Berlin user see the same chips for the same medication. The `days` query parameter caps at 180.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
query: z.object({
|
||||
days: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(180)
|
||||
.optional()
|
||||
.describe("Window size in days (default 30, max 180)."),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Cadence response.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationCadenceResponse,
|
||||
"GetMedicationCadenceResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/compliance": {
|
||||
get: {
|
||||
tags: ["Medications"],
|
||||
summary: "Batched adherence read for every medication of the caller",
|
||||
description:
|
||||
"Returns one compact adherence row per medication the caller owns (active + paused), ordered by `createdAt DESC` — the single round trip the medication cards consume instead of fanning out one `/api/medications/{id}/compliance` request per card. Each row carries the 7-/30-day summaries and the cadence-scaled display block; the per-day grid stays on the per-medication endpoint. Pure computation — no writes. Served through the same per-medication server cache as the per-id read, so the two endpoints warm each other.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "One adherence row per medication.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.array(medicationComplianceSummaryEntry),
|
||||
"ListMedicationComplianceResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/compliance": {
|
||||
get: {
|
||||
tags: ["Medications"],
|
||||
summary: "Adherence read for a medication",
|
||||
description:
|
||||
"Returns the 7- and 30-day adherence summaries, the per-day compliance grid for the history glyph track, and the two-row display block. Pure computation — no writes. Day boundaries are resolved in the user's IANA timezone, and the expected-dose denominator is cadence-aware (RRULE / rolling / one-shot / PRN / cyclic) and clamped to the medication's `createdAt`. Read `compliance30` for the headline 30-day taken-vs-expected percentage; build the per-day glyph track from `dailyCompliance` (draw a cell only where `due === true`).",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Compliance response.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationComplianceResponse,
|
||||
"GetMedicationComplianceResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/schedule-revisions": {
|
||||
get: {
|
||||
tags: ["Medications"],
|
||||
summary: "List a medication's archived schedule eras",
|
||||
description:
|
||||
"Returns every archived schedule era (newest first) plus `currentSince`, the instant the live plan took over. The dose-history ledger and compliance tallies already mint past days against these eras; this read powers the Zeitplan-tab history timeline.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Era list.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
scheduleRevisionListResponse,
|
||||
"ListScheduleRevisionsResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Medications"],
|
||||
summary: "Append a manual schedule era (pre-tracking history)",
|
||||
description:
|
||||
"Records that the medication dosed at the given daily times during `[validFrom, validUntil)` — history from before the schedule was edited in the app. The era must end at or before the start of the live plan and must not overlap an existing era; violations return 422. The snapshot is shaped exactly like a write-path archive (`FREQ=DAILY`, window pulled to the min/max of the times), so every historical surface reads it transparently. Audits as `medication.schedule_revision.created`.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: scheduleRevisionCreateSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Manual era created.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
scheduleRevisionResource,
|
||||
"CreateScheduleRevisionResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/schedule-revisions/{revisionId}": {
|
||||
patch: {
|
||||
tags: ["Medications"],
|
||||
summary: "Correct a recorded schedule era",
|
||||
description:
|
||||
"Replaces an era's bounds and daily times. A `MANUAL` era updates in place; an `ARCHIVED` era stays as the immutable audit record and the correction is minted as a superseding `MANUAL` revision that takes its place in every historical surface (the response carries the correction's id). Validation mirrors the sibling POST: the era must end at or before the start of the live plan and must not overlap another active era; violations return 422. An era that has already been corrected refuses with 409. Audits as `medication.schedule_revision.updated`.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string(), revisionId: z.string() }),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: scheduleRevisionUpdateSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Era corrected.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
scheduleRevisionResource,
|
||||
"UpdateScheduleRevisionResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"Medication or revision not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"The revision has already been superseded by a correction.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Medications"],
|
||||
summary: "Delete a manually added schedule era",
|
||||
description:
|
||||
"Removes a `MANUAL` era — one appended through the sibling POST, or a correction minted by PATCH (deleting a correction restores the archived original it superseded). Write-path archives (`source: ARCHIVED`) are immutable history and refuse with 409. Audits as `medication.schedule_revision.deleted`.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string(), revisionId: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Deletion succeeded.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ deleted: z.boolean() }),
|
||||
"DeleteScheduleRevisionResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"Medication or revision not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"The revision is a write-path archive (`ARCHIVED`) and cannot be deleted.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,226 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { INJECTION_SITE_KEYS } from "@/lib/medications/injection-sites";
|
||||
import { validateEntryInstant } from "@/lib/validations/entry-instant";
|
||||
|
||||
/**
|
||||
* v1.8.5 — the eight injection-site enum values, mirrored from the
|
||||
* Prisma `InjectionSite` enum via the shared `INJECTION_SITE_KEYS`
|
||||
* tuple. Reused by the intake + bulk-intake request schemas (the
|
||||
* `injectionSite` field) and the per-medication allowed-sites editor.
|
||||
*/
|
||||
export const INJECTION_SITE_VALUES = INJECTION_SITE_KEYS;
|
||||
export type InjectionSiteValue = (typeof INJECTION_SITE_VALUES)[number];
|
||||
|
||||
/** Zod enum over the eight injection sites. */
|
||||
export const injectionSiteEnum = z.enum(INJECTION_SITE_VALUES);
|
||||
|
||||
export const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
|
||||
/**
|
||||
* v1.9.0 — drug-classification code formats.
|
||||
*
|
||||
* `ATC_CODE_REGEX` — the 7-character WHO ATC code (one letter, two
|
||||
* digits, two letters, two digits, e.g. `A10BX10`). This is the
|
||||
* full leaf-level substance class; the shorter anatomical-group
|
||||
* prefixes (`A`, `A10`, `A10B`) are deliberately NOT accepted — the
|
||||
* exporter emits a substance-class coding, not a group.
|
||||
*
|
||||
* `RXCUI_REGEX` — the RxNorm RxCUI is a bare positive integer string
|
||||
* (e.g. `2601723`).
|
||||
*
|
||||
* Both fields are user/clinician-asserted and never machine-guessed;
|
||||
* a malformed value is rejected with 422 rather than silently stored.
|
||||
*/
|
||||
const ATC_CODE_REGEX = /^[A-Z]\d{2}[A-Z]{2}\d{2}$/;
|
||||
const RXCUI_REGEX = /^\d+$/;
|
||||
|
||||
/**
|
||||
* v1.9.0 — reusable nullable/optional code-field validators shared by
|
||||
* the create + update medication schemas. `null` clears the column;
|
||||
* `undefined` leaves it untouched on update.
|
||||
*/
|
||||
export const atcCodeField = z
|
||||
.string()
|
||||
.regex(ATC_CODE_REGEX, "Invalid ATC code (expected e.g. A10BX10)")
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional WHO ATC classification code (active-substance class, 7 chars, e.g. `A10BX10`). User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under `http://www.whocc.no/atc`. NULL clears it; absent leaves it untouched.",
|
||||
);
|
||||
export const rxNormCodeField = z
|
||||
.string()
|
||||
.regex(RXCUI_REGEX, "Invalid RxNorm code (expected a numeric RxCUI)")
|
||||
// Defence-in-depth: the `^\d+$` regex is otherwise unbounded. Real
|
||||
// RxCUIs are at most 7 digits today; 20 is a generous ceiling that
|
||||
// still bounds the column write tightly. (`atcCode` needs no max —
|
||||
// its regex already fixes the length at 7.)
|
||||
.max(20, "RxNorm code is too long")
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary coding emitted under `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any ATC code, never instead of the free-text name. NULL clears it; absent leaves it untouched.",
|
||||
);
|
||||
/**
|
||||
* Clinical-category values stored in the `medication_categories` side-
|
||||
* table (TEXT column). v1.5.4 adds `DIABETES` and `ANTIBIOTIC` so the
|
||||
* wizard's Step 2 taxonomy can write a first-class bucket for those
|
||||
* two rows instead of collapsing them into `OTHER`. The column is a
|
||||
* plain TEXT field — no Prisma enum exists to migrate; the Zod values
|
||||
* list is the only enforcement layer.
|
||||
*/
|
||||
export const MEDICATION_CATEGORY_VALUES = [
|
||||
"BLOOD_PRESSURE",
|
||||
"VITAMIN",
|
||||
"SUPPLEMENT",
|
||||
"PAIN_RELIEF",
|
||||
"ALLERGY",
|
||||
"DIGESTIVE",
|
||||
"THYROID",
|
||||
"HORMONE",
|
||||
"SKIN",
|
||||
"SLEEP_AID",
|
||||
"DIABETES",
|
||||
"ANTIBIOTIC",
|
||||
"OTHER",
|
||||
] as const;
|
||||
export type MedicationCategoryValue =
|
||||
(typeof MEDICATION_CATEGORY_VALUES)[number];
|
||||
|
||||
/**
|
||||
* v1.4.25 W4d — Prisma-level treatment class. Orthogonal to
|
||||
* `MEDICATION_CATEGORY_VALUES` (the clinical taxonomy that lives in the
|
||||
* `medication_categories` side-table). GLP1 turns on the GLP-1
|
||||
* specialist surfaces — injection-site picker, titration history, pen
|
||||
* inventory, GLP-1-aware Coach replies. Future treatment classes drop
|
||||
* into this list (INSULIN, BIOLOGIC, FERTILITY, …).
|
||||
*/
|
||||
export const MEDICATION_TREATMENT_CLASS_VALUES = ["GENERIC", "GLP1"] as const;
|
||||
export type MedicationTreatmentClass =
|
||||
(typeof MEDICATION_TREATMENT_CLASS_VALUES)[number];
|
||||
|
||||
/**
|
||||
* v1.16.12 (#316) — fractional dosing. A dose may consume a sub-unit
|
||||
* fraction of a tablet (split pills). The UI offers a CURATED set of
|
||||
* common fractions (¼ ⅓ ½ ⅔ ¾), stored as their decimal value; thirds
|
||||
* are inexact in decimal (⅓ ≈ 0.3333, ⅔ ≈ 0.6667) — the @db.Decimal(10,4)
|
||||
* column and the runway floor absorb the sub-0.0001 drift. Whole numbers
|
||||
* 1..100 (multi-tablet doses, the pre-v1.16.12 contract) stay valid
|
||||
* alongside the fractions. One source of truth — the UI selector and the
|
||||
* tests import this set so the allowed values can never drift from the
|
||||
* validator.
|
||||
*/
|
||||
export const UNITS_PER_DOSE_FRACTIONS = [
|
||||
0.25, 0.3333, 0.5, 0.6667, 0.75,
|
||||
] as const;
|
||||
export const UNITS_PER_DOSE_MAX_WHOLE = 100;
|
||||
|
||||
export function isSupportedUnitsPerDose(value: number): boolean {
|
||||
if ((UNITS_PER_DOSE_FRACTIONS as readonly number[]).includes(value)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
Number.isInteger(value) && value >= 1 && value <= UNITS_PER_DOSE_MAX_WHOLE
|
||||
);
|
||||
}
|
||||
|
||||
export const UNITS_PER_DOSE_MESSAGE =
|
||||
"unitsPerDose must be a whole number 1–100 or a supported fraction (¼, ⅓, ½, ⅔, ¾)";
|
||||
|
||||
/**
|
||||
* v1.6.0 — route of administration. Decoupled from `treatmentClass`:
|
||||
* the injection-site picker surfaces for any `INJECTION` dose, and a
|
||||
* one-time injection is `oneShot: true` + `deliveryForm: "INJECTION"`.
|
||||
* ORAL is the default the migration backfilled onto every existing row.
|
||||
*/
|
||||
export const MEDICATION_DELIVERY_FORM_VALUES = [
|
||||
"ORAL",
|
||||
"INJECTION",
|
||||
"OTHER",
|
||||
] as const;
|
||||
export type MedicationDeliveryForm =
|
||||
(typeof MEDICATION_DELIVERY_FORM_VALUES)[number];
|
||||
|
||||
/**
|
||||
* v1.5 — RRULE string shape check.
|
||||
*
|
||||
* Not a full RFC 5545 parser (that lives in the `rrule` npm package on
|
||||
* the server side). The regex catches the subset HealthLog mints from
|
||||
* the wizard / form: a `FREQ=` line with optional `INTERVAL=`,
|
||||
* `BYDAY=`, `BYMONTHDAY=`, `BYMONTH=`, `COUNT=`, `UNTIL=` properties
|
||||
* separated by `;`. Any pathological-looking shape (the `FREQ=SECONDLY`
|
||||
* DoS surface, `;COUNT=999999999`) is filtered at the server route
|
||||
* layer with a hard cap on emitted occurrences.
|
||||
*/
|
||||
export const RRULE_PROPS =
|
||||
/^FREQ=(?:DAILY|WEEKLY|MONTHLY|YEARLY)(?:;(?:INTERVAL=\d{1,3}|BYDAY=(?:MO|TU|WE|TH|FR|SA|SU)(?:,(?:MO|TU|WE|TH|FR|SA|SU))*|BYMONTHDAY=-?\d{1,2}(?:,-?\d{1,2})*|BYMONTH=\d{1,2}(?:,\d{1,2})*|COUNT=\d{1,4}|UNTIL=\d{8}T\d{6}Z))*$/;
|
||||
|
||||
/**
|
||||
* v1.15.18 — one explicit per-dose on-time window. `timeOfDay` keys the dose
|
||||
* the window applies to; `start`/`end` are the HH:mm on-time bounds in the
|
||||
* user's wall clock. `start <= end` within the day (an overnight window is not
|
||||
* a configurable on-time band — the late tail owns the cross-midnight tail).
|
||||
*/
|
||||
export const doseWindowEntrySchema = z
|
||||
.object({
|
||||
timeOfDay: z
|
||||
.string()
|
||||
.regex(timeRegex, "Format: HH:mm")
|
||||
.describe(
|
||||
"The dose time this window applies to (matches a `timesOfDay` entry).",
|
||||
),
|
||||
start: z
|
||||
.string()
|
||||
.regex(timeRegex, "Format: HH:mm")
|
||||
.describe("On-time band lower bound (HH:mm, user local)."),
|
||||
end: z
|
||||
.string()
|
||||
.regex(timeRegex, "Format: HH:mm")
|
||||
.describe(
|
||||
"On-time band upper bound (HH:mm, user local). Must be >= `start`.",
|
||||
),
|
||||
})
|
||||
.refine((w) => hhmmToMinutes(w.start) <= hhmmToMinutes(w.end), {
|
||||
message: "Window start must be on or before end (same day)",
|
||||
path: ["end"],
|
||||
})
|
||||
.meta({
|
||||
id: "DoseWindowEntry",
|
||||
description:
|
||||
"One explicit per-dose on-time intake window. `timeOfDay` matches a schedule dose time; `[start, end]` (HH:mm, user local, `start <= end`) is the on-time band. Outside it the cadence-derived late tail applies, then ad-hoc.",
|
||||
});
|
||||
|
||||
/** Minutes-since-midnight for an `HH:mm` literal (already regex-validated). */
|
||||
export function hhmmToMinutes(hhmm: string): number {
|
||||
const [h, m] = hhmm.split(":");
|
||||
return Number(h) * 60 + Number(m);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.15.19 introduced `takenAt` plausibility bounds on the EDIT path
|
||||
* (audit P0-4); v1.16.9 lifts them onto every CREATE path too (the
|
||||
* per-medication intake POST, the bulk endpoint, the external ingest) so
|
||||
* the retro-add dialog and a hand-rolled API call can't insert a row a
|
||||
* decade in the past or hours in the future. Future allowance covers
|
||||
* client clock skew; the 5-year floor matches the GLP-1 dose-change
|
||||
* validator's window. Slot-distance / medication-start checks stay in
|
||||
* the routes — the schema cannot see the medication.
|
||||
*/
|
||||
export const TAKEN_AT_MAX_AGE_MS = 5 * 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* ISO `takenAt` → `Date` with the plausibility bounds applied.
|
||||
*
|
||||
* v1.17 W1b — delegates to the shared `validateEntryInstant` helper (the
|
||||
* same bound now guarding `measuredAt` + `moodLoggedAt`), passing the
|
||||
* five-year window so a stale backdated dose is rejected well before the
|
||||
* 1900 floor. The 5-min clock-skew tolerance is the shared default.
|
||||
*/
|
||||
export const boundedTakenAtSchema = validateEntryInstant(
|
||||
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
{
|
||||
maxAgeMs: TAKEN_AT_MAX_AGE_MS,
|
||||
// Preserve the established wording so the iOS contract + tests stay stable.
|
||||
pastMessage: "takenAt must be within the last 5 years",
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,338 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
MEDICATION_CATEGORY_VALUES,
|
||||
MEDICATION_DELIVERY_FORM_VALUES,
|
||||
MEDICATION_TREATMENT_CLASS_VALUES,
|
||||
UNITS_PER_DOSE_MESSAGE,
|
||||
atcCodeField,
|
||||
injectionSiteEnum,
|
||||
isSupportedUnitsPerDose,
|
||||
INJECTION_SITE_VALUES,
|
||||
rxNormCodeField,
|
||||
} from "./base";
|
||||
import { scheduleSchema } from "./schedule";
|
||||
|
||||
/**
|
||||
* v1.5 — medication-level course window + one-shot flag. The fields
|
||||
* are optional on the create path so the existing form (no wizard
|
||||
* yet) keeps working. The route layer enforces the `oneShot` + at-
|
||||
* least-one-schedule invariant for the wizard path.
|
||||
*/
|
||||
const courseWindowFields = {
|
||||
/**
|
||||
* Date the medication course begins. ISO YYYY-MM-DD. NULL = "from
|
||||
* creation" (the legacy implicit anchor).
|
||||
*/
|
||||
startsOn: z.iso
|
||||
.date()
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Date the medication course begins (ISO `YYYY-MM-DD`). Anchors RRULE BYDAY / BYMONTHDAY patterns and the rolling-interval countdown's first window. NULL = active from creation (the legacy implicit behaviour). Required when `oneShot` is true.",
|
||||
),
|
||||
/**
|
||||
* Date the medication course ends. ISO YYYY-MM-DD. NULL = chronic.
|
||||
* Required to equal `startsOn` when `oneShot` is true (the route
|
||||
* normalises this; the schema doesn't because they're sister
|
||||
* fields and the cross-field refine is enforced at the route).
|
||||
*/
|
||||
endsOn: z.iso
|
||||
.date()
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Date the medication course ends (ISO `YYYY-MM-DD`). NULL = no end date (chronic). When `oneShot` is true the route normalises `endsOn` to equal `startsOn`.",
|
||||
),
|
||||
/**
|
||||
* Single-administration medication. Auto-deactivates after the
|
||||
* single intake is logged.
|
||||
*/
|
||||
oneShot: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Single-administration medication (e.g. flu shot, post-op single dose). When true the medication has at most one schedule with no `rrule` / `rollingIntervalDays`, and `active` auto-flips to false once the dose is logged (non-skipped).",
|
||||
),
|
||||
/**
|
||||
* v1.16.11 (#316) — as-needed (PRN) medication. No fixed schedule:
|
||||
* never due, never reminded, excluded from compliance, active
|
||||
* indefinitely. Intakes log ad-hoc and consume inventory. Carries
|
||||
* ZERO schedules — a `schedules` array alongside `asNeeded: true` is
|
||||
* a 422. Mutually exclusive with `oneShot`.
|
||||
*/
|
||||
asNeeded: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"As-needed (PRN) medication (pain relief, rescue inhaler). When true the medication carries NO schedules (supplying any schedule entry is a 422): it is never due, never reminded, and excluded from compliance rates/streaks, but intakes still log (ad-hoc), inventory still consumes, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`.",
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.16.11 — shared as-needed cross-field refines for the create +
|
||||
* update bodies: `asNeeded` and `oneShot` are mutually exclusive, and
|
||||
* an as-needed medication cannot carry schedule entries.
|
||||
*/
|
||||
const AS_NEEDED_ONE_SHOT_MESSAGE =
|
||||
"asNeeded and oneShot are mutually exclusive";
|
||||
const AS_NEEDED_SCHEDULES_MESSAGE =
|
||||
"An as-needed medication cannot carry schedules";
|
||||
|
||||
export const createMedicationSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(100),
|
||||
dose: z.string().min(1).max(50),
|
||||
category: z.enum(MEDICATION_CATEGORY_VALUES).optional(),
|
||||
/** v1.4.25 W4d — treatment-class discriminator (GENERIC | GLP1). */
|
||||
treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(),
|
||||
/** v1.4.25 W4d — doses per pen/vial for inventory tracking.
|
||||
* v1.16.10 raises the cap to 1000 (large tablet packs). */
|
||||
dosesPerUnit: z.number().int().min(1).max(1000).optional(),
|
||||
/**
|
||||
* v1.16.10 — inventory units one dose consumes (2 × 2 mg tablets
|
||||
* for a 4 mg dose). Default 1. Dose-level readouts divide the
|
||||
* unit counts by this factor.
|
||||
*/
|
||||
unitsPerDose: z
|
||||
.number()
|
||||
.refine(isSupportedUnitsPerDose, { message: UNITS_PER_DOSE_MESSAGE })
|
||||
.optional()
|
||||
.describe(
|
||||
"Inventory units consumed per dose. A whole number 1–100 (e.g. 2 tablets of 2 mg for a 4 mg dose) or a supported fraction for a split pill (¼ / ⅓ / ½ / ⅔ / ¾). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.",
|
||||
),
|
||||
/**
|
||||
* v1.17.0 — optional per-medication reorder lead time in days. The
|
||||
* low-stock alert widens its trigger by this lead plus one
|
||||
* dose-interval so a sparse cadence is warned before its last dose.
|
||||
* Omitted = inherit the user-level
|
||||
* `notificationPrefs.medication.reorderLeadDays` default.
|
||||
*/
|
||||
reorderLeadDays: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(60)
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-medication reorder lead time in days (0–60). The low-stock alert fires the supply early enough to reorder before the last dose. Omitted = inherit the user-level reorderLeadDays default (10).",
|
||||
),
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(),
|
||||
/**
|
||||
* v1.8.5 — per-medication injection-site tracking opt-in. Default
|
||||
* false. Only meaningful when `deliveryForm === "INJECTION"`.
|
||||
*/
|
||||
trackInjectionSites: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-medication injection-site tracking opt-in. Default false. Only meaningful for an INJECTION delivery form; when true the client prompts (skippably) for the site after a taken dose.",
|
||||
),
|
||||
/**
|
||||
* v1.8.5 — per-medication allowed / preferred injection sites.
|
||||
* Empty = no per-medication restriction. The effective pickable set
|
||||
* subtracts the user's global exclusion (deny wins).
|
||||
*/
|
||||
allowedInjectionSites: z
|
||||
.array(injectionSiteEnum)
|
||||
.max(INJECTION_SITE_VALUES.length)
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-medication allowed / preferred injection sites. Empty array = no per-medication restriction (every site offered). The effective pickable set is this list minus the user's global exclusion.",
|
||||
),
|
||||
notificationsEnabled: z.boolean().optional(),
|
||||
/** v1.7.0 — iOS Live Activity opt-in for this medication's reminders. */
|
||||
liveActivityEnabled: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"iOS Live Activity opt-in for this medication's reminders. Default false. The iOS client owns the ActivityKit lifecycle; the server only stores + echoes the flag.",
|
||||
),
|
||||
/** v1.7.0 — iOS 26 AlarmKit critical-reminder opt-in. */
|
||||
criticalAlarmEnabled: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only and hangs no server-side behaviour off it.",
|
||||
),
|
||||
/** v1.9.0 — optional WHO ATC classification code. */
|
||||
atcCode: atcCodeField,
|
||||
/** v1.9.0 — optional RxNorm RxCUI (secondary, US). */
|
||||
rxNormCode: rxNormCodeField,
|
||||
...courseWindowFields,
|
||||
/**
|
||||
* v1.16.11 — optional at the type level so an `asNeeded: true`
|
||||
* create can omit it; the refine below keeps the legacy
|
||||
* at-least-one-schedule contract for every scheduled medication.
|
||||
*/
|
||||
schedules: z.array(scheduleSchema).optional(),
|
||||
})
|
||||
.refine((b) => b.oneShot !== true || !!b.startsOn, {
|
||||
message: "startsOn is required when oneShot is true",
|
||||
path: ["startsOn"],
|
||||
})
|
||||
.refine(
|
||||
(b) =>
|
||||
!b.startsOn || !b.endsOn || b.endsOn.getTime() >= b.startsOn.getTime(),
|
||||
{
|
||||
message: "endsOn must be on or after startsOn",
|
||||
path: ["endsOn"],
|
||||
},
|
||||
)
|
||||
.refine((b) => !(b.asNeeded === true && b.oneShot === true), {
|
||||
message: AS_NEEDED_ONE_SHOT_MESSAGE,
|
||||
path: ["asNeeded"],
|
||||
})
|
||||
.refine(
|
||||
(b) => b.asNeeded === true || (!!b.schedules && b.schedules.length >= 1),
|
||||
{
|
||||
message: "Mindestens ein Zeitfenster",
|
||||
path: ["schedules"],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(b) => b.asNeeded !== true || !b.schedules || b.schedules.length === 0,
|
||||
{
|
||||
message: AS_NEEDED_SCHEDULES_MESSAGE,
|
||||
path: ["schedules"],
|
||||
},
|
||||
)
|
||||
.meta({
|
||||
id: "CreateMedicationRequest",
|
||||
description:
|
||||
'Create-medication body. The route enforces the v1.5 cross-field invariants on top of the per-schedule `rrule_xor_rolling` Zod refine: a `oneShot:true` medication may carry at most one schedule and that schedule must not declare a recurrence; `endsOn` is normalised to equal `startsOn` for one-shot doses; a recurring schedule with no `rrule`, `rollingIntervalDays`, or legacy `daysOfWeek` defaults to `rrule = "FREQ=DAILY"`; and `timesOfDay` is dual-written from `windowStart` when the caller omits it. v1.16.11 — `asNeeded: true` creates a PRN medication with ZERO schedules (`schedules` must be absent or empty, 422 otherwise); a scheduled medication still requires at least one schedule entry.',
|
||||
});
|
||||
|
||||
export const updateMedicationSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
dose: z.string().min(1).max(50).optional(),
|
||||
category: z.enum(MEDICATION_CATEGORY_VALUES).optional(),
|
||||
treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(),
|
||||
dosesPerUnit: z.number().int().min(1).max(1000).nullable().optional(),
|
||||
/** v1.16.10 — inventory units one dose consumes. Default 1.
|
||||
* v1.16.12 — whole number 1–100 or a curated fraction (½ / ⅓ / ¼ …). */
|
||||
unitsPerDose: z
|
||||
.number()
|
||||
.refine(isSupportedUnitsPerDose, { message: UNITS_PER_DOSE_MESSAGE })
|
||||
.optional()
|
||||
.describe(
|
||||
"Inventory units consumed per dose — a whole number 1–100 or a supported split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾). The intake consumption hook decrements this many units per taken dose; already-stamped intake events keep their recorded consumption.",
|
||||
),
|
||||
/**
|
||||
* v1.17.0 — per-medication reorder lead time in days (0–60). `null`
|
||||
* clears the override and reverts to the user-level default.
|
||||
*/
|
||||
reorderLeadDays: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(60)
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-medication reorder lead time in days (0–60). null clears the override and reverts to the user-level reorderLeadDays default.",
|
||||
),
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(),
|
||||
/** v1.8.5 — per-medication injection-site tracking opt-in. */
|
||||
trackInjectionSites: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-medication injection-site tracking opt-in. Only meaningful for an INJECTION delivery form. Set false to deactivate tracking.",
|
||||
),
|
||||
/** v1.8.5 — per-medication allowed / preferred injection sites. */
|
||||
allowedInjectionSites: z
|
||||
.array(injectionSiteEnum)
|
||||
.max(INJECTION_SITE_VALUES.length)
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-medication allowed / preferred injection sites. Empty array clears the restriction. The effective pickable set subtracts the user's global exclusion (deny wins).",
|
||||
),
|
||||
active: z.boolean().optional(),
|
||||
notificationsEnabled: z.boolean().optional(),
|
||||
/** v1.7.0 — iOS Live Activity opt-in for this medication's reminders. */
|
||||
liveActivityEnabled: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"iOS Live Activity opt-in for this medication's reminders. The iOS client owns the ActivityKit lifecycle; the server only stores + echoes the flag.",
|
||||
),
|
||||
/** v1.7.0 — iOS 26 AlarmKit critical-reminder opt-in. */
|
||||
criticalAlarmEnabled: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"iOS 26 AlarmKit critical-reminder opt-in. Critical alarms bypass the device mute switch / Focus; the server stores the preference only.",
|
||||
),
|
||||
/** v1.9.0 — optional WHO ATC classification code. */
|
||||
atcCode: atcCodeField,
|
||||
/** v1.9.0 — optional RxNorm RxCUI (secondary, US). */
|
||||
rxNormCode: rxNormCodeField,
|
||||
...courseWindowFields,
|
||||
schedules: z.array(scheduleSchema).optional(),
|
||||
/**
|
||||
* v1.5.5 — top-level primary-schedule grace bridge. The detail
|
||||
* page settings section saves the reminder-window in minutes for
|
||||
* the medication's primary schedule without re-sending the full
|
||||
* `schedules` array. The route normalises this value onto the
|
||||
* primary schedule's `reminderGraceMinutes` before the Prisma
|
||||
* update so the persisted shape stays per-schedule. NULL clears
|
||||
* the override and falls back to the legacy `windowEnd -
|
||||
* windowStart` span.
|
||||
*/
|
||||
reminderGraceMinutes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(24 * 60)
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Detail-page bridge: primary-schedule reminder-window in minutes. The route maps the value onto the primary schedule's `reminderGraceMinutes` field; ignored when a full `schedules` array is also supplied.",
|
||||
),
|
||||
})
|
||||
.refine((b) => b.oneShot !== true || !!b.startsOn, {
|
||||
message: "startsOn is required when oneShot is true",
|
||||
path: ["startsOn"],
|
||||
})
|
||||
.refine(
|
||||
(b) =>
|
||||
!b.startsOn || !b.endsOn || b.endsOn.getTime() >= b.startsOn.getTime(),
|
||||
{
|
||||
message: "endsOn must be on or after startsOn",
|
||||
path: ["endsOn"],
|
||||
},
|
||||
)
|
||||
.refine((b) => !(b.asNeeded === true && b.oneShot === true), {
|
||||
message: AS_NEEDED_ONE_SHOT_MESSAGE,
|
||||
path: ["asNeeded"],
|
||||
})
|
||||
.refine(
|
||||
(b) => b.asNeeded !== true || !b.schedules || b.schedules.length === 0,
|
||||
{
|
||||
message: AS_NEEDED_SCHEDULES_MESSAGE,
|
||||
path: ["schedules"],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
// An empty replace-list is only legal on the as-needed path — for a
|
||||
// scheduled medication it would wipe every schedule and strand the
|
||||
// row in a state no surface can reason about.
|
||||
(b) => b.asNeeded === true || !b.schedules || b.schedules.length >= 1,
|
||||
{
|
||||
message: "Mindestens ein Zeitfenster",
|
||||
path: ["schedules"],
|
||||
},
|
||||
)
|
||||
.meta({
|
||||
id: "UpdateMedicationRequest",
|
||||
description:
|
||||
"Update-medication body. Every field is optional; omitted fields are left untouched. Supplying `schedules` REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping `active` to false records the current timestamp on `pausedAt`; flipping back to true clears it. v1.5 invariants on the `schedules` array match the create path. v1.16.11 — `asNeeded: true` requires the medication to end schedule-less: supply `schedules: []` to clear an existing plan (any schedule entry is a 422); an empty `schedules` array without `asNeeded: true` is likewise a 422.",
|
||||
});
|
||||
|
||||
export type CreateMedicationInput = z.infer<typeof createMedicationSchema>;
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Medication request/response validation schemas.
|
||||
*
|
||||
* Barrel preserving the original `@/lib/validations/medication` import path.
|
||||
* The schemas are split by sub-resource into focused sibling modules:
|
||||
* - `base` — shared enums, constants, regexes, code fields, dose windows.
|
||||
* - `schedule` — the per-schedule input schema.
|
||||
* - `create-update` — create / update medication bodies + course window.
|
||||
* - `intake` — intake / external / list / edit / bulk-delete schemas.
|
||||
* - `inventory` — container + GLP-1 schemas.
|
||||
*/
|
||||
export * from "./base";
|
||||
export * from "./schedule";
|
||||
export * from "./create-update";
|
||||
export * from "./intake";
|
||||
export * from "./inventory";
|
||||
@@ -0,0 +1,190 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { boundedTakenAtSchema, injectionSiteEnum } from "./base";
|
||||
|
||||
export const intakeSchema = z
|
||||
.object({
|
||||
medicationId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"Server-narrowed from the URL path. The route layer overwrites whatever the body supplies before Zod parsing so a caller cannot log an intake against another medication.",
|
||||
),
|
||||
scheduledFor: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.optional()
|
||||
.describe(
|
||||
"Slot the dose belongs to. Defaults to `takenAt` (or `now()` when both are absent) so the compliance pairing logic can pin the dose to a schedule slot.",
|
||||
),
|
||||
takenAt: boundedTakenAtSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"When the dose was actually taken. NULL when `skipped` is true; defaults to `now()` for non-skipped intakes. Must not lie in the future (5-minute clock-skew allowance) nor more than 5 years in the past.",
|
||||
),
|
||||
skipped: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe(
|
||||
"True to log a skipped slot (no consumption, no inventory decrement, one-shot medications stay active).",
|
||||
),
|
||||
idempotencyKey: z
|
||||
.string()
|
||||
.max(128)
|
||||
.optional()
|
||||
.describe(
|
||||
"Caller-issued de-dup key. A second POST with the same key returns the original event without creating a new row.",
|
||||
),
|
||||
/**
|
||||
* v1.8.5 — optional injection-site capture. Only honoured on a
|
||||
* non-skipped (taken) write for a medication with
|
||||
* `deliveryForm === "INJECTION"` and `trackInjectionSites === true`.
|
||||
* The site is validated server-side against the medication's
|
||||
* effective allowed set (per-medication `allowedInjectionSites`
|
||||
* minus the user's `globalExcludedInjectionSites` deny-list); a
|
||||
* disallowed value is rejected with 422. Always optional — the
|
||||
* client may omit it (the dose still records).
|
||||
*/
|
||||
injectionSite: injectionSiteEnum
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional injection site for a taken dose. Honoured only when the medication is an INJECTION with site-tracking enabled; validated against the medication's effective allowed set (per-medication allowed sites minus the user's global exclusion). A disallowed site returns 422. Omit to record the dose without a site.",
|
||||
),
|
||||
/**
|
||||
* v1.15.18 — late-take "attribute anyway" pin. An off-window take that
|
||||
* band attribution would otherwise orphan to an ad-hoc row can be pinned
|
||||
* onto a chosen scheduled slot via the UI's "diesem Slot zuordnen?" nudge.
|
||||
* The instant MUST be a real slot of this medication on its day (the
|
||||
* server validates it against the band anchors); an arbitrary instant is
|
||||
* rejected with 422. Absent → default band attribution.
|
||||
*/
|
||||
forceSlotInstant: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.optional()
|
||||
.describe(
|
||||
"Late-take override: pin this taken dose onto the named scheduled slot instead of orphaning it to an ad-hoc row. Must be a real scheduled slot of this medication on its day (validated server-side against the dose-window band anchors); an instant that is not a slot returns 422. Absent applies the default window-band attribution.",
|
||||
),
|
||||
/**
|
||||
* v1.16.4 — per-intake dose override. Free text mirroring
|
||||
* `Medication.dose` (max 50 chars). Persisted only on a taken
|
||||
* (non-skipped) write. Absent = the configured medication dose
|
||||
* applies; read paths fall back to it.
|
||||
*/
|
||||
doseTaken: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe(
|
||||
"Dose actually consumed for THIS intake when it deviates from (or documents) the medication's configured dose, e.g. a half tablet or a titration step. Free text, max 50 characters. Omit to record the take under the medication's configured dose.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "MedicationIntakeRequest",
|
||||
description:
|
||||
"Per-medication intake log body. Idempotent via `idempotencyKey`; the server also dedupes by a 60-second sliding window when the key is absent. Non-skipped intakes auto-decrement pen inventory (best-effort), refresh the per-day compliance rollup, and — for one-shot medications — flip `active` to false. The optional `injectionSite` is persisted only for an INJECTION medication with site-tracking enabled and is validated against the medication's effective allowed set (422 on a disallowed value).",
|
||||
});
|
||||
|
||||
export const externalIntakeSchema = z.object({
|
||||
medicationName: z.string().min(1).max(200),
|
||||
// v1.16.9 — same plausibility bounds as the interactive create paths.
|
||||
takenAt: boundedTakenAtSchema.optional(),
|
||||
idempotencyKey: z.string().max(128),
|
||||
});
|
||||
|
||||
export const listIntakeEventsSchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(500).optional().default(25),
|
||||
offset: z.coerce.number().int().min(0).optional().default(0),
|
||||
sortBy: z
|
||||
.enum(["scheduledFor", "takenAt", "source", "createdAt"])
|
||||
.optional()
|
||||
.default("scheduledFor"),
|
||||
sortDir: z.enum(["asc", "desc"]).optional().default("desc"),
|
||||
/**
|
||||
* v1.4.37 W3 — server-side status filter so the medication detail
|
||||
* page (IntakeHistoryListV2) can hide unconfirmed / planned rows.
|
||||
*
|
||||
* - "all" (default): every event, preserves the byte-stable contract
|
||||
* the iOS Swift client and existing dashboard consumers depend on.
|
||||
* - "taken": only rows where the dose was confirmed taken
|
||||
* (`takenAt IS NOT NULL AND skipped = false`).
|
||||
* - "skipped": only rows the user explicitly skipped (`skipped = true`).
|
||||
* - "completed": taken OR skipped — anything the user actually
|
||||
* actioned. Excludes the ambiguous "missed / never confirmed"
|
||||
* rows (`takenAt IS NULL AND skipped = false`) that the v1 list
|
||||
* rendered as "verpasst" before the v1 component retired.
|
||||
*/
|
||||
status: z
|
||||
.enum(["all", "taken", "skipped", "completed"])
|
||||
.optional()
|
||||
.default("all"),
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.15.19 — `takenAt` plausibility bounds on the edit path (audit P0-4).
|
||||
* A date typo on an intake edit could park `takenAt` a month before its
|
||||
* slot with no pushback anywhere. The schema rejects the physically
|
||||
* implausible cases: a future instant (small skew allowance for client
|
||||
* clocks) and anything older than the 5-year window the GLP-1 dose-change
|
||||
* validator already established (`glp1DoseChangePostSchema`). Slot-distance
|
||||
* checks stay out of the schema — it cannot see the medication — and live
|
||||
* in the route (start-date guard) + the edit dialog (non-blocking hint).
|
||||
*/
|
||||
export const updateIntakeEventSchema = z
|
||||
.object({
|
||||
takenAt: boundedTakenAtSchema.nullable().optional(),
|
||||
skipped: z.boolean().optional(),
|
||||
scheduledFor: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
/**
|
||||
* v1.15.18 — late-take "attribute anyway" pin on the edit path. When the
|
||||
* edited `takenAt` lands outside every window the UI can offer to pin the
|
||||
* take onto a chosen slot; the server validates the instant is a real
|
||||
* scheduled slot (422 otherwise). Absent → the edit re-runs band
|
||||
* attribution on the new `takenAt`.
|
||||
*
|
||||
* v1.15.20 — an explicit `null` UNPINS: the dose re-attributes by window
|
||||
* band on its (unchanged or edited) `takenAt` and the binding provenance
|
||||
* resets to AUTO — the "Zuordnung lösen" path. A pin / unpin no longer
|
||||
* requires `takenAt` or `skipped` to change in the same request.
|
||||
*/
|
||||
forceSlotInstant: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Late-take override on edit: pin the edited dose onto the named scheduled slot instead of re-attributing by window band. Must be a real scheduled slot of this medication on its day (validated server-side); a non-slot instant returns 422. Explicit null unpins: the dose re-attributes by window band on its takenAt (ad-hoc when no band matches). Absent re-runs the default window-band attribution on the edited `takenAt`.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "UpdateMedicationIntakeEventRequest",
|
||||
description:
|
||||
"Edit a single intake event. v1.15.18 re-runs window-band slot attribution whenever `takenAt` or `skipped` change, snapping `scheduledFor` to the matched slot (or the take's own time when it falls in no window). `forceSlotInstant` overrides that to pin the take onto a named real slot (explicit null unpins, re-attributing by band); an explicit `scheduledFor` still wins when supplied directly. `takenAt` must not be in the future (5-minute clock-skew allowance) nor more than 5 years in the past; a `takenAt` before the medication's start date returns 422.",
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.5.5 — bulk-delete request body. The detail-page intake-history
|
||||
* preview surfaces a multi-select that posts the resulting eventIds
|
||||
* here. The cap matches `listIntakeEventsSchema.limit` (500) so the
|
||||
* client never selects more rows than the table can return at once.
|
||||
* Server-side guarantees scoped-by-medication ownership via
|
||||
* `assertMedicationOwnership` + a `userId` predicate on the
|
||||
* `deleteMany`.
|
||||
*/
|
||||
export const bulkDeleteIntakeEventsSchema = z.object({
|
||||
eventIds: z.array(z.string().min(1).max(64)).min(1).max(500),
|
||||
});
|
||||
|
||||
export type BulkDeleteIntakeEventsInput = z.infer<
|
||||
typeof bulkDeleteIntakeEventsSchema
|
||||
>;
|
||||
|
||||
export type IntakeInput = z.infer<typeof intakeSchema>;
|
||||
export type ListIntakeEventsInput = z.infer<typeof listIntakeEventsSchema>;
|
||||
export type UpdateIntakeEventInput = z.infer<typeof updateIntakeEventSchema>;
|
||||
@@ -0,0 +1,193 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* v1.4.25 W19b — inventory (pen / vial) CRUD validators.
|
||||
*
|
||||
* The Prisma model carries a 4-state enum
|
||||
* (ACTIVE | IN_USE | EXPIRED | USED_UP); the API surface only lets
|
||||
* the user explicitly transition into IN_USE (mark-as-first-use) or
|
||||
* USED_UP (manual override). EXPIRED is owned by the daily cron and
|
||||
* the intake hook, so the PATCH schema deliberately omits it.
|
||||
*/
|
||||
/**
|
||||
* v1.16.10 — container kinds, mirrored from the Prisma
|
||||
* `MedicationContainerType` enum. Display-level classification only.
|
||||
*/
|
||||
export const MEDICATION_CONTAINER_TYPE_VALUES = [
|
||||
"PEN",
|
||||
"AMPOULE",
|
||||
"BLISTER",
|
||||
"INHALER",
|
||||
"BOTTLE",
|
||||
"OTHER",
|
||||
] as const;
|
||||
export type MedicationContainerTypeValue =
|
||||
(typeof MEDICATION_CONTAINER_TYPE_VALUES)[number];
|
||||
|
||||
export const createInventoryItemSchema = z
|
||||
.object({
|
||||
/** Units the container ships with. v1.16.10 raises the cap from
|
||||
* 100 to 1000 (large tablet packs) and renames the wire field to
|
||||
* `unitsTotal` — it counts units, mapped to doses via
|
||||
* `Medication.unitsPerDose`. */
|
||||
unitsTotal: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(1000)
|
||||
.describe(
|
||||
"Units the container ships with (tablets / ampoules / puffs; 1–1000, fractional allowed for split-pill packs). Dose-derived readouts divide by the medication's `unitsPerDose`.",
|
||||
),
|
||||
/** v1.16.10 — container kind. Defaults to OTHER when absent. */
|
||||
containerType: z
|
||||
.enum(MEDICATION_CONTAINER_TYPE_VALUES)
|
||||
.optional()
|
||||
.describe(
|
||||
"Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level only; defaults to OTHER.",
|
||||
),
|
||||
printedExpiry: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional(),
|
||||
purchasedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional(),
|
||||
notes: z.string().max(200).nullable().optional(),
|
||||
})
|
||||
.meta({
|
||||
id: "CreateMedicationInventoryItemRequest",
|
||||
description:
|
||||
"Register a new supply container (pen / blister pack / bottle). `unitsTotal` counts UNITS (1–1000); the item starts ACTIVE with `unitsRemaining = unitsTotal` and the intake consumption hook decrements it per taken dose.",
|
||||
});
|
||||
|
||||
export const updateInventoryItemSchema = z
|
||||
.object({
|
||||
markAsFirstUseAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.optional()
|
||||
.describe(
|
||||
"Manually start the 30-day in-use clock (the user opened the container without logging an intake). ACTIVE flips to IN_USE; a backdated instant whose window already lapsed lands EXPIRED.",
|
||||
),
|
||||
markAsUsedUp: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Terminal override: zero the remaining units and mark the container USED_UP (physically discarded).",
|
||||
),
|
||||
printedExpiry: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional(),
|
||||
/**
|
||||
* v1.16.1 — stock correction (the Bestand tab's adjust / withdraw
|
||||
* flow). Sets the remaining-unit count directly; the route clamps to
|
||||
* `unitsTotal` and re-runs the canonical state machine (0 ⇒ USED_UP,
|
||||
* a raise out of 0 re-evaluates against the expiry clocks).
|
||||
* v1.16.10 raises the cap to 1000 alongside the capacity cap and
|
||||
* renames the wire field to `unitsRemaining` (it always counted
|
||||
* units), matching the response side.
|
||||
*/
|
||||
unitsRemaining: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(1000)
|
||||
.optional()
|
||||
.describe(
|
||||
"Absolute remaining-unit correction (0–1000, fractional allowed). Clamped server-side to the item's `unitsTotal`; the canonical state machine re-derives the state (0 ⇒ USED_UP).",
|
||||
),
|
||||
notes: z.string().max(200).nullable().optional(),
|
||||
})
|
||||
.meta({
|
||||
id: "UpdateMedicationInventoryItemRequest",
|
||||
description:
|
||||
"Per-item inventory mutation: manual first-use, used-up override, printed-expiry correction, absolute remaining-unit correction, notes. Every field is optional and commutative.",
|
||||
});
|
||||
|
||||
export type CreateInventoryItemInput = z.infer<
|
||||
typeof createInventoryItemSchema
|
||||
>;
|
||||
export type UpdateInventoryItemInput = z.infer<
|
||||
typeof updateInventoryItemSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* v1.4.25 W21 Fix-K — `POST /api/medications/[id]/glp1` body validators.
|
||||
*
|
||||
* The convenience route accepts either a `doseChange` or an `inventory`
|
||||
* payload (the route picks one). Both branches were hand-rolled
|
||||
* `typeof === "number"` checks pre-Fix-K, which let `NaN`, `Infinity`,
|
||||
* negative doses, and unbounded notes slip through.
|
||||
*
|
||||
* Bounds:
|
||||
* - `doseValue` is finite, non-negative, capped at 100 mg (covers every
|
||||
* real-world GLP-1 step with headroom).
|
||||
* - `doseUnit` is a short string (mg / mcg / IE).
|
||||
* - `note` is capped at 500 characters so the field can't be used as a
|
||||
* blob smuggler.
|
||||
* - `effectiveFrom` is constrained to a ±5-year window around now —
|
||||
* a paper-record back-fill or a planned future step both fit, but
|
||||
* "1970" / "9999" do not.
|
||||
* - `delta` is a non-zero finite integer in [−100, 100] — the legacy
|
||||
* ledger counts pens, and ±100 pens per correction stays plenty
|
||||
* (deliberately NOT raised with the v1.16.10 per-item unit cap).
|
||||
* - `reason` is a bounded string (the route logs it; raw blob bad).
|
||||
*/
|
||||
const MAX_DOSE_MG = 100;
|
||||
const MAX_NOTE_CHARS = 500;
|
||||
const MAX_REASON_CHARS = 200;
|
||||
const MIN_EFFECTIVE_FROM = new Date("2020-01-01T00:00:00Z");
|
||||
const FIVE_YEARS_MS = 5 * 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export const glp1DoseChangePostSchema = z.object({
|
||||
effectiveFrom: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.refine((d) => d.getTime() >= MIN_EFFECTIVE_FROM.getTime(), {
|
||||
message: "effectiveFrom must be on or after 2020-01-01",
|
||||
})
|
||||
.refine((d) => d.getTime() <= Date.now() + FIVE_YEARS_MS, {
|
||||
message: "effectiveFrom must be within 5 years of now",
|
||||
}),
|
||||
doseValue: z.number().finite().min(0).max(MAX_DOSE_MG),
|
||||
doseUnit: z.string().min(1).max(10),
|
||||
note: z.string().max(MAX_NOTE_CHARS).nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* DEPRECATED write path (v1.16.10) — the `inventory.delta` branch feeds
|
||||
* the legacy `MedicationInventoryEvent` running-sum ledger. The per-item
|
||||
* endpoints (`POST /api/medications/[id]/inventory`,
|
||||
* `PATCH /api/medications/[id]/inventory/[itemId]`) replaced it; reads
|
||||
* fall back to the ledger only while a medication has zero inventory
|
||||
* items. New callers must register containers instead of posting deltas.
|
||||
*/
|
||||
export const glp1InventoryPostSchema = z.object({
|
||||
delta: z
|
||||
.number()
|
||||
.int()
|
||||
.finite()
|
||||
.min(-100)
|
||||
.max(100)
|
||||
.refine((n) => n !== 0, { message: "delta must be non-zero" })
|
||||
.describe(
|
||||
"Deprecated since v1.16.10: pen-count delta on the legacy running-sum ledger. Register containers via the inventory endpoints instead; reads use the ledger only while the medication has no inventory items.",
|
||||
),
|
||||
reason: z.string().min(1).max(MAX_REASON_CHARS),
|
||||
});
|
||||
|
||||
export const glp1PostBodySchema = z
|
||||
.object({
|
||||
doseChange: glp1DoseChangePostSchema.optional(),
|
||||
inventory: glp1InventoryPostSchema.optional(),
|
||||
})
|
||||
.refine((b) => Boolean(b.doseChange) !== Boolean(b.inventory), {
|
||||
message: "Body must carry exactly one of doseChange or inventory",
|
||||
});
|
||||
|
||||
export type Glp1DoseChangePostInput = z.infer<typeof glp1DoseChangePostSchema>;
|
||||
export type Glp1InventoryPostInput = z.infer<typeof glp1InventoryPostSchema>;
|
||||
export type Glp1PostBodyInput = z.infer<typeof glp1PostBodySchema>;
|
||||
@@ -0,0 +1,220 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { SCHEDULE_TYPES } from "@/lib/medications/scheduling/recurrence";
|
||||
import { RRULE_PROPS, doseWindowEntrySchema, timeRegex } from "./base";
|
||||
|
||||
export const scheduleSchema = z
|
||||
.object({
|
||||
windowStart: z
|
||||
.string()
|
||||
.regex(timeRegex, "Format: HH:mm")
|
||||
.describe(
|
||||
"Legacy single-time-of-intake (HH:mm, user local). v1.5 keeps the field for backwards compatibility with pre-wizard iOS clients; the new `timesOfDay` array supersedes it.",
|
||||
),
|
||||
windowEnd: z
|
||||
.string()
|
||||
.regex(timeRegex, "Format: HH:mm")
|
||||
.describe(
|
||||
"Legacy reminder-window upper bound (HH:mm). Used to derive the late-classification grace span when `reminderGraceMinutes` is null.",
|
||||
),
|
||||
label: z
|
||||
.string()
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe('Optional human label (e.g. "Morning", "Evening").'),
|
||||
dose: z
|
||||
.string()
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-schedule dose override. NULL means the schedule inherits `Medication.dose`.",
|
||||
),
|
||||
daysOfWeek: z
|
||||
.array(z.number().int().min(0).max(6))
|
||||
.optional()
|
||||
.describe(
|
||||
"Legacy day-of-week filter (0=Sunday..6=Saturday). v1.5 reads new writes through `rrule` first; this field is preserved for pre-v1.5 rows and is the input the route serialises into the persisted `days_of_week` string.",
|
||||
),
|
||||
intervalWeeks: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(4)
|
||||
.optional()
|
||||
.describe(
|
||||
"Legacy multi-week stride (1..4). Bi-weekly + tri-weekly were broken in the pre-v1.5 reminder worker; new writes encode the same intent via `rrule` (e.g. `FREQ=WEEKLY;INTERVAL=2;BYDAY=WE`).",
|
||||
),
|
||||
/**
|
||||
* v1.5 — first-class times-of-day. One or more HH:mm entries in
|
||||
* the user's wall-clock; the engine applies them per matched day.
|
||||
* Empty array falls back to `[windowStart]` for backwards-compat.
|
||||
*/
|
||||
timesOfDay: z
|
||||
.array(z.string().regex(timeRegex, "Format: HH:mm"))
|
||||
.max(8)
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.5 first-class points-in-time the dose is taken (HH:mm, user local). Up to 8 entries. Absent or empty means the route stamps `[windowStart]` so the new engine always sees a populated array.",
|
||||
),
|
||||
/**
|
||||
* v1.5 — reminder grace window (minutes). Replaces the implicit
|
||||
* `windowEnd - windowStart` span for late-classification. NULL
|
||||
* falls back to the legacy span. Capped at 24 hours.
|
||||
*/
|
||||
reminderGraceMinutes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(24 * 60)
|
||||
.optional()
|
||||
.describe(
|
||||
"Reminder grace window in minutes. Caps at 24h. NULL falls back to the legacy `windowEnd - windowStart` span.",
|
||||
),
|
||||
/**
|
||||
* v1.5 — RFC 5545 RRULE string for calendar-anchored cadences.
|
||||
* Mutually exclusive with `rollingIntervalDays`.
|
||||
*/
|
||||
rrule: z
|
||||
.string()
|
||||
.max(200)
|
||||
.regex(RRULE_PROPS, "Invalid RRULE")
|
||||
.optional()
|
||||
.describe(
|
||||
"RFC 5545 RRULE string (subset). Use for daily / weekly-with-BYDAY / multi-week / monthly / yearly cadences. Mutually exclusive with `rollingIntervalDays`. Examples: `FREQ=DAILY`, `FREQ=WEEKLY;BYDAY=MO,WE,FR`, `FREQ=WEEKLY;INTERVAL=2;BYDAY=WE`, `FREQ=MONTHLY;BYMONTHDAY=1`, `FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1`.",
|
||||
),
|
||||
/**
|
||||
* v1.5 — flexible-rolling interval in days, counted from the
|
||||
* latest MedicationIntakeEvent.takenAt. Mutually exclusive with
|
||||
* `rrule`. Range 1..365 days.
|
||||
*/
|
||||
rollingIntervalDays: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(365)
|
||||
.optional()
|
||||
.describe(
|
||||
"Flexible-rolling interval in days, counted forward from the latest `MedicationIntakeEvent.takenAt` (the dose re-anchors when logged). Mutually exclusive with `rrule`. Range 1..365.",
|
||||
),
|
||||
/**
|
||||
* v1.7.0 — schedule-type discriminator. SCHEDULED (default) keeps the
|
||||
* rrule / rolling / legacy cadence. PRN is as-needed (never projected,
|
||||
* reminded, or counted in compliance expected; still loggable).
|
||||
* CYCLIC wraps the inner cadence with an N-on / M-off week phase.
|
||||
*/
|
||||
scheduleType: z
|
||||
.enum(SCHEDULE_TYPES)
|
||||
.optional()
|
||||
.describe(
|
||||
"Schedule type. SCHEDULED (default) = rrule / rolling / legacy cadence. PRN = as-needed (never projected, reminded, or counted in compliance expected; still loggable via the intake route). CYCLIC = N weeks on / M weeks off, gating whichever inner cadence the rrule / legacy fields describe.",
|
||||
),
|
||||
/** v1.7.0 — cyclic "on" weeks. Required when `scheduleType === "CYCLIC"`. */
|
||||
cyclicOnWeeks: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(52)
|
||||
.optional()
|
||||
.describe(
|
||||
'Cyclic "on" weeks (1..52). Required when `scheduleType` is CYCLIC; ignored otherwise.',
|
||||
),
|
||||
/** v1.7.0 — cyclic "off" weeks. Required when `scheduleType === "CYCLIC"`. */
|
||||
cyclicOffWeeks: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(52)
|
||||
.optional()
|
||||
.describe(
|
||||
'Cyclic "off" weeks (0..52). Required when `scheduleType` is CYCLIC; ignored otherwise.',
|
||||
),
|
||||
/**
|
||||
* v1.15.18 — per-dose configurable on-time intake window (the maintainer's
|
||||
* "07:00–09:00" lever). One entry per dose time the user wants an explicit
|
||||
* range for; a `timeOfDay` with no entry keeps the symmetric ±1h default.
|
||||
* Each `timeOfDay` MUST match one of the schedule's `timesOfDay` (or the
|
||||
* legacy `windowStart`), and `start <= end` within the day. Absent → every
|
||||
* slot uses the default derivation (unchanged behaviour).
|
||||
*/
|
||||
doseWindows: z
|
||||
.array(doseWindowEntrySchema)
|
||||
.max(8)
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-dose on-time intake windows. Each `{ timeOfDay, start, end }` HH:mm triple sets the explicit on-time band for the matching dose time; a dose time with no entry keeps the symmetric ±1h default. `timeOfDay` must match one of `timesOfDay` (or `windowStart`); `start <= end`. Up to 8 entries. Absent leaves every slot on the default derivation. The late tail stays cadence-derived.",
|
||||
),
|
||||
})
|
||||
.refine((s) => !(s.rrule && s.rollingIntervalDays), {
|
||||
message: "A schedule can be calendar-anchored (rrule) or rolling, not both",
|
||||
path: ["rrule"],
|
||||
})
|
||||
.refine(
|
||||
(s) =>
|
||||
s.scheduleType !== "CYCLIC" ||
|
||||
(s.cyclicOnWeeks !== undefined && s.cyclicOffWeeks !== undefined),
|
||||
{
|
||||
message: "cyclic schedules require both cyclicOnWeeks and cyclicOffWeeks",
|
||||
path: ["cyclicOnWeeks"],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(s) =>
|
||||
s.scheduleType !== "PRN" ||
|
||||
(s.rrule === undefined && s.rollingIntervalDays === undefined),
|
||||
{
|
||||
message:
|
||||
"PRN schedules cannot carry a cadence (rrule or rollingIntervalDays)",
|
||||
path: ["scheduleType"],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(s) =>
|
||||
s.rollingIntervalDays === undefined ||
|
||||
s.rollingIntervalDays === null ||
|
||||
!s.timesOfDay ||
|
||||
s.timesOfDay.length <= 1,
|
||||
{
|
||||
message: "rolling-cadence schedules accept at most one time of day",
|
||||
path: ["timesOfDay"],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(s) => {
|
||||
// Every per-dose window must name a real dose time. The effective dose
|
||||
// times are `timesOfDay` when set, else the single legacy `windowStart`
|
||||
// (mirrors the engine's `effectiveTimesOfDay`).
|
||||
if (!s.doseWindows || s.doseWindows.length === 0) return true;
|
||||
const times = new Set(
|
||||
s.timesOfDay && s.timesOfDay.length > 0
|
||||
? s.timesOfDay
|
||||
: [s.windowStart],
|
||||
);
|
||||
return s.doseWindows.every((w) => times.has(w.timeOfDay));
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Each doseWindows.timeOfDay must match one of the schedule's timesOfDay",
|
||||
path: ["doseWindows"],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(s) => {
|
||||
// A dose time may carry at most one explicit window.
|
||||
if (!s.doseWindows || s.doseWindows.length === 0) return true;
|
||||
const seen = new Set<string>();
|
||||
for (const w of s.doseWindows) {
|
||||
if (seen.has(w.timeOfDay)) return false;
|
||||
seen.add(w.timeOfDay);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "doseWindows must not repeat a timeOfDay",
|
||||
path: ["doseWindows"],
|
||||
},
|
||||
)
|
||||
.meta({
|
||||
id: "MedicationScheduleInput",
|
||||
description:
|
||||
"Single schedule entry on a medication. v1.5 introduces `timesOfDay`, `rrule`, `rollingIntervalDays`, and `reminderGraceMinutes` as first-class fields; `windowStart`, `windowEnd`, `daysOfWeek`, and `intervalWeeks` are preserved through the v1.5.x line for backwards compatibility. **`rrule` and `rollingIntervalDays` are mutually exclusive** — supplying both fails 422 (`rrule_xor_rolling`). The DB enforces the same invariant via a CHECK constraint.",
|
||||
});
|
||||
Reference in new issue
Block a user