diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 795ae7379..91bb91870 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -75720,7 +75720,7 @@ components: conversations: type: array items: - $ref: "#/components/schemas/DocumentChatConversation" + $ref: "#/components/schemas/DocumentChatListItem" nextCursor: type: - string @@ -75729,7 +75729,7 @@ components: - conversations - nextCursor additionalProperties: false - DocumentChatConversation: + DocumentChatListItem: type: object properties: id: @@ -75742,12 +75742,38 @@ components: type: string messageCount: type: number + fenced: + type: boolean + description: "Always true here: a chat about a document is a fenced thread (no tools, no health snapshot)." + attachments: + description: The documents the thread is about, as they are now. + type: array + items: + type: object + properties: + documentId: + type: string + title: + description: The attached document's title, falling back to its filename; null when it has neither. + type: + - string + - "null" + required: + - documentId + - title + additionalProperties: false + documentTitle: + description: The first attached document's title, for a one-line badge; null when none is attached any more. + type: + - string + - "null" required: - id - title - createdAt - updatedAt - messageCount + - fenced additionalProperties: false DocumentReindexEnvelope: type: object diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 35fb5495d..0808f1686 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -118,9 +118,40 @@ const documentChatDetailSchema = documentChatConversationSchema }) .meta({ id: "DocumentChatDetail" }); +/** One thread in the document's rail: the conversation row the Coach rail reads. */ +const documentChatListItemSchema = documentChatConversationSchema + .extend({ + fenced: z + .boolean() + .describe( + "Always true here: a chat about a document is a fenced thread (no tools, no health snapshot).", + ), + attachments: z + .array( + z.object({ + documentId: z.string(), + title: z + .string() + .nullable() + .describe( + "The attached document's title, falling back to its filename; null when it has neither.", + ), + }), + ) + .optional() + .describe("The documents the thread is about, as they are now."), + documentTitle: z + .string() + .nullish() + .describe( + "The first attached document's title, for a one-line badge; null when none is attached any more.", + ), + }) + .meta({ id: "DocumentChatListItem" }); + const documentChatListSchema = z .object({ - conversations: z.array(documentChatConversationSchema), + conversations: z.array(documentChatListItemSchema), nextCursor: z.string().nullable(), }) .meta({ id: "DocumentChatList" }); diff --git a/tests/integration/contract-seed.ts b/tests/integration/contract-seed.ts index e878b9d48..c297e90c2 100644 --- a/tests/integration/contract-seed.ts +++ b/tests/integration/contract-seed.ts @@ -467,15 +467,16 @@ export async function seedContractAccount( actorUserId: CONTRACT_USER_ID, medicationId: ids.medication, payload: {}, + // Part-way through, so the progress lists carry an entry each. progress: { - processed: 0, + processed: 2, total: 3, - imported: 0, - skippedByReason: {}, - skipDetails: [], + imported: 1, + skippedByReason: { already_recorded: 1 }, + skipDetails: [{ line: 2, reason: "already_recorded" }], skippedDetailsOmitted: 0, - touchedDays: [], - rollupProcessed: 0, + touchedDays: [dayKey(daysAgo(3))], + rollupProcessed: 1, }, }, }); @@ -485,15 +486,16 @@ export async function seedContractAccount( recordUserId: CONTRACT_USER_ID, actorUserId: CONTRACT_USER_ID, payload: {}, + // Part-way through, so the progress lists carry an entry each. progress: { - processed: 0, + processed: 2, total: 3, - imported: 0, - skippedByReason: {}, - skipDetails: [], + imported: 1, + skippedByReason: { already_recorded: 1 }, + skipDetails: [{ line: 2, reason: "already_recorded" }], skippedDetailsOmitted: 0, - touchedDays: [], - rollupProcessed: 0, + touchedDays: [dayKey(daysAgo(3))], + rollupProcessed: 1, }, }, }); @@ -509,9 +511,22 @@ export async function seedContractAccount( { id: ids.medication }, ); } - await write("cycle-symptom", "POST", "api/cycle/symptoms/custom", { - label: "Contract symptom", - }); + 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", @@ -582,6 +597,19 @@ export async function seedContractAccount( } 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", @@ -667,6 +695,20 @@ export async function seedContractAccount( }); if (ingested.kind === "stored") ids.document = ingested.document.id; else failures.push(`document: ingest answered ${ingested.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"); @@ -715,6 +757,235 @@ export async function seedContractAccount( }); 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 }, + ); + } + 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"; @@ -774,7 +1045,7 @@ const PATH_PARAM_SOURCES: Record> = { "/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: "medication" }, + "/api/medications/{id}/schedule-revisions": { id: "rescheduledMedication" }, "/api/allergies/{id}": { id: "allergy" }, "/api/family-history/{id}": { id: "familyHistory" }, "/api/practitioners/{id}": { id: "practitioner" }, diff --git a/tests/integration/openapi-get-contract.test.ts b/tests/integration/openapi-get-contract.test.ts index 573fbe253..47fffdb99 100644 --- a/tests/integration/openapi-get-contract.test.ts +++ b/tests/integration/openapi-get-contract.test.ts @@ -116,6 +116,41 @@ const NOT_CALLED: Record = { /** The least number of operations that must actually be called and parsed. */ const CHECKED_FLOOR = 235; +/** + * Operations whose every list may come back empty here, with the reason. An + * empty list parses against any item schema, so every other operation must + * answer at least once with a list that has a row in it; the seed + * (`contract-seed.ts`) writes one for each. As for `NOT_CALLED`, a reason is + * about the operation: a list a fixture could fill is a row to seed. + */ +const EMPTY_ALLOWED: Record = { + "/api/auth/me/mfa": + "The list is the account's security keys, and an enrolled second factor turns the export and backup reads this run also calls into a re-proof a seeded session cannot give.", + "/api/auth/passkeys": + "The same: a passkey on the account puts the export and backup reads behind a re-proof.", + "/api/coach/about-me/questions": + "The questions are written by the account's AI provider; the run has none.", + "/api/insights/medication-compliance-status": + "The per-medication summaries are written by the account's AI provider; with none the published answer is the empty list beside `hasProvider: false`.", + "/api/insights/chat/{id}/messages/{messageId}/results": + "Result tables are stored by a model turn that called tools, in its own sealed format; the run has no provider.", + "/api/cycle/insights": + "A phase row only survives a false-discovery-rate test over many cycles of readings, which no handful of rows passes.", + "/api/insights/health-status": + "A deviation or shift is reported only once a reading leaves the account's own long-run baseline, which needs months of history.", + "/api/insights/glp1-timeline": + "Entries exist only for a GLP-1 medication, whose dose phases would change what the main medication's reads answer.", + "/api/documents/sources": + "Lists connections to the operator's document archive, which is environment configuration; the run configures none.", + "/api/environment/travel/suggestions": + "Suggestions come from the account's location history in an external Dawarich instance the run does not reach.", + "/api/mcp/connections": + "A connection is minted only by the OAuth authorization flow of a registered agent client.", +}; + +/** The least number of operations that answer with a list that has a row. */ +const FILLED_FLOOR = 228; + /** * The queries each operation is called with. An operation with a required * parameter needs an entry; an operation whose optional parameters select a @@ -346,6 +381,8 @@ function onlyEmptyArrays(value: unknown): boolean { } const emptyBodies: string[] = []; +/** Operations that answered at least once with a row in a list (or no list). */ +const filled = new Set(); /** Call one operation once and report what disagrees with its schema. */ async function check( @@ -407,6 +444,7 @@ async function check( ); } if (onlyEmptyArrays(body)) emptyBodies.push(op.path); + else filled.add(op.path); const parsed = schema.safeParse(body); if (!parsed.success) { return { @@ -465,6 +503,15 @@ describe("every published GET answers what its schema says", () => { expect(findings).toEqual([]); expect(checked).toBeGreaterThanOrEqual(CHECKED_FLOOR); + + // Every list a schema promises is read with a row in it at least once, + // apart from the reasoned exceptions; an exception that now has a row + // is stale and goes. + const onlyEmpty = [...new Set(emptyBodies)] + .filter((path) => !filled.has(path)) + .sort(); + expect(onlyEmpty).toEqual(Object.keys(EMPTY_ALLOWED).sort()); + expect(filled.size).toBeGreaterThanOrEqual(FILLED_FLOOR); }, 600_000); it("leaves no exception that the run could have called", async () => {