mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Answer the native client's open server asks
- Publish timezone, glucoseUnit and a features block on the account payload; glucoseUnit reads as the closed pair the contract names, and features.trackIntake tells a separately released client that medications take the record-only switch. - Refuse a new dose for a record-only medication with medication.intake.notTracked on every dose-writing route; the bulk route reports it per entry as intake_not_tracked, and correcting an already resolved event stays open. - Answer passkey sign-in failures by name instead of a 500: passkey.challenge.expired, passkey.unknown, passkey.response.invalid and passkey.verification.failed, each with an audit row. - Carry one account-wide adherence figure in meta.aggregate on the batched compliance read, weighted by expected doses, and accept ?days=30|60|90|180|365 for a report window. - Accept ?days=30|90|180|365 on the mood insights read so stability covers the period shown, and name the window it covers. - Keep only the reminder that announces a dose off APNs when the phone owns medication reminders; follow-ups for a dose still open are pushed again, marked by a followUp flag the reminder cron sets. - Document which batch validation issues belong to an entry and pin it with a test that splits and resends a batch; publish rejectedFields items as ValidationIssue.
This commit is contained in:
1 parent
7bcb6f71d9
commit
bb563b6506
50 files changed
+1789
-142
No files matched your search
@@ -248,7 +248,10 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"measurement.restore.invalid",
|
||||
"measurement.update.server_owned_source",
|
||||
],
|
||||
medication: ["medication.intake.import.invalid_format"],
|
||||
medication: [
|
||||
"medication.intake.import.invalid_format",
|
||||
"medication.intake.notTracked",
|
||||
],
|
||||
medications: [
|
||||
"medications.category.invalid",
|
||||
"medications.category.limitReached",
|
||||
@@ -306,6 +309,12 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"onboarding.restart.rateLimited",
|
||||
"onboarding.tour.invalid",
|
||||
],
|
||||
passkey: [
|
||||
"passkey.challenge.expired",
|
||||
"passkey.response.invalid",
|
||||
"passkey.unknown",
|
||||
"passkey.verification.failed",
|
||||
],
|
||||
personal_records: ["personal_records.invalid_query"],
|
||||
practitioner: ["practitioner.invalid"],
|
||||
profile: [
|
||||
|
||||
@@ -501,6 +501,30 @@ const accountPayload = z
|
||||
.looseObject({
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
// Enumerated because clients cut days and convert glucose by them: the
|
||||
// day key and the unit must be the server's, not the device's.
|
||||
timezone: z
|
||||
.string()
|
||||
.describe(
|
||||
"The IANA zone the server cuts this account's days in: the stored zone, or the instance default when the stored one is unusable. Resolved, never the raw column, so bucket day keys by this value.",
|
||||
),
|
||||
glucoseUnit: z
|
||||
.enum(["mg/dL", "mmol/L"])
|
||||
.nullable()
|
||||
.describe(
|
||||
"The account's chosen glucose display unit, as stored. `null` means the person never chose one (or the stored value is neither unit), and the server then shows glucose in mg/dL.",
|
||||
),
|
||||
features: z
|
||||
.object({
|
||||
trackIntake: z
|
||||
.literal(true)
|
||||
.describe(
|
||||
"Medications take `trackIntake`: a medication can be kept as a record whose doses are never due, and a dose written against one is refused with `medication.intake.notTracked`.",
|
||||
),
|
||||
})
|
||||
.describe(
|
||||
"What this server understands, for a client released apart from it. Constant across accounts; a flag appears once the server supports the feature and stays `true`. Absent on servers older than v1.42.0. A client that meets a flag it does not know ignores it.",
|
||||
),
|
||||
accountAccess: accountAccessBlock,
|
||||
moduleAccess: moduleAccessMap,
|
||||
// v1.39 — enumerated because every AI surface decides on it: a client
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
errorEnvelope,
|
||||
loginPasswordSchema,
|
||||
notImplementedResponse,
|
||||
validationIssue,
|
||||
} from "./shared";
|
||||
import { passkeyLoginOptionsSchema } from "@/lib/validations/auth";
|
||||
|
||||
@@ -633,13 +634,7 @@ const authProfileUpdateResponse = z
|
||||
"Whether an insurance number is stored. The number itself is encrypted at rest and is never returned — only its presence.",
|
||||
),
|
||||
rejectedFields: z
|
||||
.array(
|
||||
z.object({
|
||||
path: z.string(),
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
.array(validationIssue)
|
||||
.optional()
|
||||
.describe(
|
||||
"Present only on a PARTIAL success: the fields that were skipped while the rest of the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save. `code` is the validator code for a field that failed validation, or `rate_limited` for an email-address change that has spent the account's hourly budget; that one is not a bad value and the same address will be accepted once the window rolls over.",
|
||||
@@ -1446,10 +1441,20 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"The assertion resolved to a user row that no longer exists.",
|
||||
"`meta.errorCode` = `passkey.unknown` (v1.42.0): no passkey with this credential id is registered on this server — it was removed here, or belongs to another server. Tell the person to sign in another way. Without a code: the assertion resolved to a user row that no longer exists.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
"401": {
|
||||
description:
|
||||
"The sign-in did not prove anything (v1.42.0 codes; before, the first case answered 500). `meta.errorCode` = `passkey.challenge.expired` when the challenge is gone — it expired after five minutes, was already used, or was begun for a different account — so start a new sign-in; `passkey.verification.failed` when the assertion was checked and refused (signature, origin, relying-party id or challenge mismatch).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"`challengeId` or `credential` is missing, or (`meta.errorCode` = `passkey.response.invalid`, v1.42.0) `credential` is not a WebAuthn authentication response.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -87,7 +87,7 @@ export const ingestPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"Validation failed, with every issue listed rather than the first — an integration operator should see the whole shape at once. An empty or unstable `idempotencyKey` fails here: the column is unique table-wide, so an empty key would resolve to whichever unrelated row stored it first.",
|
||||
"Validation failed, with every issue listed rather than the first — an integration operator should see the whole shape at once. An empty or unstable `idempotencyKey` fails here: the column is unique table-wide, so an empty key would resolve to whichever unrelated row stored it first. Also 422 with `meta.errorCode` = `medication.intake.notTracked` (v1.42.0) when the named medication is kept as a record only (`trackIntake: false`); checked after the token's scope, so a token without this medication's scope learns nothing about it.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
|
||||
@@ -1079,7 +1079,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...stdResponses,
|
||||
"422": {
|
||||
description:
|
||||
"The batch exceeded the 500-entry limit (`measurement.batch.too_large`), failed validation (`measurement.batch.invalid`, with every issue under `details.issues`), or (`measurement.batch.source_not_permitted`) carried an entry naming a source, any source, under a narrow `measurements:write` credential. Nothing was written in any of the three cases, and all three are permanent for the batch as sent: resending it unchanged is refused the same way.",
|
||||
'The batch exceeded the 500-entry limit (`measurement.batch.too_large`), failed validation (`measurement.batch.invalid`, with every issue under `details.issues`), or (`measurement.batch.source_not_permitted`) carried an entry naming a source, any source, under a narrow `measurements:write` credential. Nothing was written in any of the three cases, and all three are permanent for the batch as sent: resending it unchanged is refused the same way. Which issues belong to an entry: every issue caused by one entry has a `path` that starts `entries.<n>` (the entry\'s index in the request, then the field, e.g. `entries.3.startDate`, or just `entries.4` when the entry is not an object). Those are the only per-entry issues, so a client may drop the named entries and resend the rest. An issue whose `path` is `""` (the body is not an object), `entries` (missing, not an array, or empty) or `syncTrigger` is about the batch itself, and no split recovers it.',
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -88,6 +88,7 @@ import {
|
||||
medicationCadenceResponse,
|
||||
medicationComplianceResponse,
|
||||
medicationComplianceSummaryEntry,
|
||||
medicationComplianceAggregate,
|
||||
scheduleRevisionResource,
|
||||
scheduleRevisionListResponse,
|
||||
medicationExtractRequest,
|
||||
@@ -212,7 +213,12 @@ const bulkIntakeEntryResult = z
|
||||
.describe(
|
||||
"`inserted`/`updated`/`duplicate` — the row landed (advance the cursor). `skipped` — not stored; see `reason`.",
|
||||
),
|
||||
reason: z.string().optional(),
|
||||
reason: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Why a `skipped` entry was not stored. Among others: `medication_not_found`, `unstable_external_id`, `force_slot_invalid`, `force_slot_occupied`, `injection_site_not_allowed`, and (v1.42.0) `intake_not_tracked` for an entry against a medication kept as a record only (`trackIntake: false`), the per-entry form of `medication.intake.notTracked`. A skip is final: resending the entry gives the same answer.",
|
||||
),
|
||||
id: z.string().optional().describe("The landed row id (absent on skips)."),
|
||||
})
|
||||
.meta({ id: "BulkMedicationIntakeEntryResult" });
|
||||
@@ -312,7 +318,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"Body validation failed (multi-issue envelope, breadcrumbed as `medications.intake.update.validation-failed`), or `meta.errorCode` = `medications.intake.injection_site.disallowed` when the site is outside the medication's effective allowed set, or `medications.intake.force_slot.invalid` when `forceSlotInstant` is not a real slot of the medication. The latter two carry no issue list — branch on the code.",
|
||||
"Body validation failed (multi-issue envelope, breadcrumbed as `medications.intake.update.validation-failed`), or `meta.errorCode` = `medications.intake.injection_site.disallowed` when the site is outside the medication's effective allowed set, or `medications.intake.force_slot.invalid` when `forceSlotInstant` is not a real slot of the medication, or (v1.42.0) `medication.intake.notTracked` when the event is still open and its medication is kept as a record only (`trackIntake: false`): resolving or snoozing it would record a new dose that is counted nowhere. Correcting an event that is already taken or skipped stays open. The coded refusals carry no issue list — branch on the code.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"401": stdResponses["401"],
|
||||
@@ -1437,6 +1443,11 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
...stdResponses,
|
||||
...recordWriteRateLimitResponse,
|
||||
"422": {
|
||||
description:
|
||||
"Body validation failed (multi-issue envelope), or `meta.errorCode` names the refusal: `medications.intake.injection_site.disallowed`, `medications.intake.force_slot.invalid`, or (v1.42.0) `medication.intake.notTracked` when the medication is kept as a record only (`trackIntake: false`). Its stored history stays; a new dose is refused rather than stored and counted nowhere. The check runs after ownership, so a foreign medication is still a 404.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
get: {
|
||||
@@ -1699,17 +1710,49 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Medications"],
|
||||
summary: "Batched adherence read for every medication of the caller",
|
||||
description:
|
||||
"Returns one compact row per non-PRN medication. A scheduled medication with no local schedule remains in the array with applicable=false and reason NO_LOCAL_SCHEDULE. compliance7/compliance30 stay non-null as all-zero compatibility placeholders for released clients; aware clients must ignore those percentages when applicable=false. complianceDisplay is null.",
|
||||
"Returns one compact row per non-PRN medication. A scheduled medication with no local schedule remains in the array with applicable=false and reason NO_LOCAL_SCHEDULE. compliance7/compliance30 stay non-null as all-zero compatibility placeholders for released clients; aware clients must ignore those percentages when applicable=false. complianceDisplay is null.\n\n" +
|
||||
"v1.42.0, additive: `meta.aggregate` carries one figure for the whole account (`MedicationComplianceAggregate`, weighted by expected doses across the applicable medications; `null` when none applies), so a client never averages the rows itself. `days` (30, 60, 90, 180 or 365) adds a `complianceN` block in the same shape to every row and to the aggregate, for a report that covers its own window; `meta.days` echoes it. Any other `days` is a 422 rather than a rounded window.",
|
||||
requestParams: {
|
||||
query: z.object({
|
||||
days: z
|
||||
.enum(["30", "60", "90", "180", "365"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Report window in days. 30 is already `compliance30`; the others add `compliance60` / `compliance90` / `compliance180` / `compliance365`.",
|
||||
),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "One adherence row per medication.",
|
||||
description:
|
||||
"One adherence row per medication, and the account aggregate in `meta`.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.array(medicationComplianceSummaryEntry),
|
||||
"ListMedicationComplianceResponse",
|
||||
),
|
||||
schema: z
|
||||
.object({
|
||||
data: z.array(medicationComplianceSummaryEntry),
|
||||
error: z.null(),
|
||||
meta: z
|
||||
.object({
|
||||
aggregate: medicationComplianceAggregate.nullable(),
|
||||
days: z
|
||||
.union([
|
||||
z.literal(30),
|
||||
z.literal(60),
|
||||
z.literal(90),
|
||||
z.literal(180),
|
||||
z.literal(365),
|
||||
])
|
||||
.optional()
|
||||
.describe("The `days` the request asked for."),
|
||||
requestId: z.string().optional(),
|
||||
})
|
||||
.describe(
|
||||
"Absent on servers older than v1.42.0; a client that reads it treats absence as no aggregate.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "ListMedicationComplianceResponse" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -936,6 +936,18 @@ export const medicationComplianceSummaryEntry = z
|
||||
compliance30: complianceResult.describe(
|
||||
"Thirty-day adherence summary, or an all-zero compatibility placeholder when applicable is false.",
|
||||
),
|
||||
compliance60: complianceResult
|
||||
.optional()
|
||||
.describe("Present only on `?days=60`: the trailing 60 days."),
|
||||
compliance90: complianceResult
|
||||
.optional()
|
||||
.describe("Present only on `?days=90`: the trailing 90 days."),
|
||||
compliance180: complianceResult
|
||||
.optional()
|
||||
.describe("Present only on `?days=180`: the trailing 180 days."),
|
||||
compliance365: complianceResult
|
||||
.optional()
|
||||
.describe("Present only on `?days=365`: the trailing 365 days."),
|
||||
complianceDisplay: complianceDisplay.nullable(),
|
||||
})
|
||||
.meta({
|
||||
@@ -1732,3 +1744,27 @@ export const medicationSideEffect = z
|
||||
description:
|
||||
"One logged side effect. Not a clinical record — the account owns it, and it stays deletable at any time rather than locking after a retraction window.",
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.42.0 — the account-wide adherence figure the batched read carries in
|
||||
* `meta.aggregate`, beside the per-medication rows.
|
||||
*/
|
||||
export const medicationComplianceAggregate = z
|
||||
.object({
|
||||
compliance7: complianceResult,
|
||||
compliance30: complianceResult,
|
||||
compliance60: complianceResult.nullable().optional(),
|
||||
compliance90: complianceResult.nullable().optional(),
|
||||
compliance180: complianceResult.nullable().optional(),
|
||||
compliance365: complianceResult.nullable().optional(),
|
||||
medicationCount: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.describe("How many applicable medications the figure is built from."),
|
||||
})
|
||||
.meta({
|
||||
id: "MedicationComplianceAggregate",
|
||||
description:
|
||||
"One adherence figure for the whole account in the `ComplianceResult` shape, weighted by expected doses: `taken`, `skipped` and `missed` are summed across every medication with `applicable: true`, and `rate` is recomputed from the sums by the per-medication rule (round(100 · taken / (taken + missed)), capped at 100, 100 when nothing was expected). Averaging the per-medication rates would let a weekly injection weigh as much as a three-times-daily tablet. `streak` is the shortest per-medication streak. The `complianceN` block for a requested `days` is present when `days` was sent and is not 30.",
|
||||
});
|
||||
@@ -955,7 +955,14 @@ const moodAggregatesResponse = z
|
||||
days: z.number().int(),
|
||||
})
|
||||
.nullable()
|
||||
.describe("Null for a sparse logger below the day floor."),
|
||||
.describe(
|
||||
"Day-to-day steadiness over the trailing `stabilityWindowDays` days. Null below the day floor (seven days with an entry) inside that window, so a short window can be null where the year is not.",
|
||||
),
|
||||
stabilityWindowDays: z
|
||||
.union([z.literal(30), z.literal(90), z.literal(180), z.literal(365)])
|
||||
.describe(
|
||||
"The window `stability` covers: the `days` query parameter, or 365 (the whole daily series) when it was not sent. Added in v1.42.0; absent from older servers, whose score always covers the year.",
|
||||
),
|
||||
tags: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -1277,7 +1284,17 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Mood"],
|
||||
summary: "Pre-computed mood aggregates (v1.8.5)",
|
||||
description:
|
||||
"The whole Mood Insights page in one read. The compute runs server-side and only pre-shaped blocks leave — no raw mood row is on this wire, and no LLM is reachable from this path. Stale-while-revalidate: past the fresh window the prior aggregate is served immediately while one background recompute warms a new one, so an active logger never re-pays the cold compute. Module-gated on `mood`; a disabled module answers 403 `module.disabled` even for a valid Bearer token. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record — which makes it the one mood surface a delegate cannot reach.",
|
||||
"The whole Mood Insights page in one read. The compute runs server-side and only pre-shaped blocks leave — no raw mood row is on this wire, and no LLM is reachable from this path. Stale-while-revalidate: past the fresh window the prior aggregate is served immediately while one background recompute warms a new one, so an active logger never re-pays the cold compute. Module-gated on `mood`; a disabled module answers 403 `module.disabled` even for a valid Bearer token. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record — which makes it the one mood surface a delegate cannot reach. v1.42.0 — `days` cuts `stability` to the period a client shows; every other block is unchanged by it.",
|
||||
requestParams: {
|
||||
query: z.object({
|
||||
days: z
|
||||
.enum(["30", "90", "180", "365"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Trailing window for `stability`, in days. Omitted: 365. Any other value is a 422.",
|
||||
),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The aggregate bundle.",
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
updatedAtTokenField,
|
||||
validationIssue,
|
||||
} from "./shared";
|
||||
|
||||
// v1.18.0 — module enable/disable. The PATCH request is the REAL runtime
|
||||
@@ -259,7 +260,7 @@ const notificationPrefsResolved = z
|
||||
clientManaged: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when the iOS app owns local medication reminders; the server-side MEDICATION_REMINDER APNs cron is suppressed.",
|
||||
"True when the iOS app delivers medication reminders locally. The server then keeps the reminder that announces a dose off APNs (the phone shows its own at dose time) and still pushes the follow-ups for a dose that stays open, which the phone does not send (v1.42.0; before, every MEDICATION_REMINDER push was suppressed). Other channels are never affected.",
|
||||
),
|
||||
deliveryDefault: z
|
||||
.enum(["server", "client"])
|
||||
@@ -587,13 +588,7 @@ const profileUpdateResponse = z
|
||||
.boolean()
|
||||
.describe("Whether a KVNR is on file (the value itself is not echoed)."),
|
||||
rejectedFields: z
|
||||
.array(
|
||||
z.object({
|
||||
path: z.string(),
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
.array(validationIssue)
|
||||
.optional()
|
||||
.describe(
|
||||
"Present only on a PARTIAL success: the fields that were skipped while the rest of the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save. `code` is the validator code for a field that failed validation, or `rate_limited` for an email-address change that has spent the account's hourly budget; that one is not a bad value and the same address will be accepted once the window rolls over. The handler has always been able to answer with this key on this path; it was published only on PUT /api/auth/profile.",
|
||||
@@ -1638,7 +1633,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Notifications"],
|
||||
summary: "Update per-user notification preferences",
|
||||
description:
|
||||
'Deep-merges the supplied partial shape over the persisted row — a PATCH touching only one category leaves the siblings intact. Always returns the fully-resolved next state so clients can hard-set their optimistic update. `medication.clientManaged: true` (or `deliveryDefault: "client"`) suppresses server-side MEDICATION_REMINDER APNs; `medication.lowStockRunwayDays` (1–60, nullable, default 7) tunes the low-stock alert — null switches it off. Rate-limit 60/min per user.',
|
||||
'Deep-merges the supplied partial shape over the persisted row — a PATCH touching only one category leaves the siblings intact. Always returns the fully-resolved next state so clients can hard-set their optimistic update. `medication.clientManaged: true` (or `deliveryDefault: "client"`) keeps the MEDICATION_REMINDER that announces a dose off APNs and leaves the follow-ups for a dose still open on it (v1.42.0); `medication.lowStockRunwayDays` (1–60, nullable, default 7) tunes the low-stock alert — null switches it off. Rate-limit 60/min per user.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
|
||||
@@ -32,7 +32,7 @@ import { createBatchWorkoutSchema as createBatchWorkoutSchemaBase } from "@/lib/
|
||||
* built for could not read it from the contract. `params` never ships: some
|
||||
* Zod codes embed the offending value in it and that is user content.
|
||||
*/
|
||||
const validationIssue = z
|
||||
export const validationIssue = z
|
||||
.object({
|
||||
path: z
|
||||
.string()
|
||||
|
||||
Reference in new issue
Block a user