mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
The contract walk had no fixture for `/api/biomarkers/{id}/series`, so
the new route was reported instead of parsed. It now takes the seeded
biomarker, which already carries a lab value, and is checked against its
published schema like `/api/biomarkers/{id}`.
Refs #1342
1157 lines
36 KiB
TypeScript
1157 lines
36 KiB
TypeScript
/**
|
|
* One account with something in every section, for the GET contract run.
|
|
*
|
|
* Rows go in through the app's own write routes wherever one exists, so the
|
|
* fixture carries exactly what a client would have created — encrypted
|
|
* columns encrypted, derived columns derived — rather than what a hand-written
|
|
* Prisma literal guesses. Prisma is used directly only where no route writes
|
|
* the row (imported recordings, job ledgers, conversations).
|
|
*
|
|
* A list that comes back empty parses against almost any schema, so the point
|
|
* of the seed is that the lists the contract run reads are not empty.
|
|
*/
|
|
import { NextRequest } from "next/server";
|
|
import { join } from "node:path";
|
|
|
|
import type { PrismaClient } from "@/generated/prisma/client";
|
|
|
|
export interface ContractFixture {
|
|
userId: string;
|
|
sessionId: string;
|
|
/** A second session of the same account, switched into the managed profile. */
|
|
guardianSessionId: string;
|
|
guardianRecordEpoch: number;
|
|
managedProfileId: string;
|
|
/** Writes that did not land; the contract run reports them first. */
|
|
seedFailures: string[];
|
|
/** The path parameters for a published path, or null when none is seeded. */
|
|
pathParams(path: string): Record<string, string> | null;
|
|
/** A seeded id by name, for query fixtures. */
|
|
id(name: string): string;
|
|
}
|
|
|
|
export const CONTRACT_USER_ID = "user-openapi-get-contract";
|
|
const MANAGED_PROFILE_ID = "user-openapi-get-contract-managed";
|
|
|
|
type Handler = (req: NextRequest, ctx: unknown) => Promise<Response>;
|
|
|
|
function routeFile(path: string): string {
|
|
return join(process.cwd(), "src/app", path, "route.ts");
|
|
}
|
|
|
|
/**
|
|
* Call one route handler in-process, as the seeded session.
|
|
*
|
|
* `path` is the file-system path under `src/app` (`api/allergies`,
|
|
* `api/medications/[id]/courses`); `params` fills its dynamic segments.
|
|
*/
|
|
export async function callRoute(
|
|
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
|
|
path: string,
|
|
options: {
|
|
body?: unknown;
|
|
params?: Record<string, string>;
|
|
query?: Record<string, string>;
|
|
} = {},
|
|
): Promise<{ status: number; body: unknown }> {
|
|
const mod = (await import(/* @vite-ignore */ routeFile(path))) as Record<
|
|
string,
|
|
Handler | undefined
|
|
>;
|
|
const handler = mod[method];
|
|
if (!handler) throw new Error(`${path} exports no ${method}`);
|
|
let urlPath = `/${path}`;
|
|
for (const [name, value] of Object.entries(options.params ?? {})) {
|
|
urlPath = urlPath.replace(`[${name}]`, encodeURIComponent(value));
|
|
}
|
|
const url = new URL(`http://localhost${urlPath}`);
|
|
for (const [key, value] of Object.entries(options.query ?? {})) {
|
|
url.searchParams.set(key, value);
|
|
}
|
|
const init: { method: string; body?: string; headers?: HeadersInit } = {
|
|
method,
|
|
};
|
|
if (options.body !== undefined) {
|
|
init.body = JSON.stringify(options.body);
|
|
init.headers = { "content-type": "application/json" };
|
|
}
|
|
const res = await handler(new NextRequest(url, init), {
|
|
params: Promise.resolve(options.params ?? {}),
|
|
});
|
|
const text = await res.text();
|
|
let body: unknown = text;
|
|
try {
|
|
body = JSON.parse(text);
|
|
} catch {
|
|
// not JSON; keep the text for the failure message
|
|
}
|
|
return { status: res.status, body };
|
|
}
|
|
|
|
const DAY = 24 * 60 * 60 * 1000;
|
|
|
|
function daysAgo(n: number, hour = 8): Date {
|
|
const d = new Date(Date.now() - n * DAY);
|
|
d.setUTCHours(hour, 0, 0, 0);
|
|
return d;
|
|
}
|
|
|
|
function dayKey(d: Date): string {
|
|
return d.toISOString().slice(0, 10);
|
|
}
|
|
|
|
export async function seedContractAccount(
|
|
prisma: PrismaClient,
|
|
): Promise<ContractFixture> {
|
|
await prisma.user.create({
|
|
data: {
|
|
id: CONTRACT_USER_ID,
|
|
username: "openapi-get-contract",
|
|
email: "openapi-get-contract@example.test",
|
|
gender: "FEMALE",
|
|
timezone: "UTC",
|
|
role: "ADMIN",
|
|
dateOfBirth: new Date("1985-04-12T00:00:00.000Z"),
|
|
heightCm: 170,
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: {
|
|
userId: CONTRACT_USER_ID,
|
|
expiresAt: new Date(Date.now() + 6 * 60 * 60 * 1000),
|
|
},
|
|
});
|
|
|
|
const { cookieJar } = await import("./mock-next-headers");
|
|
cookieJar.set("healthlog_session", session.id);
|
|
|
|
const ids: Record<string, string> = {};
|
|
const failures: string[] = [];
|
|
|
|
/** Write through a route; on success return `data`, on failure note it. */
|
|
async function write(
|
|
label: string,
|
|
method: "POST" | "PUT" | "PATCH",
|
|
path: string,
|
|
body: unknown,
|
|
params?: Record<string, string>,
|
|
): Promise<Record<string, unknown> | null> {
|
|
try {
|
|
const res = await callRoute(method, path, { body, params });
|
|
if (res.status >= 200 && res.status < 300) {
|
|
const data = (res.body as { data?: unknown }).data;
|
|
return (data ?? {}) as Record<string, unknown>;
|
|
}
|
|
failures.push(
|
|
`${label}: ${res.status} ${JSON.stringify(res.body).slice(0, 600)}`,
|
|
);
|
|
} catch (error) {
|
|
failures.push(`${label}: threw ${(error as Error).message}`);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function keep(name: string, data: Record<string, unknown> | null, at = "id") {
|
|
const value = data?.[at];
|
|
if (typeof value === "string") ids[name] = value;
|
|
}
|
|
|
|
// ── Modules that are off by default ────────────────────────────────────
|
|
await write("modules", "PATCH", "api/auth/me/modules", {
|
|
nutrients: true,
|
|
timeline: true,
|
|
});
|
|
|
|
// ── Measurements: ninety days of the common series ─────────────────────
|
|
const rows: Array<{
|
|
type: string;
|
|
value: number;
|
|
unit: string;
|
|
measuredAt: Date;
|
|
glucoseContext?: string;
|
|
}> = [];
|
|
for (let n = 0; n < 90; n++) {
|
|
rows.push(
|
|
{
|
|
type: "WEIGHT",
|
|
value: 72 + Math.sin(n / 7),
|
|
unit: "kg",
|
|
measuredAt: daysAgo(n, 7),
|
|
},
|
|
{
|
|
type: "BLOOD_PRESSURE_SYS",
|
|
value: 122 + (n % 9),
|
|
unit: "mmHg",
|
|
measuredAt: daysAgo(n, 8),
|
|
},
|
|
{
|
|
type: "BLOOD_PRESSURE_DIA",
|
|
value: 79 + (n % 5),
|
|
unit: "mmHg",
|
|
measuredAt: daysAgo(n, 8),
|
|
},
|
|
{
|
|
type: "PULSE",
|
|
value: 64 + (n % 7),
|
|
unit: "bpm",
|
|
measuredAt: daysAgo(n, 8),
|
|
},
|
|
{
|
|
type: "RESTING_HEART_RATE",
|
|
value: 58 + (n % 4),
|
|
unit: "bpm",
|
|
measuredAt: daysAgo(n, 6),
|
|
},
|
|
{
|
|
type: "HEART_RATE_VARIABILITY",
|
|
value: 40 + (n % 11),
|
|
unit: "ms",
|
|
measuredAt: daysAgo(n, 6),
|
|
},
|
|
{
|
|
type: "ACTIVITY_STEPS",
|
|
value: 6000 + ((n * 977) % 5000),
|
|
unit: "count",
|
|
measuredAt: daysAgo(n, 20),
|
|
},
|
|
{
|
|
type: "SLEEP_DURATION",
|
|
value: 400 + (n % 60),
|
|
unit: "min",
|
|
measuredAt: daysAgo(n, 6),
|
|
},
|
|
{
|
|
type: "BODY_FAT",
|
|
value: 24 + (n % 3) / 2,
|
|
unit: "%",
|
|
measuredAt: daysAgo(n, 7),
|
|
},
|
|
{
|
|
type: "OXYGEN_SATURATION",
|
|
value: 97,
|
|
unit: "%",
|
|
measuredAt: daysAgo(n, 6),
|
|
},
|
|
{
|
|
type: "BLOOD_GLUCOSE",
|
|
value: 92 + (n % 13),
|
|
unit: "mg/dL",
|
|
measuredAt: daysAgo(n, 9),
|
|
glucoseContext: "FASTING",
|
|
},
|
|
);
|
|
}
|
|
await prisma.measurement.createMany({
|
|
data: rows.map((row, i) => ({
|
|
userId: CONTRACT_USER_ID,
|
|
type: row.type as never,
|
|
value: row.value,
|
|
unit: row.unit,
|
|
measuredAt: row.measuredAt,
|
|
source: "MANUAL",
|
|
externalId: `contract-${i}`,
|
|
...(row.glucoseContext
|
|
? { glucoseContext: row.glucoseContext as never }
|
|
: {}),
|
|
})),
|
|
});
|
|
// A second source for the steps on the last week, so the provenance reads
|
|
// explain a pick between two sources and the comparison has a pair.
|
|
await prisma.measurement.createMany({
|
|
data: [1, 2, 3, 4, 5, 6, 7].map((n) => ({
|
|
userId: CONTRACT_USER_ID,
|
|
type: "ACTIVITY_STEPS" as const,
|
|
value: 6500 + ((n * 613) % 4000),
|
|
unit: "count",
|
|
measuredAt: daysAgo(n, 21),
|
|
source: "APPLE_HEALTH" as const,
|
|
externalId: `contract-steps-watch-${n}`,
|
|
})),
|
|
});
|
|
const firstMeasurement = await prisma.measurement.findFirst({
|
|
where: { userId: CONTRACT_USER_ID, type: "WEIGHT" },
|
|
orderBy: { measuredAt: "desc" },
|
|
});
|
|
if (firstMeasurement) ids.measurement = firstMeasurement.id;
|
|
|
|
// ── Mood ───────────────────────────────────────────────────────────────
|
|
const binaryTag = await prisma.moodTag.findFirst({
|
|
where: { userId: null, kind: { not: "RATED" } },
|
|
});
|
|
const ratedTag = await prisma.moodTag.findFirst({
|
|
where: { userId: null, kind: "RATED" },
|
|
});
|
|
for (let n = 0; n < 20; n++) {
|
|
const mood = await write("mood", "POST", "api/mood-entries", {
|
|
date: dayKey(daysAgo(n + 1)),
|
|
mood: ["GUT", "OKAY", "SUPER_GUT", "SCHLECHT"][n % 4],
|
|
moodLoggedAt: daysAgo(n + 1, 21).toISOString(),
|
|
note: n === 0 ? "A contract note" : undefined,
|
|
...(n === 0 && binaryTag ? { tagKeys: [binaryTag.key] } : {}),
|
|
...(n === 0 && ratedTag
|
|
? { ratedFactors: [{ key: ratedTag.key, rating: 3 }] }
|
|
: {}),
|
|
});
|
|
if (n === 0) keep("mood", mood);
|
|
}
|
|
|
|
// ── Medication ─────────────────────────────────────────────────────────
|
|
const medication = await write("medication", "POST", "api/medications", {
|
|
name: "Contract compound",
|
|
dose: "10 mg",
|
|
schedules: [
|
|
{
|
|
time: "08:00",
|
|
windowStart: "07:00",
|
|
windowEnd: "10:00",
|
|
daysOfWeek: [0, 1, 2, 3, 4, 5, 6],
|
|
},
|
|
],
|
|
});
|
|
keep("medication", medication);
|
|
|
|
// ── Records ────────────────────────────────────────────────────────────
|
|
keep(
|
|
"allergy",
|
|
await write("allergy", "POST", "api/allergies", {
|
|
substance: "Pollen",
|
|
category: "ENVIRONMENT",
|
|
type: "ALLERGY",
|
|
status: "ACTIVE",
|
|
reaction: "Sneezing",
|
|
}),
|
|
);
|
|
keep(
|
|
"familyHistory",
|
|
await write("family-history", "POST", "api/family-history", {
|
|
relationship: "MOTHER",
|
|
condition: "Hypertension",
|
|
}),
|
|
);
|
|
keep(
|
|
"practitioner",
|
|
await write("practitioner", "POST", "api/practitioners", {
|
|
name: "Dr. Example",
|
|
specialty: "General practice",
|
|
}),
|
|
);
|
|
keep(
|
|
"encounter",
|
|
await write("encounter", "POST", "api/encounters", {
|
|
kind: "ROUTINE",
|
|
occurredAt: daysAgo(30).toISOString(),
|
|
reason: "Check-up",
|
|
}),
|
|
);
|
|
keep(
|
|
"vaccination",
|
|
await write("vaccination", "POST", "api/vaccinations", {
|
|
vaccineName: "Tetanus",
|
|
occurredAt: daysAgo(200).toISOString(),
|
|
}),
|
|
);
|
|
keep(
|
|
"illness",
|
|
await write("illness", "POST", "api/illness/episodes", {
|
|
label: "Cold",
|
|
type: "INFECTION",
|
|
onsetAt: daysAgo(14).toISOString(),
|
|
}),
|
|
);
|
|
const assessment = await write(
|
|
"assessment",
|
|
"POST",
|
|
"api/mental-health/assessments",
|
|
{ instrument: "PHQ9", items: [0, 1, 0, 1, 0, 0, 1, 0, 0] },
|
|
);
|
|
keep(
|
|
"assessment",
|
|
(assessment?.assessment as Record<string, unknown> | undefined) ?? null,
|
|
);
|
|
keep(
|
|
"customMetric",
|
|
await write("custom-metric", "POST", "api/custom-metrics", {
|
|
name: "Contract metric",
|
|
unit: "pts",
|
|
}),
|
|
);
|
|
if (ids.customMetric) {
|
|
await write(
|
|
"custom-metric-entry",
|
|
"POST",
|
|
"api/custom-metrics/[id]/entries",
|
|
{ value: 3, measuredAt: daysAgo(1).toISOString() },
|
|
{ id: ids.customMetric },
|
|
);
|
|
}
|
|
keep(
|
|
"lab",
|
|
await write("lab", "POST", "api/labs", {
|
|
analyte: "HbA1c",
|
|
value: 5.4,
|
|
unit: "%",
|
|
takenAt: daysAgo(40).toISOString(),
|
|
}),
|
|
);
|
|
keep(
|
|
"reminder",
|
|
await write("reminder", "POST", "api/measurement-reminders", {
|
|
type: "WEIGHT",
|
|
label: "Morning weight",
|
|
time: "07:30",
|
|
intervalDays: 1,
|
|
}),
|
|
);
|
|
await write("cycle-day", "POST", "api/cycle/day-logs", {
|
|
date: dayKey(daysAgo(3)),
|
|
loggedAt: daysAgo(3, 9).toISOString(),
|
|
flow: "MEDIUM",
|
|
});
|
|
await write("symptom-definition", "POST", "api/symptoms/definitions", {
|
|
label: "Headache",
|
|
});
|
|
const definitions = await callRoute("GET", "api/symptoms/definitions");
|
|
const definition = (definitions.body as { data?: unknown }).data as
|
|
Array<{ id: string }> | { definitions?: Array<{ id: string }> } | undefined;
|
|
const definitionId = Array.isArray(definition)
|
|
? definition[0]?.id
|
|
: definition?.definitions?.[0]?.id;
|
|
if (definitionId) {
|
|
await write("symptom", "POST", "api/symptoms/events", {
|
|
definitionId,
|
|
occurredAt: daysAgo(2).toISOString(),
|
|
intensity: 4,
|
|
});
|
|
} else {
|
|
failures.push(
|
|
`symptom: no definition ${JSON.stringify(definitions.body).slice(0, 300)}`,
|
|
);
|
|
}
|
|
await write("life-event", "POST", "api/life-events", {
|
|
title: "Moved house",
|
|
category: "HOME",
|
|
startDate: dayKey(daysAgo(60)),
|
|
precision: "DAY",
|
|
});
|
|
// Creating a managed profile asks for a fresh second factor, which a seeded
|
|
// session cannot give; the profile and its MANAGE grant go in directly.
|
|
await prisma.user.create({
|
|
data: {
|
|
id: MANAGED_PROFILE_ID,
|
|
username: "openapi-get-contract-managed",
|
|
email: null,
|
|
role: "USER",
|
|
managedProfileAt: new Date(),
|
|
},
|
|
});
|
|
await prisma.accountGrant.create({
|
|
data: {
|
|
grantorId: MANAGED_PROFILE_ID,
|
|
granteeId: CONTRACT_USER_ID,
|
|
access: "MANAGE",
|
|
acceptedAt: new Date(Date.now() - 60_000),
|
|
},
|
|
});
|
|
ids.managedProfile = MANAGED_PROFILE_ID;
|
|
|
|
// ── Medication detail ──────────────────────────────────────────────────
|
|
if (ids.medication) {
|
|
const medication = { id: ids.medication };
|
|
await write(
|
|
"side-effect",
|
|
"POST",
|
|
"api/medications/[id]/side-effects",
|
|
{ entry: "NAUSEA", severity: 2, occurredAt: daysAgo(5).toISOString() },
|
|
medication,
|
|
);
|
|
await prisma.medicationIntakeEvent.createMany({
|
|
data: Array.from({ length: 14 }, (_, n) => ({
|
|
userId: CONTRACT_USER_ID,
|
|
medicationId: ids.medication,
|
|
scheduledFor: daysAgo(n + 1, 8),
|
|
takenAt: n % 5 === 0 ? null : daysAgo(n + 1, 8),
|
|
skipped: n % 5 === 0,
|
|
source: "WEB" as const,
|
|
})),
|
|
});
|
|
const intakeJob = await prisma.medicationIntakeImportJob.create({
|
|
data: {
|
|
recordUserId: CONTRACT_USER_ID,
|
|
actorUserId: CONTRACT_USER_ID,
|
|
medicationId: ids.medication,
|
|
payload: {},
|
|
// Part-way through, so the progress lists carry an entry each.
|
|
progress: {
|
|
processed: 2,
|
|
total: 3,
|
|
imported: 1,
|
|
skippedByReason: { already_recorded: 1 },
|
|
skipDetails: [{ line: 2, reason: "already_recorded" }],
|
|
skippedDetailsOmitted: 0,
|
|
touchedDays: [dayKey(daysAgo(3))],
|
|
rollupProcessed: 1,
|
|
},
|
|
},
|
|
});
|
|
ids.intakeImportJob = intakeJob.id;
|
|
const doseJob = await prisma.medicationIntakeImportJob.create({
|
|
data: {
|
|
recordUserId: CONTRACT_USER_ID,
|
|
actorUserId: CONTRACT_USER_ID,
|
|
payload: {},
|
|
// Part-way through, so the progress lists carry an entry each.
|
|
progress: {
|
|
processed: 2,
|
|
total: 3,
|
|
imported: 1,
|
|
skippedByReason: { already_recorded: 1 },
|
|
skipDetails: [{ line: 2, reason: "already_recorded" }],
|
|
skippedDetailsOmitted: 0,
|
|
touchedDays: [dayKey(daysAgo(3))],
|
|
rollupProcessed: 1,
|
|
},
|
|
},
|
|
});
|
|
ids.doseHistoryImportJob = doseJob.id;
|
|
}
|
|
|
|
if (ids.medication) {
|
|
await write(
|
|
"inventory",
|
|
"POST",
|
|
"api/medications/[id]/inventory",
|
|
{ unitsTotal: 30, containerType: "BLISTER" },
|
|
{ id: ids.medication },
|
|
);
|
|
}
|
|
const cycleSymptom = await write(
|
|
"cycle-symptom",
|
|
"POST",
|
|
"api/cycle/symptoms/custom",
|
|
{ label: "Contract symptom" },
|
|
);
|
|
// The day carries the account's own symptom, so the day log's symptom
|
|
// list is not empty.
|
|
if (typeof cycleSymptom?.key === "string") {
|
|
await write("cycle-day-symptom", "POST", "api/cycle/day-logs", {
|
|
date: dayKey(daysAgo(3)),
|
|
loggedAt: daysAgo(3, 10).toISOString(),
|
|
flow: "MEDIUM",
|
|
symptoms: [{ key: cycleSymptom.key, severity: 2 }],
|
|
});
|
|
}
|
|
await write("device", "POST", "api/devices", {
|
|
token: "contract-device-token",
|
|
bundleId: "dev.healthlog.contract",
|
|
apnsToken: "a".repeat(64),
|
|
apnsEnvironment: "sandbox",
|
|
appVersion: "1.0",
|
|
model: "iPhone",
|
|
});
|
|
await write("share-link", "POST", "api/share-links", {
|
|
label: "For the practice",
|
|
rangeStart: daysAgo(60).toISOString(),
|
|
expiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString(),
|
|
});
|
|
await write("mcp-token", "POST", "api/mcp/tokens", {
|
|
name: "Contract connector",
|
|
scope: "read",
|
|
});
|
|
// A device's irregular-rhythm notification: the EVENT row the rhythm
|
|
// timeline reads.
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: CONTRACT_USER_ID,
|
|
type: "IRREGULAR_RHYTHM_NOTIFICATION",
|
|
value: 1,
|
|
unit: "count",
|
|
measuredAt: daysAgo(6, 3),
|
|
source: "APPLE_HEALTH",
|
|
externalId: "contract-rhythm-1",
|
|
rhythmClassification: "IRREGULAR",
|
|
},
|
|
});
|
|
|
|
// ── Workouts ───────────────────────────────────────────────────────────
|
|
const workouts = await write("workouts", "POST", "api/workouts/batch", {
|
|
workouts: [1, 3, 6].map((n) => ({
|
|
sportType: "running",
|
|
startedAt: daysAgo(n, 17).toISOString(),
|
|
endedAt: new Date(daysAgo(n, 17).getTime() + 40 * 60_000).toISOString(),
|
|
totalEnergyKcal: 420,
|
|
totalDistanceM: 6500,
|
|
avgHeartRate: 148,
|
|
externalId: `contract-workout-${n}`,
|
|
})),
|
|
});
|
|
void workouts;
|
|
const workout = await prisma.workout.findFirst({
|
|
where: { userId: CONTRACT_USER_ID },
|
|
});
|
|
if (workout) ids.workout = workout.id;
|
|
|
|
// ── Biomarker, a lab against it ────────────────────────────────────────
|
|
keep(
|
|
"biomarker",
|
|
await write("biomarker", "POST", "api/biomarkers", {
|
|
name: "Contract marker",
|
|
unit: "mg/dL",
|
|
lowerBound: 10,
|
|
upperBound: 50,
|
|
}),
|
|
);
|
|
if (ids.biomarker) {
|
|
await write("lab-on-biomarker", "POST", "api/labs", {
|
|
biomarkerId: ids.biomarker,
|
|
value: 31,
|
|
unit: "mg/dL",
|
|
takenAt: daysAgo(20).toISOString(),
|
|
});
|
|
}
|
|
|
|
if (ids.illness) {
|
|
// A logged day with a catalogue symptom (the catalogue is migration
|
|
// data), so the day's symptom list is not empty.
|
|
await write(
|
|
"illness-day-symptom",
|
|
"POST",
|
|
"api/illness/episodes/[id]/day-logs",
|
|
{
|
|
date: dayKey(daysAgo(2)),
|
|
functionalImpact: 1,
|
|
symptoms: [{ key: "cough", severity: 2 }],
|
|
},
|
|
{ id: ids.illness },
|
|
);
|
|
await write(
|
|
"illness-day",
|
|
"POST",
|
|
"api/illness/episodes/[id]/day-logs",
|
|
{ date: dayKey(daysAgo(13)), functionalImpact: 2, feverC: 38.1 },
|
|
{ id: ids.illness },
|
|
);
|
|
}
|
|
await write("custom-vaccine", "POST", "api/vaccinations/custom", {
|
|
name: "Contract vaccine",
|
|
components: ["tetanus"],
|
|
});
|
|
await write("coach-reminder", "POST", "api/coach/reminders", {
|
|
note: "Check weight trend",
|
|
});
|
|
|
|
// ── ECG: a recording through the persistence path the importers use ───
|
|
const { persistEcgRecording } = await import("@/lib/ecg/persist-recording");
|
|
const ecg = await persistEcgRecording(
|
|
{
|
|
userId: CONTRACT_USER_ID,
|
|
source: "APPLE_HEALTH",
|
|
externalRecordingId: "contract-ecg-1",
|
|
recordedAt: daysAgo(4, 10),
|
|
samples: Array.from({ length: 512 }, (_, i) =>
|
|
Math.round(Math.sin(i / 8) * 400),
|
|
),
|
|
samplingFrequency: 512,
|
|
averageHeartRate: 66,
|
|
rhythmClassification: "NOT_DETECTED",
|
|
},
|
|
prisma,
|
|
);
|
|
ids.ecg = ecg.id;
|
|
|
|
// ── A Coach conversation with an exchange in it ────────────────────────
|
|
const { createConversation, appendMessage } =
|
|
await import("@/lib/ai/coach/persistence");
|
|
const conversation = await createConversation({
|
|
userId: CONTRACT_USER_ID,
|
|
title: "Contract conversation",
|
|
});
|
|
ids.conversation = conversation.id;
|
|
await appendMessage({
|
|
conversationId: conversation.id,
|
|
role: "user",
|
|
content: "How is my blood pressure?",
|
|
});
|
|
const answer = await appendMessage({
|
|
conversationId: conversation.id,
|
|
role: "assistant",
|
|
content: "It has been steady over the last month.",
|
|
});
|
|
ids.message = answer.id;
|
|
|
|
// ── A document, through the same ingest the upload route calls ────────
|
|
const { ingestDocument } = await import("@/lib/documents/ingest");
|
|
const pdf = Buffer.from(
|
|
"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Kids[]/Count 0>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF\n",
|
|
);
|
|
const ingested = await ingestDocument({
|
|
userId: CONTRACT_USER_ID,
|
|
scoped: false,
|
|
ipAddress: null,
|
|
bytes: pdf,
|
|
filename: "contract.pdf",
|
|
title: "Contract letter",
|
|
kind: null,
|
|
documentDate: dayKey(daysAgo(10)),
|
|
episodeIds: [],
|
|
encounterIds: [],
|
|
sourceSystem: "PAPERLESS",
|
|
sourceId: "4711",
|
|
sourceInstance: null,
|
|
aiDeferred: true,
|
|
sourceKeyChecked: false,
|
|
limits: { maxFileBytes: 10 * 1024 * 1024, quotaBytes: 100 * 1024 * 1024 },
|
|
documentAi: async () => ({
|
|
available: false,
|
|
reason: "no_provider",
|
|
onDeviceAllowed: false,
|
|
}),
|
|
});
|
|
if (ingested.kind === "stored") ids.document = ingested.document.id;
|
|
else failures.push(`document: ingest answered ${ingested.kind}`);
|
|
// A second document, deleted the way the delete route tombstones one, so
|
|
// Recently deleted answers with a row.
|
|
const deleted = await ingestDocument({
|
|
userId: CONTRACT_USER_ID,
|
|
scoped: false,
|
|
ipAddress: null,
|
|
bytes: Buffer.concat([pdf, Buffer.from("% deleted\n")]),
|
|
filename: "deleted.pdf",
|
|
title: "Deleted letter",
|
|
kind: null,
|
|
documentDate: dayKey(daysAgo(12)),
|
|
episodeIds: [],
|
|
encounterIds: [],
|
|
sourceSystem: null,
|
|
sourceId: null,
|
|
sourceInstance: null,
|
|
aiDeferred: true,
|
|
sourceKeyChecked: false,
|
|
limits: { maxFileBytes: 10 * 1024 * 1024, quotaBytes: 100 * 1024 * 1024 },
|
|
documentAi: async () => ({
|
|
available: false,
|
|
reason: "no_provider",
|
|
onDeviceAllowed: false,
|
|
}),
|
|
});
|
|
if (deleted.kind === "stored") {
|
|
await prisma.inboundDocument.update({
|
|
where: { id: deleted.document.id },
|
|
data: { deletedAt: daysAgo(2) },
|
|
});
|
|
} else failures.push(`deleted document: ingest answered ${deleted.kind}`);
|
|
// A chat about that document, as the document sheet starts one.
|
|
if (ids.document) {
|
|
const documentChat = await createConversation({
|
|
userId: CONTRACT_USER_ID,
|
|
title: "About the letter",
|
|
documentScoped: true,
|
|
attachmentIds: [ids.document],
|
|
});
|
|
await appendMessage({
|
|
conversationId: documentChat.id,
|
|
role: "user",
|
|
content: "What does the letter say?",
|
|
});
|
|
}
|
|
|
|
// ── An AI run ──────────────────────────────────────────────────────────
|
|
const { createAiRun } = await import("@/lib/documents/ai-runs/store");
|
|
if (ids.document) {
|
|
ids.aiRun = await createAiRun({
|
|
userId: CONTRACT_USER_ID,
|
|
kind: "DOCUMENT_INDEX",
|
|
documentId: ids.document,
|
|
params: { mode: "vision", mime: "application/pdf" },
|
|
});
|
|
}
|
|
|
|
// ── Import ledger rows ─────────────────────────────────────────────────
|
|
const importJob = await prisma.importJob.create({
|
|
data: {
|
|
userId: CONTRACT_USER_ID,
|
|
uploadBytes: 2048,
|
|
status: "completed",
|
|
completedAt: daysAgo(1),
|
|
},
|
|
});
|
|
ids.importJob = importJob.id;
|
|
|
|
// ── A stored backup of this very account, and a restore job for it ─────
|
|
const { storeBackupBlob } = await import("@/lib/export/store-backup-blob");
|
|
const { streamFullBackupJson } =
|
|
await import("@/lib/export/full-backup-stream");
|
|
const stored = await storeBackupBlob(
|
|
prisma,
|
|
{ userId: CONTRACT_USER_ID, type: "WEEKLY_AUTO", preview: () => null },
|
|
async (out) => {
|
|
await streamFullBackupJson(prisma, CONTRACT_USER_ID, out, {
|
|
purpose: "disaster-recovery",
|
|
});
|
|
},
|
|
);
|
|
ids.backup = stored.id;
|
|
const restore = await prisma.backupRestoreJob.create({
|
|
data: {
|
|
userId: CONTRACT_USER_ID,
|
|
actorUserId: CONTRACT_USER_ID,
|
|
backupId: stored.id,
|
|
backupDigest: "contract-digest",
|
|
status: "completed",
|
|
},
|
|
});
|
|
ids.restoreJob = restore.id;
|
|
|
|
// ── Rows behind the lists a detail or a summary carries ──────────────
|
|
// An array that comes back empty parses against any item schema, so each
|
|
// list the run reads gets at least one row (see EMPTY_ALLOWED in the run
|
|
// for the ones that cannot have one here, with the reason).
|
|
if (ids.encounter) {
|
|
const visit = { id: ids.encounter };
|
|
const links: Array<[string, string | undefined]> = [
|
|
["labResult", ids.lab],
|
|
["conditionEpisode", ids.illness],
|
|
["document", ids.document],
|
|
["vaccination", ids.vaccination],
|
|
["medication", ids.medication],
|
|
];
|
|
for (const [targetKind, targetId] of links) {
|
|
if (!targetId) continue;
|
|
await write(
|
|
`encounter-link-${targetKind}`,
|
|
"POST",
|
|
"api/encounters/[id]/links",
|
|
{ targetKind, targetIds: [targetId] },
|
|
visit,
|
|
);
|
|
}
|
|
}
|
|
if (ids.lab && ids.document) {
|
|
await write(
|
|
"lab-link-document",
|
|
"POST",
|
|
"api/labs/[id]/links",
|
|
{ targetKind: "document", targetIds: [ids.document] },
|
|
{ id: ids.lab },
|
|
);
|
|
}
|
|
// A saved manual order for the labs list, so the layout read carries an
|
|
// order with an id in it.
|
|
if (ids.lab) {
|
|
await write(
|
|
"module-list-layout",
|
|
"PUT",
|
|
"api/settings/module-list-layout",
|
|
{
|
|
version: 1,
|
|
list: "labs",
|
|
view: "list",
|
|
order: [ids.lab],
|
|
sortDir: "manual",
|
|
},
|
|
);
|
|
}
|
|
await write("procedure", "POST", "api/encounters", {
|
|
kind: "PROCEDURE",
|
|
occurredAt: daysAgo(25).toISOString(),
|
|
reason: "Knee arthroscopy",
|
|
bodySite: "Knee",
|
|
laterality: "LEFT",
|
|
});
|
|
// A second reading of the same analyte, so the lab change summary has a
|
|
// pair to compare.
|
|
await write("lab-second", "POST", "api/labs", {
|
|
analyte: "HbA1c",
|
|
value: 5.8,
|
|
unit: "%",
|
|
takenAt: daysAgo(5).toISOString(),
|
|
});
|
|
await write("lab-third", "POST", "api/labs", {
|
|
analyte: "HbA1c",
|
|
value: 5.6,
|
|
unit: "%",
|
|
takenAt: daysAgo(20).toISOString(),
|
|
});
|
|
await write("nutrients", "POST", "api/nutrients/batch", {
|
|
entries: [1, 2, 3].map((n) => ({
|
|
day: dayKey(daysAgo(n)),
|
|
nutrient: "vitamin_c",
|
|
unit: "mg",
|
|
amount: 80 + n,
|
|
})),
|
|
});
|
|
await write("medication-category", "POST", "api/medications/categories", {
|
|
label: "Contract category",
|
|
});
|
|
if (ids.medication) {
|
|
await write("medication-layout", "PUT", "api/medications/layout", {
|
|
version: 1,
|
|
order: [ids.medication],
|
|
});
|
|
// An ended course and the current one. The route refuses a second
|
|
// course while the implicit open one runs, so both go in as rows.
|
|
await prisma.medicationCourse.createMany({
|
|
data: [
|
|
{
|
|
medicationId: ids.medication,
|
|
userId: CONTRACT_USER_ID,
|
|
startsOn: new Date(`${dayKey(daysAgo(400))}T00:00:00.000Z`),
|
|
endsOn: new Date(`${dayKey(daysAgo(380))}T00:00:00.000Z`),
|
|
},
|
|
{
|
|
medicationId: ids.medication,
|
|
userId: CONTRACT_USER_ID,
|
|
startsOn: new Date(`${dayKey(daysAgo(60))}T00:00:00.000Z`),
|
|
endsOn: null,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
// A schedule change archives the previous schedule as a revision. On a
|
|
// medication of its own, so the main one's history stays as seeded.
|
|
const rescheduled = await write(
|
|
"medication-rescheduled",
|
|
"POST",
|
|
"api/medications",
|
|
{
|
|
name: "Contract rescheduled",
|
|
dose: "5 mg",
|
|
schedules: [
|
|
{
|
|
time: "08:00",
|
|
windowStart: "07:00",
|
|
windowEnd: "10:00",
|
|
daysOfWeek: [0, 1, 2, 3, 4, 5, 6],
|
|
},
|
|
],
|
|
},
|
|
);
|
|
keep("rescheduledMedication", rescheduled);
|
|
if (ids.rescheduledMedication) {
|
|
await write(
|
|
"medication-reschedule",
|
|
"PUT",
|
|
"api/medications/[id]",
|
|
{
|
|
name: "Contract rescheduled",
|
|
dose: "5 mg",
|
|
schedules: [
|
|
{
|
|
time: "09:00",
|
|
windowStart: "08:00",
|
|
windowEnd: "11:00",
|
|
daysOfWeek: [0, 1, 2, 3, 4, 5, 6],
|
|
},
|
|
],
|
|
},
|
|
{ id: ids.rescheduledMedication },
|
|
);
|
|
}
|
|
await write("anamnesis-fact", "POST", "api/anamnesis/facts", {
|
|
kind: "SMOKING_STATUS",
|
|
value: "FORMER",
|
|
validFrom: dayKey(daysAgo(400)),
|
|
});
|
|
await write("coach-prefs", "PUT", "api/auth/me/coach-prefs", {
|
|
excludeMetrics: ["weight"],
|
|
});
|
|
await write(
|
|
"injection-site-prefs",
|
|
"PATCH",
|
|
"api/auth/me/injection-site-prefs",
|
|
{
|
|
globalExcludedInjectionSites: ["ABDOMEN_LEFT"],
|
|
},
|
|
);
|
|
await write("invite", "POST", "api/admin/invites", { expiresInDays: 7 });
|
|
const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec");
|
|
await prisma.coachPlan.create({
|
|
data: {
|
|
userId: CONTRACT_USER_ID,
|
|
metric: "SLEEP",
|
|
ifCueEncrypted: encryptToBytes("it is 22:30"),
|
|
thenActionEncrypted: encryptToBytes("put the phone away"),
|
|
status: "active",
|
|
},
|
|
});
|
|
await prisma.coachFact.create({
|
|
data: {
|
|
userId: CONTRACT_USER_ID,
|
|
factEncrypted: encryptToBytes("Works night shifts twice a month"),
|
|
category: "context",
|
|
confidence: 0.9,
|
|
source: "user",
|
|
},
|
|
});
|
|
if (ids.reminder) {
|
|
await prisma.measurementReminderEvent.create({
|
|
data: {
|
|
userId: CONTRACT_USER_ID,
|
|
reminderId: ids.reminder,
|
|
kind: "SATISFIED",
|
|
occurredAt: daysAgo(1, 7),
|
|
onTime: true,
|
|
source: "auto_measurement",
|
|
},
|
|
});
|
|
}
|
|
// A sign-in, as the security activity lists it.
|
|
await prisma.auditLog.create({
|
|
data: {
|
|
userId: CONTRACT_USER_ID,
|
|
actorUserId: CONTRACT_USER_ID,
|
|
action: "auth.login",
|
|
createdAt: daysAgo(1),
|
|
},
|
|
});
|
|
await prisma.personalRecord.create({
|
|
data: {
|
|
userId: CONTRACT_USER_ID,
|
|
metricType: "ACTIVITY_STEPS",
|
|
direction: "MAX",
|
|
value: 18_204,
|
|
unit: "steps",
|
|
achievedAt: daysAgo(6),
|
|
},
|
|
});
|
|
// A delegate's visit to this record, as the activity list shows it.
|
|
await prisma.auditLog.create({
|
|
data: {
|
|
userId: CONTRACT_USER_ID,
|
|
actorUserId: MANAGED_PROFILE_ID,
|
|
action: "sharing.record.accessed",
|
|
details: JSON.stringify({ accesses: 2 }),
|
|
createdAt: daysAgo(2),
|
|
},
|
|
});
|
|
// A remembered device. A passkey or a security key stays out: either one
|
|
// turns the export and backup reads into a re-proof the seeded session
|
|
// cannot give (EMPTY_ALLOWED in the run).
|
|
await prisma.trustedDevice.create({
|
|
data: {
|
|
userId: CONTRACT_USER_ID,
|
|
tokenHash: "contract-trusted-device-hash",
|
|
label: "Phone",
|
|
expiresAt: new Date(Date.now() + 20 * DAY),
|
|
},
|
|
});
|
|
if (ids.biomarker) {
|
|
await prisma.biomarkerAlias.create({
|
|
data: {
|
|
userId: CONTRACT_USER_ID,
|
|
biomarkerId: ids.biomarker,
|
|
name: "Contract alias",
|
|
nameFolded: "contract alias",
|
|
source: "MANUAL",
|
|
},
|
|
});
|
|
}
|
|
|
|
// Fixed keys for the parameters that are not row ids.
|
|
ids.today = dayKey(daysAgo(1));
|
|
ids.paperless = "paperless";
|
|
ids.profileFamily = "profile";
|
|
|
|
const guardianSession = await prisma.session.create({
|
|
data: {
|
|
userId: CONTRACT_USER_ID,
|
|
expiresAt: new Date(Date.now() + 6 * 60 * 60 * 1000),
|
|
actingAsUserId: MANAGED_PROFILE_ID,
|
|
recordEpoch: 1,
|
|
},
|
|
});
|
|
|
|
return {
|
|
userId: CONTRACT_USER_ID,
|
|
sessionId: session.id,
|
|
guardianSessionId: guardianSession.id,
|
|
guardianRecordEpoch: guardianSession.recordEpoch,
|
|
managedProfileId: MANAGED_PROFILE_ID,
|
|
seedFailures: failures,
|
|
id(name) {
|
|
const value = ids[name];
|
|
if (value === undefined) throw new Error(`no seeded ${name}`);
|
|
return value;
|
|
},
|
|
pathParams(path) {
|
|
const names = [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
|
|
if (names.length === 0) return {};
|
|
const value = PATH_PARAM_SOURCES[path];
|
|
if (!value) return null;
|
|
const out: Record<string, string> = {};
|
|
for (const name of names) {
|
|
const source = value[name];
|
|
const resolved = source ? ids[source] : undefined;
|
|
if (resolved === undefined) return null;
|
|
out[name] = resolved;
|
|
}
|
|
return out;
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Which seeded id fills which path parameter. */
|
|
const PATH_PARAM_SOURCES: Record<string, Record<string, string>> = {
|
|
"/api/measurements/{id}": { id: "measurement" },
|
|
"/api/mood-entries/{id}": { id: "mood" },
|
|
"/api/medications/{id}": { id: "medication" },
|
|
"/api/medications/{id}/glp1": { id: "medication" },
|
|
"/api/medications/{id}/api-endpoint": { id: "medication" },
|
|
"/api/medications/{id}/phase-config": { id: "medication" },
|
|
"/api/medications/{id}/side-effects": { id: "medication" },
|
|
"/api/medications/{id}/courses": { id: "medication" },
|
|
"/api/medications/{id}/intake": { id: "medication" },
|
|
"/api/medications/{id}/inventory": { id: "medication" },
|
|
"/api/medications/{id}/cadence": { id: "medication" },
|
|
"/api/medications/{id}/compliance": { id: "medication" },
|
|
"/api/medications/{id}/efficacy": { id: "medication" },
|
|
"/api/medications/{id}/dose-history": { id: "medication" },
|
|
"/api/medications/{id}/schedule-revisions": { id: "rescheduledMedication" },
|
|
"/api/allergies/{id}": { id: "allergy" },
|
|
"/api/family-history/{id}": { id: "familyHistory" },
|
|
"/api/practitioners/{id}": { id: "practitioner" },
|
|
"/api/encounters/{id}": { id: "encounter" },
|
|
"/api/vaccinations/{id}": { id: "vaccination" },
|
|
"/api/illness/episodes/{id}": { id: "illness" },
|
|
"/api/illness/episodes/{id}/day-logs": { id: "illness" },
|
|
"/api/illness/episodes/{id}/correlation": { id: "illness" },
|
|
"/api/mental-health/assessments/{id}": { id: "assessment" },
|
|
"/api/custom-metrics/{id}": { id: "customMetric" },
|
|
"/api/custom-metrics/{id}/entries": { id: "customMetric" },
|
|
"/api/labs/{id}": { id: "lab" },
|
|
"/api/measurement-reminders/{id}": { id: "reminder" },
|
|
"/api/measurement-reminders/{id}/history": { id: "reminder" },
|
|
"/api/managed-profiles/{id}": { id: "managedProfile" },
|
|
"/api/managed-profiles/{id}/guardians": { id: "managedProfile" },
|
|
"/api/managed-profiles/{id}/handover": { id: "managedProfile" },
|
|
"/api/workouts/{id}": { id: "workout" },
|
|
"/api/biomarkers/{id}": { id: "biomarker" },
|
|
"/api/biomarkers/{id}/series": { id: "biomarker" },
|
|
"/api/insights/ecg/{id}": { id: "ecg" },
|
|
"/api/insights/chat/{id}": { id: "conversation" },
|
|
"/api/insights/chat/{id}/messages/{messageId}/results": {
|
|
id: "conversation",
|
|
messageId: "message",
|
|
},
|
|
"/api/insights/chat/{id}/messages/{messageId}/trail": {
|
|
id: "conversation",
|
|
messageId: "message",
|
|
},
|
|
"/api/documents/inbound/{id}": { id: "document" },
|
|
"/api/documents/inbound/{id}/chat": { id: "document" },
|
|
"/api/ai-runs/{id}": { id: "aiRun" },
|
|
"/api/import/apple-health-export/{jobId}/status": { jobId: "importJob" },
|
|
"/api/medications/{id}/intake/import/{jobId}/status": {
|
|
id: "medication",
|
|
jobId: "intakeImportJob",
|
|
},
|
|
"/api/medications/intake/dose-history-import/{jobId}/status": {
|
|
jobId: "doseHistoryImportJob",
|
|
},
|
|
"/api/admin/backups/{id}/download": { id: "backup" },
|
|
"/api/admin/backups/restores/{jobId}": { jobId: "restoreJob" },
|
|
"/api/documents/sources/{system}/tags": { system: "paperless" },
|
|
"/api/documents/sources/{system}/search": { system: "paperless" },
|
|
"/api/day/{date}": { date: "today" },
|
|
"/api/record-settings/{family}": { family: "profileFamily" },
|
|
};
|