mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
feat: lay down the shared v1.42 contract
Land every file the v1.42 work shares before any of it is built, so the parts built side by side never edit the same file. Schema and migrations 0379-0386: the HEALTH_CONNECT measurement source, ImportJob.kind, the air-quality columns and the per-user switch on the environment tables, sealed environment locations with nullable plaintext, managed-profile handover links, the per-type HealthKit arrival ledger, lab unit-conversion provenance, and user-defined vaccines. Every new column is annotated for the column-reader sweep. HEALTH_CONNECT reaches every exhaustive list: the source labels, the read enum (not the write allowlist), the source-priority ladder cap, now the number of sources rather than a fixed eight, and the write-path smoke test. RMSSD gets its own priority class and a 300 ms ceiling. The batch syncTrigger gains `manual`, with its label on the Apple Health card. Queues for the Health Connect import, the compaction-tombstone purge and the measurement-table maintenance are registered with stub handlers; host-metric sampling moves to every five minutes. Environment and work_mem variables reach the compose whitelist, the production example and the manifest. The proxy admits the handover link and redirects it at the edge, and the Health Connect upload bypasses the proxy body limit. The new routes answer 501 behind their final authentication and are published in the contract; backup, wipe and key rotation classify the new models and columns; the seven message bundles carry the base copy. Guards that list release-pending keys and columns fail once an entry is wired, so those lists empty themselves before the tag. Also re-anchor a login-throttle fixture to the wall clock, where its expiry is compared, and re-base the aggregate bundle cap on a measured 12 KB of new locale strings.
This commit is contained in:
1 parent
125b15bfb7
commit
e63be67b5a
82 files changed
+5099
-727
No files matched your search
@@ -98,6 +98,7 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
"insights.derived.composite.READINESS.component.hrv",
|
||||
"labs.catalog.hs-crp",
|
||||
"measurements.typeHrvRmssd",
|
||||
"settings.sections.sources.metrics.hrvRmssd",
|
||||
"measurements.typeVo2Max",
|
||||
// "{hours} h {minutes} min": the SI symbols for hour and minute.
|
||||
"measurements.durationHoursMinutes",
|
||||
@@ -112,6 +113,7 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
"admin.webPushVapidTitle",
|
||||
"measurements.sourceAppleHealth",
|
||||
"measurements.sourceGoogleHealth",
|
||||
"measurements.sourceHealthConnect",
|
||||
"medications.sourceAppleHealth",
|
||||
"settings.ai.providerChain.types.anthropic",
|
||||
"settings.ai.providerChain.types.codex",
|
||||
@@ -119,6 +121,7 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
"settings.sections.export.import.appleHealth.title",
|
||||
"settings.sections.sources.sourceLabels.APPLE_HEALTH",
|
||||
"settings.sections.sources.sourceLabels.GOOGLE_HEALTH",
|
||||
"settings.sections.sources.sourceLabels.HEALTH_CONNECT",
|
||||
"settings.appleHealth.title",
|
||||
"mood.tag.fastFood",
|
||||
// Technical / config labels conventionally kept verbatim.
|
||||
|
||||
@@ -66,6 +66,42 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [
|
||||
"apiErrors",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Keys a release lands ahead of their call sites, so that the packages built
|
||||
* in parallel on the release branch share one bundle and never edit it
|
||||
* themselves. Each entry names the package that wires the call sites and the
|
||||
* issue it serves.
|
||||
*
|
||||
* This list expires by construction: the companion test fails as soon as an
|
||||
* entry no longer covers a single orphan, so a prefix whose call sites have
|
||||
* all landed must be dropped in the same change, and the list is empty again
|
||||
* before the release is tagged. It is NOT a place for keys nobody will wire.
|
||||
*/
|
||||
const RELEASE_PENDING_PREFIXES: Record<string, string> = {
|
||||
"auth.claim": "v1.42 managed-profile claim page (#959)",
|
||||
"recordSharing.managed.handover":
|
||||
"v1.42 managed-profile handover dialog (#959)",
|
||||
"settings.sections.export.import.healthConnect":
|
||||
"v1.42 Health Connect import card (#972)",
|
||||
"settings.sections.environment.airQuality":
|
||||
"v1.42 environment settings, air quality (#615)",
|
||||
"environment.fields.apparentMax": "v1.42 air-quality field label (#615)",
|
||||
"environment.fields.pm10": "v1.42 air-quality field label (#615)",
|
||||
"environment.fields.no2": "v1.42 air-quality field label (#615)",
|
||||
"environment.fields.europeanAqi": "v1.42 air-quality field label (#615)",
|
||||
"environment.fields.uvIndexMax": "v1.42 air-quality field label (#615)",
|
||||
"environment.fields.dustMax": "v1.42 air-quality field label (#615)",
|
||||
"insights.correlation.dailySameDay":
|
||||
"v1.42 same-day environment correlation templates (#615)",
|
||||
"settings.appleHealth.freshness":
|
||||
"v1.42 per-type HealthKit freshness list (#1173)",
|
||||
"insights.workouts.manual.editTitle": "v1.42 manual workout edit (#1162)",
|
||||
"insights.workouts.manual.editAction": "v1.42 manual workout edit (#1162)",
|
||||
"insights.workouts.manual.updated": "v1.42 manual workout edit (#1162)",
|
||||
"vaccinations.custom": "v1.42 user-defined vaccines (#1005)",
|
||||
"labs.convertedFrom": "v1.42 lab unit conversion note (#1095)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Interpolating strings that LOOK like key templates and are not. Each entry
|
||||
* whitelists nothing — the template still contributes no reachable keys — it
|
||||
@@ -118,9 +154,14 @@ function nodePaths(obj: unknown, prefix: string, out: Set<string>): void {
|
||||
}
|
||||
}
|
||||
|
||||
function underPrefix(leaf: string, prefix: string): boolean {
|
||||
return leaf === prefix || leaf.startsWith(`${prefix}.`);
|
||||
}
|
||||
|
||||
function isAllowlisted(leaf: string): boolean {
|
||||
return DYNAMIC_ALLOWLIST_PREFIXES.some(
|
||||
(p) => leaf === p || leaf.startsWith(`${p}.`),
|
||||
return (
|
||||
DYNAMIC_ALLOWLIST_PREFIXES.some((p) => underPrefix(leaf, p)) ||
|
||||
Object.keys(RELEASE_PENDING_PREFIXES).some((p) => underPrefix(leaf, p))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,4 +226,18 @@ describe("i18n reverse coverage", () => {
|
||||
);
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it("every release-pending prefix still covers an unwired key", () => {
|
||||
const { leaves, ref } = scan();
|
||||
const stale = Object.keys(RELEASE_PENDING_PREFIXES).filter(
|
||||
(prefix) =>
|
||||
!leaves.some(
|
||||
(leaf) => underPrefix(leaf, prefix) && !isReferenced(leaf, ref),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
stale,
|
||||
"These RELEASE_PENDING_PREFIXES entries have all their call sites now — drop them",
|
||||
).toEqual([]);
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -79,6 +79,10 @@ const UNAUTHENTICATED: Readonly<Record<string, string>> = {
|
||||
"Says whether single sign-on is configured, so the sign-in screen can offer it.",
|
||||
"/api/notifications/vapid":
|
||||
"The instance's public Web Push key. Public by definition.",
|
||||
"/api/auth/claim/preview":
|
||||
"Previews a managed-profile handover link for a person who has no credential yet; the one-time token in the body is the credential.",
|
||||
"/api/auth/claim":
|
||||
"Turns a managed profile into the claimant's own account and creates their first credential; the one-time token in the body authorises it.",
|
||||
// The tombstones, sourced from the registry rather than restated one by one.
|
||||
// A retired path answers 410 from the proxy before any handler runs, and that
|
||||
// answer never reads the request's cookies or Authorization header — so it is
|
||||
|
||||
@@ -51,18 +51,21 @@ describe("User column backup classification", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// The person's own data, sealed at rest for privacy rather than a secret
|
||||
// that grants anything: the insurance number, and since v1.42 the home
|
||||
// location the environment module resolves weather against.
|
||||
const SEALED_SETTINGS = ["insuranceNumberEncrypted", "homeLocationEncrypted"];
|
||||
|
||||
it("carries no credential-shaped column as a setting", () => {
|
||||
// The insurance number is the one sealed setting: it is the person's own
|
||||
// data, sealed at rest for privacy, not a secret that grants anything.
|
||||
const sealedSettings = ACCOUNT_SETTING_COLUMNS.filter((column) =>
|
||||
/Encrypted$|Token|Secret|password|totp|oidc/i.test(column),
|
||||
);
|
||||
expect(sealedSettings).toEqual(["insuranceNumberEncrypted"]);
|
||||
expect(sealedSettings.sort()).toEqual([...SEALED_SETTINGS].sort());
|
||||
});
|
||||
|
||||
it("never classifies a sealed column other than the insurance number as anything but a credential", () => {
|
||||
it("never classifies a sealed column other than the sealed settings as anything but a credential", () => {
|
||||
const sealed = columns.filter(
|
||||
(c) => /Encrypted$/.test(c) && c !== "insuranceNumberEncrypted",
|
||||
(c) => /Encrypted$/.test(c) && !SEALED_SETTINGS.includes(c),
|
||||
);
|
||||
expect(sealed.length).toBeGreaterThan(20);
|
||||
expect(sealed.filter((c) => classified[c] !== "CREDENTIAL")).toEqual([]);
|
||||
|
||||
@@ -402,6 +402,11 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
"src/app/api/vaccinations/[id]/route.ts",
|
||||
"src/app/api/vaccinations/[id]/restore/route.ts",
|
||||
"src/app/api/vaccinations/[id]/links/route.ts",
|
||||
// v1.42 (#1005) — the record's own vaccine definitions. Same posture: a
|
||||
// restore must bring back the definitions its doses point at with the
|
||||
// surface hidden.
|
||||
"src/app/api/vaccinations/custom/route.ts",
|
||||
"src/app/api/vaccinations/custom/[id]/route.ts",
|
||||
// The booster mint and the upload suggestion are the same posture: a restore
|
||||
// or an import that arms a booster, or a document review that files a scan,
|
||||
// must keep working with the surface hidden, so the data routes stay exempt
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* `POST /api/admin/maintenance/measurements` — queue the operator-triggered
|
||||
* `VACUUM (ANALYZE)` / `REINDEX INDEX CONCURRENTLY` pass over `measurements`
|
||||
* (v1.42). Cookie-only admin: `requireAdmin()` refuses every Bearer.
|
||||
*
|
||||
* Contract stub: authenticates as the finished route will, then answers 501.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
return notImplemented();
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* `POST /api/auth/claim/preview` — what a handover link would hand over: the
|
||||
* profile's display name and each guardian's proposal (v1.42, #959).
|
||||
* Anonymous, token in the body, the same uniform 404 as the claim itself.
|
||||
*
|
||||
* Contract stub: answers 501.
|
||||
*/
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
|
||||
export const POST = apiHandler(async () => notImplemented());
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* `POST /api/auth/claim` — claim a managed profile through its handover link
|
||||
* (v1.42, #959). Anonymous by design: the person claiming has no credentials
|
||||
* yet. The one-time `hlp_` token travels in the body, never in the URL of
|
||||
* this request, and every failure answers the same 404.
|
||||
*
|
||||
* Contract stub: answers 501.
|
||||
*/
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
|
||||
export const POST = apiHandler(async () => notImplemented());
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* `PATCH /api/environment/preferences` — the caller's environment switches,
|
||||
* today `airQualityEnabled` (v1.42, #615). Module-gated like the rest of the
|
||||
* environment surface.
|
||||
*
|
||||
* Contract stub: authenticates as the finished route will, then answers 501.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
export const PATCH = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
return notImplemented();
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* `POST /api/import/health-connect-export` — upload an Android Health Connect
|
||||
* export ZIP for background import (v1.42, #972). Outside the proxy matcher
|
||||
* like the Apple Health upload (`src/lib/http/proxy-bypass-routes.ts`), so the
|
||||
* body is streamed to disk after authentication instead of being buffered.
|
||||
*
|
||||
* Contract stub: authenticates as the finished route will, then answers 501.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
await requireAuth();
|
||||
return notImplemented();
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* `GET /api/import/health-connect-export/status` — the caller's most recent
|
||||
* Health Connect import job (v1.42, #972).
|
||||
*
|
||||
* Contract stub: authenticates as the finished route will, then answers 501.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAuth();
|
||||
return notImplemented();
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* `POST /api/managed-profiles/[id]/handover` — mint the one-time link that
|
||||
* hands a managed profile over to the person it describes, with a proposal
|
||||
* per guardian; `DELETE` withdraws the open link (v1.42, #959). Cookie-only
|
||||
* like every act on a managed profile; minting is step-up gated, withdrawing
|
||||
* is not.
|
||||
*
|
||||
* Contract stub: authenticates as the finished route will, then answers 501.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
await requireCookieAuth();
|
||||
return notImplemented();
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
await requireCookieAuth();
|
||||
return notImplemented();
|
||||
});
|
||||
@@ -181,7 +181,9 @@ const batchEntrySchema = z.object({
|
||||
// client knows its own wake reason, so it is client-asserted by necessity;
|
||||
// being diagnostic-only, that carries no trust surface. Optional so every
|
||||
// pre-#66 caller stays byte-for-byte unchanged.
|
||||
const syncTriggerEnum = z.enum(["foreground", "background", "push"]);
|
||||
// v1.42 (#1173) — `manual` names a user-initiated "Sync all" run, as opposed to
|
||||
// the app simply being open (`foreground`).
|
||||
const syncTriggerEnum = z.enum(["foreground", "background", "push", "manual"]);
|
||||
|
||||
const batchPayloadSchema = z.object({
|
||||
entries: z.array(batchEntrySchema).min(1),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* `PATCH /api/vaccinations/custom/[id]` edits one of the record's own vaccine
|
||||
* definitions; `DELETE` soft-deletes it, leaving every dose logged against it
|
||||
* on its `vaccineName` (v1.42, #1005). Record-scoped like the dose log.
|
||||
*
|
||||
* Contract stub: owner-only until implemented, then answers 501. The
|
||||
* finished routes resolve the record through `requireRecordAuth` like the
|
||||
* dose log and join the sharing guard's delegable lists with their audit
|
||||
* rows; a stub cannot carry those, so it does not claim the admission.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
|
||||
export const PATCH = apiHandler(async () => {
|
||||
await requireAuth();
|
||||
return notImplemented();
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
await requireAuth();
|
||||
return notImplemented();
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* `GET /api/vaccinations/custom` lists the record's own vaccine definitions;
|
||||
* `POST` adds one (v1.42, #1005). Record-scoped like the dose log.
|
||||
*
|
||||
* Contract stub: owner-only until implemented, then answers 501. The
|
||||
* finished routes resolve the record through `requireRecordAuth` like the
|
||||
* dose log and join the sharing guard's delegable lists with their audit
|
||||
* rows; a stub cannot carry those, so it does not claim the admission.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAuth();
|
||||
return notImplemented();
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
await requireAuth();
|
||||
return notImplemented();
|
||||
});
|
||||
@@ -65,6 +65,10 @@ const PUBLIC_PATHS = [
|
||||
// so without the entry the shell classifies the route as protected and
|
||||
// `router.replace("/auth/login")` wins the race against the redirect.
|
||||
"/invite/",
|
||||
// v1.42 (#959) — the managed-profile handover link is an edge redirect onto
|
||||
// `/auth/claim?token=…`, public in `proxy.ts` for the same reason as
|
||||
// `/invite/`.
|
||||
"/claim/",
|
||||
];
|
||||
|
||||
export function AuthShell({
|
||||
|
||||
@@ -34,7 +34,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
|
||||
import { IntegrationErrorMessage, pillStateForVerdict } from "./shared";
|
||||
|
||||
type SyncTrigger = "foreground" | "background" | "push";
|
||||
type SyncTrigger = "foreground" | "background" | "push" | "manual";
|
||||
|
||||
interface HealthKitStatus {
|
||||
lastSyncedAt: string | null;
|
||||
@@ -65,6 +65,7 @@ const TRIGGER_KEYS: Record<SyncTrigger, string> = {
|
||||
foreground: "settings.appleHealth.delivery.trigger.foreground",
|
||||
background: "settings.appleHealth.delivery.trigger.background",
|
||||
push: "settings.appleHealth.delivery.trigger.push",
|
||||
manual: "settings.appleHealth.delivery.trigger.manual",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -85,6 +85,7 @@ const METRIC_LABEL_KEYS: Record<SourcePriorityMetricKey, string> = {
|
||||
bodyTemperature: "settings.sections.sources.metrics.bodyTemperature",
|
||||
spo2: "settings.sections.sources.metrics.spo2",
|
||||
hrv: "settings.sections.sources.metrics.hrv",
|
||||
hrvRmssd: "settings.sections.sources.metrics.hrvRmssd",
|
||||
restingHeartRate: "settings.sections.sources.metrics.restingHeartRate",
|
||||
vo2Max: "settings.sections.sources.metrics.vo2Max",
|
||||
// v1.11.0 — WHOOP-overlapping metric classes + native-vs-derived recovery.
|
||||
|
||||
@@ -161,9 +161,13 @@ describe("somebody guessing from two addresses", () => {
|
||||
it("the owner's remembered browser still gets through", async () => {
|
||||
await tenGuesses();
|
||||
deviceCookie = "device-token";
|
||||
// The device's expiry is compared against the wall clock, not the mocked
|
||||
// rate-limit clock `now`, so it is anchored to the wall clock too: an
|
||||
// expiry fixed relative to `now` expired for real once the calendar
|
||||
// passed it.
|
||||
vi.mocked(prisma.trustedDevice.findUnique).mockResolvedValue({
|
||||
userId: "owner-1",
|
||||
expiresAt: new Date(now + 1e9),
|
||||
expiresAt: new Date(Date.now() + 30 * 86_400_000),
|
||||
} as never);
|
||||
expect(await guess("203.0.113.51", { right: true })).toBe("checked");
|
||||
});
|
||||
|
||||
@@ -60,6 +60,14 @@ export const WORKOUT_ROUTE_GEOMETRY_AAD = "healthlog/workout-route-geometry/v1";
|
||||
export const DOCUMENT_AI_RUN_INPUT_AAD = "healthlog/document-ai-run-input/v1";
|
||||
export const DOCUMENT_AI_RUN_RESULT_AAD = "healthlog/document-ai-run-result/v1";
|
||||
|
||||
/**
|
||||
* The associated-data label the environment module's coarse locations are
|
||||
* sealed with (v1.42, #615): the home, each dated location period, and each
|
||||
* day's resolved location. One label for all three, because they hold the
|
||||
* same kind of value; it keeps a location from opening as anything else.
|
||||
*/
|
||||
export const ENVIRONMENT_LOCATION_AAD = "healthlog/environment-location/v1";
|
||||
|
||||
export interface EncryptedColumn {
|
||||
/** Prisma model name (PascalCase, as declared in schema.prisma). */
|
||||
readonly model: string;
|
||||
@@ -395,6 +403,32 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
|
||||
aad: WORKOUT_ROUTE_GEOMETRY_AAD,
|
||||
},
|
||||
|
||||
// ───── v1.42 environment locations (Bytes, binary2) ─────
|
||||
// The coarse home, each dated location period, and each day's resolved
|
||||
// location, sealed as (lat, lon, label) under one label. The plaintext
|
||||
// columns beside them empty as the encryption backfill runs.
|
||||
{
|
||||
model: "User",
|
||||
field: "homeLocationEncrypted",
|
||||
kind: "bytes",
|
||||
codec: "binary2",
|
||||
aad: ENVIRONMENT_LOCATION_AAD,
|
||||
},
|
||||
{
|
||||
model: "EnvironmentTravelLocation",
|
||||
field: "locationEncrypted",
|
||||
kind: "bytes",
|
||||
codec: "binary2",
|
||||
aad: ENVIRONMENT_LOCATION_AAD,
|
||||
},
|
||||
{
|
||||
model: "EnvironmentContext",
|
||||
field: "locationEncrypted",
|
||||
kind: "bytes",
|
||||
codec: "binary2",
|
||||
aad: ENVIRONMENT_LOCATION_AAD,
|
||||
},
|
||||
|
||||
// ───── v1.38.0 dose free text (Bytes column) ─────
|
||||
// What the person wrote about the dose — a reaction, a sore arm, why the
|
||||
// schedule went the way it did. The Pass's own structured fields stay
|
||||
@@ -551,6 +585,8 @@ export const UPDATED_AT_MODELS: ReadonlySet<string> = new Set([
|
||||
"DocumentThumbnail",
|
||||
"EcgRecording",
|
||||
"Encounter",
|
||||
"EnvironmentContext",
|
||||
"EnvironmentTravelLocation",
|
||||
"ExtractedFact",
|
||||
"FamilyHistoryEntry",
|
||||
"FitbitConnection",
|
||||
|
||||
@@ -114,6 +114,9 @@ export const WIPE_MODELS = [
|
||||
// ahead of both.
|
||||
"VaccinationDocumentLink",
|
||||
"VaccinationRecord",
|
||||
// v1.42 (#1005) — the person's own vaccine definitions. After the doses,
|
||||
// which point at them with SetNull, so neither count reads short.
|
||||
"CustomVaccine",
|
||||
|
||||
// ── Clinical record ─────────────────────────────────────────────────────
|
||||
"MentalHealthAssessment",
|
||||
@@ -173,6 +176,14 @@ export const WIPE_MODELS = [
|
||||
// was read off it; a child of the document, so it goes before it.
|
||||
"DocumentAiRun",
|
||||
"ImportJob",
|
||||
// v1.42 (#1173) — when each HealthKit type last arrived. Diagnostic, but a
|
||||
// record of what the account's phone sent and when.
|
||||
"HealthKitTypeSync",
|
||||
// v1.42 (#959) — handover links, on both sides: the ones minted for this
|
||||
// profile, and the ones this account minted for a profile it looks after.
|
||||
// The second is the AccountGrant argument: an account that asked for
|
||||
// everything of its own to be deleted must not leave a live link behind.
|
||||
"ManagedProfileHandover",
|
||||
|
||||
// ── AI ──────────────────────────────────────────────────────────────────
|
||||
"CoachConversation",
|
||||
@@ -360,6 +371,8 @@ export const USER_RESET = {
|
||||
homeLabel: null,
|
||||
homeTimezone: null,
|
||||
homeSince: null,
|
||||
homeLocationEncrypted: null,
|
||||
environmentAirQualityEnabled: true,
|
||||
|
||||
// Derived AI output cached on the row
|
||||
insightsPrivacyMode: "aggregated",
|
||||
@@ -512,6 +525,7 @@ export const WIPE_OWNER_FIELDS: Readonly<Record<string, readonly string[]>> = {
|
||||
MedicationIntakeImportJob: ["recordUserId"],
|
||||
NotificationEvent: ["recordUserId"],
|
||||
NotificationEgressAuthorization: ["recordUserId", "recipientUserId"],
|
||||
ManagedProfileHandover: ["profileId", "createdById"],
|
||||
// Both sides. Wiping only the grantor side would leave the account still
|
||||
// holding read access to other people's records after it asked for
|
||||
// everything of its own to be deleted.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* The air-quality day and its attributions (v1.42, #615).
|
||||
*
|
||||
* One `EnvironmentContext` row carries, beside the weather, the day's air
|
||||
* quality, pollen and UV from the Open-Meteo air-quality API (CAMS models).
|
||||
* This is the shape every reader agrees on: the environment overview, the
|
||||
* Coach and MCP read, and the day view. Every value is nullable, because a
|
||||
* partial feed stores what it has and a day the feed never covered is
|
||||
* absent, not zero.
|
||||
*/
|
||||
|
||||
/** One local day of air quality, as stored and as served. */
|
||||
export interface AirQualityDay {
|
||||
/** Daily apparent ("feels-like") maximum, °C. */
|
||||
apparentMax: number | null;
|
||||
/** PM2.5 daily mean and hourly maximum, µg/m³. */
|
||||
pm25Mean: number | null;
|
||||
pm25Max: number | null;
|
||||
/** PM10 daily mean, µg/m³. */
|
||||
pm10Mean: number | null;
|
||||
/** NO2, SO2 and CO daily means, µg/m³. */
|
||||
no2Mean: number | null;
|
||||
so2Mean: number | null;
|
||||
coMean: number | null;
|
||||
/** Ozone, highest 8-hour running mean of the day, µg/m³. */
|
||||
o3Max8h: number | null;
|
||||
/** European and US air-quality index, daily maximum. */
|
||||
eaqiMax: number | null;
|
||||
usaqiMax: number | null;
|
||||
/** UV index, daily maximum. */
|
||||
uvIndexMax: number | null;
|
||||
/** Saharan dust (µg/m³) and aerosol optical depth, daily maximum. */
|
||||
dustMax: number | null;
|
||||
aodMax: number | null;
|
||||
/** Pollen daily maxima, grains/m³; Europe only. */
|
||||
pollenAlderMax: number | null;
|
||||
pollenBirchMax: number | null;
|
||||
pollenGrassMax: number | null;
|
||||
pollenMugwortMax: number | null;
|
||||
pollenOliveMax: number | null;
|
||||
pollenRagweedMax: number | null;
|
||||
/** Which model domain served the day, e.g. `cams_europe`. */
|
||||
aqDomain: string | null;
|
||||
/** Hourly samples the day was aggregated from. */
|
||||
aqHours: number | null;
|
||||
/** When the air-quality part was fetched, ISO-8601; null = not yet. */
|
||||
aqFetchedAt: string | null;
|
||||
}
|
||||
|
||||
/** Who the values come from, shown wherever they are shown. */
|
||||
export const AIR_QUALITY_ATTRIBUTIONS = [
|
||||
{
|
||||
key: "cams",
|
||||
name: "Copernicus Atmosphere Monitoring Service (CAMS)",
|
||||
url: "https://atmosphere.copernicus.eu/",
|
||||
},
|
||||
{
|
||||
key: "open-meteo",
|
||||
name: "Open-Meteo",
|
||||
url: "https://open-meteo.com/",
|
||||
},
|
||||
] as const;
|
||||
@@ -174,7 +174,7 @@ export async function fetchAndStoreEnvironment(args: {
|
||||
}
|
||||
: null;
|
||||
|
||||
const travelRows = await prisma.environmentTravelLocation.findMany({
|
||||
const travelRecords = await prisma.environmentTravelLocation.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
startDate: true,
|
||||
@@ -185,6 +185,15 @@ export async function fetchAndStoreEnvironment(args: {
|
||||
},
|
||||
});
|
||||
|
||||
// Since v1.42 the plaintext location is nullable (migration 0382): a period
|
||||
// whose plaintext the encryption backfill has already cleared resolves only
|
||||
// through its sealed copy, which this reader does not open yet.
|
||||
const travelRows: TravelOverride[] = travelRecords.flatMap((row) =>
|
||||
row.lat != null && row.lon != null && row.label != null
|
||||
? [{ ...row, lat: row.lat, lon: row.lon, label: row.label }]
|
||||
: [],
|
||||
);
|
||||
|
||||
// The timezone used to enumerate AND fetch days — one tz keeps the stored
|
||||
// `date` consistent with the user's day-keys elsewhere.
|
||||
const timezone = home?.timezone ?? user.timezone;
|
||||
|
||||
@@ -381,6 +381,37 @@ const insuranceNumber: SettingCodec = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.42 (#615) — the sealed home location. A disaster-recovery file carries
|
||||
* the ciphertext verbatim, like the insurance number, because it is restored
|
||||
* onto a host holding the same keys. A portable file leaves it out: the
|
||||
* readable home rides `homeLat` / `homeLon` / `homeLabel` until those columns
|
||||
* drop, and the receiving host's encryption backfill seals it again.
|
||||
*/
|
||||
const homeLocationSealed: SettingCodec = {
|
||||
write: (row, disasterRecovery) => {
|
||||
if (!disasterRecovery) return {};
|
||||
const sealed = row.homeLocationEncrypted;
|
||||
return {
|
||||
homeLocationEncrypted:
|
||||
sealed instanceof Uint8Array
|
||||
? Buffer.from(sealed).toString("base64")
|
||||
: null,
|
||||
};
|
||||
},
|
||||
read: (entry) => {
|
||||
if (!has(entry, "homeLocationEncrypted")) return undefined;
|
||||
const value = entry.homeLocationEncrypted;
|
||||
if (value === null) return null;
|
||||
if (typeof value !== "string") return new Refused("homeLocationEncrypted");
|
||||
const buffer = Buffer.from(value, "base64");
|
||||
if (buffer.byteLength === 0) return new Refused("homeLocationEncrypted");
|
||||
const bytes = new Uint8Array(new ArrayBuffer(buffer.byteLength));
|
||||
bytes.set(buffer);
|
||||
return bytes;
|
||||
},
|
||||
};
|
||||
|
||||
const ACCOUNT_SETTING_CODECS: Readonly<
|
||||
Record<AccountSettingColumn, SettingCodec>
|
||||
> = {
|
||||
@@ -402,6 +433,8 @@ const ACCOUNT_SETTING_CODECS: Readonly<
|
||||
homeLabel: plain("homeLabel"),
|
||||
homeTimezone: timeZone("homeTimezone", true),
|
||||
homeSince: instant("homeSince"),
|
||||
homeLocationEncrypted: homeLocationSealed,
|
||||
environmentAirQualityEnabled: plain("environmentAirQualityEnabled"),
|
||||
timezone: timeZone("timezone", false),
|
||||
locale: oneOf("locale", locales, true),
|
||||
// The vocabularies the two preference routes accept, read from their own
|
||||
|
||||
@@ -171,6 +171,10 @@ export const BACKED_UP_MODELS = [
|
||||
// re-syncs it from a provider.
|
||||
"VaccinationRecord",
|
||||
"VaccinationDocumentLink",
|
||||
// v1.42 (#1005) — the person's own vaccine definitions. A dose logged
|
||||
// against one resolves its antigens and booster rule through it, so a
|
||||
// restore without them leaves those doses as bare names.
|
||||
"CustomVaccine",
|
||||
|
||||
// ── Coach ─────────────────────────────────────────────────────────────────
|
||||
// The transcript — conversation, message, attachment — travels; see
|
||||
@@ -585,6 +589,8 @@ export const STRUCTURALLY_UNATTRIBUTABLE: Readonly<Record<string, string>> = {
|
||||
* rather than by what is cheapest to write.
|
||||
*/
|
||||
export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
|
||||
CustomVaccine:
|
||||
"The person's own vaccine definitions (v1.42, #1005). Until both ends land, a restored dose that pointed at one comes back as its bare `vaccineName`: no antigens, no series position, no booster rule.",
|
||||
WorkoutRoute:
|
||||
"GPS traces. Deliberately absent from the payload today and DISCLOSED as absent in the file's own manifest, which is why this is a documented exclusion rather than a silent one — but it is still a loss for a self-hoster with no other copy.",
|
||||
WorkoutSamples:
|
||||
@@ -718,6 +724,10 @@ export const NOT_IN_BACKUP_MODELS: Readonly<Record<string, string>> = {
|
||||
"Further import source keys that were answered with an already stored document because the bytes matched. The next import run writes the same alias again the moment it meets the same bytes, so a restore loses nothing a re-run does not rebuild; and for a deleted document it restores no less than the tombstones and ledger beside it do, which do not travel either.",
|
||||
ImportJob:
|
||||
"A job record pointing at an uploaded file that the backup does not carry, so restoring it would resurrect a task with nothing to work on.",
|
||||
HealthKitTypeSync:
|
||||
"When each HealthKit type last arrived on this host and how. Diagnostic state the next sync rebuilds; restored elsewhere it would claim deliveries that host never received.",
|
||||
ManagedProfileHandover:
|
||||
"A one-time handover link: a credential, minted by a guardian and shown once. Restoring it would bring back a link that was used, withdrawn or expired, and only its hash exists to carry anyway. Same reasoning as `InviteToken`.",
|
||||
InviteToken:
|
||||
"The instance's registration ledger, minted by the operator rather than by the person, and already declared instance-scoped by the wipe plan. Restoring one account's backup must not re-open a registration code the operator retired, and the creator relation is the only thing that makes this look account-scoped at all. It surfaced here when the classification check learned to read a relation by type instead of by field name.",
|
||||
MedicationIntakeImportJob:
|
||||
@@ -914,6 +924,10 @@ export const USER_COLUMN_BACKUP_CLASS = {
|
||||
homeLabel: "SETTING",
|
||||
homeTimezone: "SETTING",
|
||||
homeSince: "SETTING",
|
||||
// v1.42 (#615) — the sealed copy of the same home location, and the
|
||||
// account's switch for the air-quality part of the module.
|
||||
homeLocationEncrypted: "SETTING",
|
||||
environmentAirQualityEnabled: "SETTING",
|
||||
// How the interface reads.
|
||||
timezone: "SETTING",
|
||||
locale: "SETTING",
|
||||
|
||||
@@ -66,9 +66,13 @@ import { roundCoarse } from "@/lib/environment/open-meteo";
|
||||
export interface EnvironmentContextBackupEntry {
|
||||
/** `YYYY-MM-DD`, anchored to the resolved location's timezone. */
|
||||
date: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
locationLabel: string;
|
||||
/**
|
||||
* Plaintext coarse location. Nullable since v1.42 (migration 0382): the
|
||||
* sealed copy replaces it once the encryption backfill has run.
|
||||
*/
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
locationLabel: string | null;
|
||||
/**
|
||||
* Which precedence rule chose the location. Carried because it is the only
|
||||
* record that this day was NOT the home city, and because a re-resolve
|
||||
@@ -98,9 +102,10 @@ export interface EnvironmentTravelLocationBackupEntry {
|
||||
/** Inclusive `YYYY-MM-DD` bounds. Strings end to end; see the file header. */
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
label: string;
|
||||
/** Plaintext coarse location; nullable since v1.42 (migration 0382). */
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
label: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -315,8 +320,8 @@ export async function restoreEnvironmentData(
|
||||
endDate: entry.endDate,
|
||||
// A file written before v1.39.4 carries 2-decimal coordinates; they
|
||||
// come back at today's privacy floor.
|
||||
lat: roundCoarse(entry.lat),
|
||||
lon: roundCoarse(entry.lon),
|
||||
lat: entry.lat == null ? null : roundCoarse(entry.lat),
|
||||
lon: entry.lon == null ? null : roundCoarse(entry.lon),
|
||||
label: entry.label,
|
||||
...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}),
|
||||
...(entry.updatedAt ? { updatedAt: new Date(entry.updatedAt) } : {}),
|
||||
@@ -329,8 +334,8 @@ export async function restoreEnvironmentData(
|
||||
data: payload.environmentContexts.map((entry) => ({
|
||||
userId: ownerId,
|
||||
date: entry.date,
|
||||
lat: roundCoarse(entry.lat),
|
||||
lon: roundCoarse(entry.lon),
|
||||
lat: entry.lat == null ? null : roundCoarse(entry.lat),
|
||||
lon: entry.lon == null ? null : roundCoarse(entry.lon),
|
||||
locationLabel: entry.locationLabel,
|
||||
source: entry.source,
|
||||
tempMin: entry.tempMin ?? null,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* The answer a route gives while its contract exists and its body does not.
|
||||
*
|
||||
* A release built in parallel packages lands every new route as a stub first,
|
||||
* registered in the OpenAPI table with its final shape, so the route-coverage
|
||||
* and contract checks hold in every package's worktree from the start. The
|
||||
* stub authenticates exactly as the finished route will and then answers 501.
|
||||
*
|
||||
* Nothing may ship with a stub in it: the error-code catalogue check fails on
|
||||
* a listed code no route emits, so the code leaves the catalogue, and this
|
||||
* file with it, once the last stub is implemented.
|
||||
*/
|
||||
import { apiError } from "@/lib/api-response";
|
||||
|
||||
export const NOT_IMPLEMENTED_ERROR_CODE = "not_implemented";
|
||||
|
||||
export function notImplemented() {
|
||||
return apiError("Not implemented", 501, {
|
||||
errorCode: NOT_IMPLEMENTED_ERROR_CODE,
|
||||
});
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { DEMO_REFUSAL_BODY } from "@/lib/demo-refusal";
|
||||
export const PROXY_BYPASS_ROUTES: readonly string[] = [
|
||||
// Streamed archive uploads, up to 1.5 GB.
|
||||
"/api/import/apple-health-export",
|
||||
"/api/import/health-connect-export",
|
||||
"/api/admin/import-apple-health-export",
|
||||
// Backup file upload, up to 512 MB raw.
|
||||
"/api/admin/backups/upload",
|
||||
|
||||
@@ -45,6 +45,7 @@ export const MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS: Record<
|
||||
// Never offered in the ladder (it is on no default priority list), but
|
||||
// the map is keyed by the enum, so the entry is not optional.
|
||||
EXTERNAL: "settings.sections.sources.sourceLabels.EXTERNAL",
|
||||
HEALTH_CONNECT: "settings.sections.sources.sourceLabels.HEALTH_CONNECT",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -71,6 +72,7 @@ export const MEASUREMENT_SOURCE_LIST_LABEL_KEYS: Record<
|
||||
GOOGLE_HEALTH: "measurements.sourceGoogleHealth",
|
||||
STRAVA: "measurements.sourceStrava",
|
||||
EXTERNAL: "measurements.sourceExternal",
|
||||
HEALTH_CONNECT: "measurements.sourceHealthConnect",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Per-type HealthKit arrival ledger (v1.42, #1173).
|
||||
*
|
||||
* The batch route records, per measurement type it received, when the type
|
||||
* last arrived, under which `syncTrigger`, and when it last brought a new or
|
||||
* updated sample (as opposed to duplicates only). The Apple Health card reads
|
||||
* it to show which types are actually flowing. A scoped ingest credential
|
||||
* does not write it, the same rule as `lastSyncedAt`.
|
||||
*
|
||||
* Contract stub: the signature is final; the body records nothing yet.
|
||||
*/
|
||||
import type { MeasurementType, Prisma } from "@/generated/prisma/client";
|
||||
|
||||
export type HealthKitSyncTrigger =
|
||||
"foreground" | "background" | "push" | "manual";
|
||||
|
||||
/** What one batch did for one type. */
|
||||
export interface HealthKitTypeArrival {
|
||||
/** Entries of this type the batch accepted (inserted, updated or duplicate). */
|
||||
accepted: number;
|
||||
/** Entries of this type that were inserted or updated. */
|
||||
newSamples: number;
|
||||
}
|
||||
|
||||
export async function recordHealthKitTypeArrivals(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: {
|
||||
userId: string;
|
||||
counts: ReadonlyMap<MeasurementType, HealthKitTypeArrival>;
|
||||
trigger: HealthKitSyncTrigger | null;
|
||||
at?: Date;
|
||||
},
|
||||
): Promise<void> {
|
||||
void tx;
|
||||
void input;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Backlog purge of compaction tombstones (v1.42).
|
||||
*
|
||||
* The dense-intraday consolidation used to soft-delete the raw rows it folded
|
||||
* into an hourly or daily `stats:` row. Those tombstones ("class A") never
|
||||
* reach a client as a meaningful deletion and only weigh on the
|
||||
* `measurements` table and its indexes. This queue removes the backlog in
|
||||
* batches of 5 000 rows, each batch under `holdAccountAgainstRestore`; the
|
||||
* delete predicate is idempotent, so a run that stops early simply resumes on
|
||||
* the next one. User deletions ("class B") are never touched here and keep
|
||||
* their 75-day retention.
|
||||
*
|
||||
* Contract stub: the queue is registered and bound so the registrar, the
|
||||
* runtime table and the subsystem surface are settled. The handler refuses to
|
||||
* claim success until the purge is implemented, and the boot enqueue sends
|
||||
* nothing yet.
|
||||
*/
|
||||
import { jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
|
||||
export const COMPACTION_TOMBSTONE_PURGE_QUEUE = "compaction-tombstone-purge";
|
||||
|
||||
/** Empty payload: one run walks every account. */
|
||||
export type CompactionTombstonePurgePayload = Record<string, never>;
|
||||
|
||||
export async function handleCompactionTombstonePurge(): Promise<JobOutcome> {
|
||||
return jobFailed("compaction tombstone purge not implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue one purge run when the worker boots. The run is self-limiting, so a
|
||||
* boot that finds no backlog costs one cheap query. Sends nothing until the
|
||||
* purge is implemented.
|
||||
*/
|
||||
export async function enqueueBootTimeCompactionTombstonePurge(): Promise<{
|
||||
enqueued: boolean;
|
||||
}> {
|
||||
return { enqueued: false };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Android Health Connect export import worker (v1.42, #972).
|
||||
*
|
||||
* Reads the `health_connect_export.db` SQLite file out of an uploaded export
|
||||
* ZIP (read-only, `immutable=1`) and writes its records as `HEALTH_CONNECT`
|
||||
* measurements through the same reconciliation path the other external
|
||||
* sources use. Mirrors the Apple Health import: the upload is staged to disk
|
||||
* by the route, an `ImportJob` row (`kind = "health_connect"`) carries the
|
||||
* progress, and the run consumes its staged file, so it is a one-shot.
|
||||
*
|
||||
* Contract stub: the queue is registered and bound; the handler refuses to
|
||||
* claim success until the importer is implemented.
|
||||
*/
|
||||
import { jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
|
||||
export const HEALTH_CONNECT_IMPORT_QUEUE = "health-connect-import";
|
||||
|
||||
/** One import at a time per worker: the SQLite read and the writes are heavy. */
|
||||
export const HEALTH_CONNECT_IMPORT_CONCURRENCY = 1;
|
||||
|
||||
/** Same shape and reasoning as `APPLE_HEALTH_IMPORT_SEND_OPTIONS`. */
|
||||
export const HEALTH_CONNECT_IMPORT_SEND_OPTIONS = {
|
||||
retryLimit: 0,
|
||||
expireInSeconds: 6 * 60 * 60,
|
||||
} as const;
|
||||
|
||||
/** Payload `boss.send` carries onto the queue. */
|
||||
export interface HealthConnectImportPayload {
|
||||
/** Owner of the imported rows. */
|
||||
userId: string;
|
||||
/** The `ImportJob` row that mirrors this run. */
|
||||
importJobId: string;
|
||||
/** Absolute path on the worker filesystem where the upload landed. */
|
||||
uploadPath: string;
|
||||
}
|
||||
|
||||
export async function handleHealthConnectImport(): Promise<JobOutcome> {
|
||||
return jobFailed("health connect import not implemented");
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* One report per job run with one outcome per candidate (v1.42).
|
||||
*
|
||||
* A pass over many accounts used to annotate its wide event once per
|
||||
* candidate, and the last write won: a run where six accounts worked and one
|
||||
* failed read as whatever the last account did. `reportJobRun` takes the
|
||||
* whole candidate list instead, so the run's line carries every outcome and
|
||||
* the level follows the worst of them.
|
||||
*
|
||||
* Contract stub: the signature is fixed for its callers (the insight
|
||||
* pre-generation, the tombstone purge, the measurement maintenance). Until
|
||||
* the implementation lands it annotates the run's counts the way the callers
|
||||
* do today.
|
||||
*/
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/** How one candidate of a run ended. */
|
||||
export type JobRunOutcome =
|
||||
"ok" | "skipped" | "deferred" | "auth_failed" | "screened" | "failed";
|
||||
|
||||
export interface JobRunCandidate {
|
||||
/** Stable, non-identifying key (a scope or a hashed id, never a user id). */
|
||||
key: string;
|
||||
outcome: JobRunOutcome;
|
||||
/** Short, stable cause for a non-`ok` outcome. */
|
||||
cause?: string;
|
||||
}
|
||||
|
||||
export function reportJobRun(input: {
|
||||
queue: string;
|
||||
runId: string;
|
||||
candidates: readonly JobRunCandidate[];
|
||||
}): void {
|
||||
const failed = input.candidates.filter(
|
||||
(c) => c.outcome === "failed" || c.outcome === "auth_failed",
|
||||
).length;
|
||||
annotate({
|
||||
meta: {
|
||||
queue: input.queue,
|
||||
runId: input.runId,
|
||||
candidates: input.candidates.length,
|
||||
failed,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Operator-triggered maintenance of the `measurements` table (v1.42).
|
||||
*
|
||||
* `VACUUM (ANALYZE)` and a `REINDEX INDEX CONCURRENTLY` per index, largest
|
||||
* first, after the compaction-tombstone purge has shrunk the table. Neither
|
||||
* statement can run inside a transaction, so this is a job rather than a
|
||||
* Prisma migration. Started only from `POST /api/admin/maintenance/measurements`
|
||||
* (cookie-only admin), never on a schedule: the operator picks a quiet window.
|
||||
* Runbook: `docs/ops/measurement-maintenance.md`.
|
||||
*
|
||||
* Contract stub: the queue is registered and bound; the handler refuses to
|
||||
* claim success until the maintenance pass is implemented.
|
||||
*/
|
||||
import { jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
|
||||
export const MEASUREMENT_MAINTENANCE_QUEUE = "measurement-maintenance";
|
||||
|
||||
/**
|
||||
* A REINDEX of the largest index on a big instance takes well past pg-boss's
|
||||
* default fifteen minutes. One-shot: a retry would start the pass over beside
|
||||
* a half-finished one, so the operator re-triggers instead.
|
||||
*/
|
||||
export const MEASUREMENT_MAINTENANCE_SEND_OPTIONS = {
|
||||
retryLimit: 0,
|
||||
expireInSeconds: 6 * 60 * 60,
|
||||
singletonKey: "measurement-maintenance",
|
||||
} as const;
|
||||
|
||||
/** Which steps the operator asked for. */
|
||||
export interface MeasurementMaintenancePayload {
|
||||
vacuum: boolean;
|
||||
reindex: boolean;
|
||||
}
|
||||
|
||||
export async function handleMeasurementMaintenance(): Promise<JobOutcome> {
|
||||
return jobFailed("measurement maintenance not implemented");
|
||||
}
|
||||
@@ -39,6 +39,8 @@ import { BACKUP_RESTORE_EXPIRE_SECONDS } from "@/lib/jobs/backup-restore";
|
||||
import { RESTORE_DRILL_EXPIRE_SECONDS } from "@/lib/jobs/restore-drill";
|
||||
import { APPLE_HEALTH_IMPORT_SEND_OPTIONS } from "@/lib/jobs/apple-health-import-worker";
|
||||
import { MEDICATION_INTAKE_IMPORT_SEND_OPTIONS } from "@/lib/jobs/medication-intake-import";
|
||||
import { HEALTH_CONNECT_IMPORT_SEND_OPTIONS } from "@/lib/jobs/health-connect-import-worker";
|
||||
import { MEASUREMENT_MAINTENANCE_SEND_OPTIONS } from "@/lib/jobs/measurement-maintenance";
|
||||
|
||||
/** pg-boss's default job expiry, in seconds. */
|
||||
export const DEFAULT_JOB_EXPIRE_SECONDS = 15 * 60;
|
||||
@@ -351,6 +353,43 @@ export const QUEUE_RUNTIME: Readonly<Record<string, QueueRuntime>> = {
|
||||
"The legacy queue: moves each job onto apple-health-import-v2.",
|
||||
),
|
||||
"apple-health-import-reconcile": short("One updateMany."),
|
||||
"health-connect-import": {
|
||||
runtime: "long",
|
||||
expireInSeconds: HEALTH_CONNECT_IMPORT_SEND_OPTIONS.expireInSeconds,
|
||||
expiryVia: ["HEALTH_CONNECT_IMPORT_SEND_OPTIONS"],
|
||||
stop: {
|
||||
oneShot:
|
||||
"The run consumes and unlinks its staged upload, so it can neither stop half-way nor be retried; the send sets retryLimit 0.",
|
||||
},
|
||||
exclusive: {
|
||||
marker: "retryLimit: 0",
|
||||
at: {
|
||||
file: "lib/jobs/health-connect-import-worker.ts",
|
||||
fn: "HEALTH_CONNECT_IMPORT_SEND_OPTIONS",
|
||||
},
|
||||
},
|
||||
why: "Reads a Health Connect export database that can hold years of minute-level samples.",
|
||||
},
|
||||
"compaction-tombstone-purge": short(
|
||||
"At most 40 batches of 5 000 rows per run; the predicate is idempotent, so the next run resumes.",
|
||||
),
|
||||
"measurement-maintenance": {
|
||||
runtime: "long",
|
||||
expireInSeconds: MEASUREMENT_MAINTENANCE_SEND_OPTIONS.expireInSeconds,
|
||||
expiryVia: ["MEASUREMENT_MAINTENANCE_SEND_OPTIONS"],
|
||||
stop: {
|
||||
oneShot:
|
||||
"VACUUM and REINDEX CONCURRENTLY cannot be resumed mid-statement; the send sets retryLimit 0 and the operator re-triggers.",
|
||||
},
|
||||
exclusive: {
|
||||
marker: "retryLimit: 0",
|
||||
at: {
|
||||
file: "lib/jobs/measurement-maintenance.ts",
|
||||
fn: "MEASUREMENT_MAINTENANCE_SEND_OPTIONS",
|
||||
},
|
||||
},
|
||||
why: "Rebuilds every index of the largest table, one at a time.",
|
||||
},
|
||||
"medication-intake-import": {
|
||||
runtime: "long",
|
||||
expireInSeconds: MEDICATION_INTAKE_IMPORT_SEND_OPTIONS.expireInSeconds,
|
||||
|
||||
@@ -61,6 +61,23 @@ import {
|
||||
migrateLegacyAppleHealthImport,
|
||||
type AppleHealthImportPayload,
|
||||
} from "@/lib/jobs/apple-health-import-worker";
|
||||
import {
|
||||
COMPACTION_TOMBSTONE_PURGE_QUEUE,
|
||||
enqueueBootTimeCompactionTombstonePurge,
|
||||
handleCompactionTombstonePurge,
|
||||
type CompactionTombstonePurgePayload,
|
||||
} from "@/lib/jobs/compaction-tombstone-purge";
|
||||
import {
|
||||
MEASUREMENT_MAINTENANCE_QUEUE,
|
||||
handleMeasurementMaintenance,
|
||||
type MeasurementMaintenancePayload,
|
||||
} from "@/lib/jobs/measurement-maintenance";
|
||||
import {
|
||||
HEALTH_CONNECT_IMPORT_QUEUE,
|
||||
HEALTH_CONNECT_IMPORT_CONCURRENCY,
|
||||
handleHealthConnectImport,
|
||||
type HealthConnectImportPayload,
|
||||
} from "@/lib/jobs/health-connect-import-worker";
|
||||
import {
|
||||
MEDICATION_INTAKE_IMPORT_QUEUE,
|
||||
MEDICATION_INTAKE_IMPORT_CONCURRENCY,
|
||||
@@ -303,9 +320,11 @@ const INTAKE_SLOT_DEDUP_CRON = "28 3 * * *";
|
||||
const OFFHOST_BACKUP_CRON = "30 2 * * *";
|
||||
|
||||
const HOST_METRIC_QUEUE = "host-metric-sample";
|
||||
// Per-minute cadence — matches the chart's 60s polling refetchInterval.
|
||||
// v1.42 — every five minutes. A per-minute sample filled pg-boss's job
|
||||
// table with one row a minute for a chart nobody reads at that resolution.
|
||||
// The admin chart's polling interval (`host-metrics-chart.tsx`) has to match.
|
||||
|
||||
const HOST_METRIC_CRON = "* * * * *";
|
||||
const HOST_METRIC_CRON = "*/5 * * * *";
|
||||
// v1.4.16 phase B5e — daily rec-feedback aggregator. 04:00 Europe/Berlin
|
||||
// runs the slot AFTER all the cleanup jobs (rate-limit, idempotency,
|
||||
// audit-log) so the previous-day's noise is gone before we aggregate.
|
||||
@@ -436,6 +455,17 @@ const allQueues = [
|
||||
// that survives past the next worker boot is never revisited.
|
||||
IMPORT_JOB_RECONCILE_QUEUE,
|
||||
MEDICATION_INTAKE_IMPORT_QUEUE,
|
||||
// v1.42 (#972) — Android Health Connect export import, the Apple Health
|
||||
// import's sibling. Without this entry every upload would queue a job
|
||||
// nobody works.
|
||||
HEALTH_CONNECT_IMPORT_QUEUE,
|
||||
// v1.42 — backlog purge of compaction tombstones, queued once at boot.
|
||||
// Without this entry the boot enqueue silently no-ops.
|
||||
COMPACTION_TOMBSTONE_PURGE_QUEUE,
|
||||
// v1.42 — operator-triggered VACUUM / REINDEX of `measurements`. No
|
||||
// cron: the admin route enqueues it. Without this entry the trigger
|
||||
// silently no-ops.
|
||||
MEASUREMENT_MAINTENANCE_QUEUE,
|
||||
// v1.8.2 — one-time duplicate dose-slot cleanup. Boot discovery enqueues
|
||||
// one job per user holding two live intake rows that snap to the same
|
||||
// canonical slot (the pre-fix REMINDER-pending + API-taken pair). Also
|
||||
@@ -1084,6 +1114,30 @@ export async function registerMaintenanceQueues(
|
||||
return jobDone({ jobs: jobs.length });
|
||||
},
|
||||
);
|
||||
// v1.42 (#972) — Health Connect import. One at a time per worker, like the
|
||||
// Apple Health import: each run reads a whole export and consumes its
|
||||
// staged upload.
|
||||
await createAndWork<HealthConnectImportPayload>(
|
||||
boss,
|
||||
HEALTH_CONNECT_IMPORT_QUEUE,
|
||||
{ localConcurrency: HEALTH_CONNECT_IMPORT_CONCURRENCY },
|
||||
handleHealthConnectImport,
|
||||
);
|
||||
// v1.42 — compaction-tombstone backlog purge. Serial: each batch holds
|
||||
// an account against a concurrent restore.
|
||||
await createAndWork<CompactionTombstonePurgePayload>(
|
||||
boss,
|
||||
COMPACTION_TOMBSTONE_PURGE_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleCompactionTombstonePurge,
|
||||
);
|
||||
// v1.42 — measurement table maintenance, operator-triggered only.
|
||||
await createAndWork<MeasurementMaintenancePayload>(
|
||||
boss,
|
||||
MEASUREMENT_MAINTENANCE_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleMeasurementMaintenance,
|
||||
);
|
||||
// v1.32.1 (issue #588) — periodic orphan-ImportJob sweep. Single-flight:
|
||||
// the underlying `updateMany` is idempotent and two ticks racing the same
|
||||
// read-then-write pass would just be wasted work.
|
||||
@@ -1825,6 +1879,22 @@ export async function enqueueMaintenanceBootDiscovery(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// v1.42 — one compaction-tombstone purge run per boot. The run is
|
||||
// self-limiting and idempotent, so a boot with no backlog costs one query.
|
||||
try {
|
||||
const { enqueued } = await enqueueBootTimeCompactionTombstonePurge();
|
||||
workerLog(
|
||||
"info",
|
||||
`[compaction-tombstone-purge] boot discovery: enqueued=${enqueued}`,
|
||||
);
|
||||
} catch (err) {
|
||||
workerLog(
|
||||
"error",
|
||||
"[compaction-tombstone-purge] boot discovery threw an unexpected error",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
// v1.37.7 (issue #659) — runtime GeoLite2 fetch. Only enqueues when a
|
||||
// MAXMIND_LICENSE_KEY is set, egress is not disabled, and the offline tier
|
||||
// is not already present — so a self-hoster who sets the key on the
|
||||
|
||||
@@ -114,6 +114,9 @@ const subsystemSurface = {
|
||||
"apple-health-import-v2": { audience: "account" },
|
||||
"apple-health-import": { audience: "account" },
|
||||
"apple-health-import-reconcile": { audience: "account" },
|
||||
"health-connect-import": { audience: "account" },
|
||||
"compaction-tombstone-purge": { audience: "system" },
|
||||
"measurement-maintenance": { audience: "system" },
|
||||
"medication-intake-import": { audience: "system" },
|
||||
"intake-slot-dedup": { audience: "system" },
|
||||
"mood-reminder-cleanup": { audience: "system" },
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* The `folded_window` ingest guard (v1.42).
|
||||
*
|
||||
* The dense-intraday consolidation folds the raw Apple Health samples of a
|
||||
* DENSE type into one `stats:` row per local hour once they are older than
|
||||
* the retention window, and a MEAN type into one `stats:` row per local day
|
||||
* once the consolidation grace has passed. Since v1.42 the folded raw rows are
|
||||
* deleted outright rather than left as tombstones, so a client that re-uploads
|
||||
* an old sample after losing its anchor would put it back beside the hour or
|
||||
* day that already accounts for it. A raw (non-`stats:`) Apple Health value of
|
||||
* such a type, old enough to have been folded, is therefore a duplicate when a
|
||||
* live `stats:` row covers its hour or day. The batch route and the ZIP import
|
||||
* apply the same rule.
|
||||
*
|
||||
* Health Connect does not use this: its importer writes the folded shape
|
||||
* itself.
|
||||
*
|
||||
* Contract stub: the rules and the pure candidate check are final; the lookup
|
||||
* that finds the covering `stats:` rows returns nothing until it is built, so
|
||||
* nothing is refused yet.
|
||||
*/
|
||||
import type {
|
||||
MeasurementSource,
|
||||
MeasurementType,
|
||||
Prisma,
|
||||
} from "@/generated/prisma/client";
|
||||
import { HIGH_FREQUENCY_MEAN_TYPES } from "@/lib/measurements/apple-health-mapping";
|
||||
import { CONSOLIDATION_GRACE_CUTOFF_HOURS } from "@/lib/measurements/consolidation-tz";
|
||||
import {
|
||||
DENSE_INTRADAY_RETENTION_DAYS,
|
||||
DENSE_INTRADAY_RETENTION_TYPES,
|
||||
} from "@/lib/measurements/dense-intraday-retention";
|
||||
|
||||
/** The per-entry status reason the batch result carries for such a row. */
|
||||
export type FoldedWindowReason = "folded_window";
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
|
||||
/** Which types fold into which window, and from what age. */
|
||||
export const FOLDED_WINDOW_RULES = [
|
||||
{
|
||||
window: "hour",
|
||||
types: DENSE_INTRADAY_RETENTION_TYPES,
|
||||
foldedAfterMs: DENSE_INTRADAY_RETENTION_DAYS * 24 * HOUR_MS,
|
||||
},
|
||||
{
|
||||
window: "day",
|
||||
types: HIGH_FREQUENCY_MEAN_TYPES,
|
||||
foldedAfterMs: CONSOLIDATION_GRACE_CUTOFF_HOURS * HOUR_MS,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** The fields of an incoming row the guard reads. */
|
||||
export interface FoldedWindowCandidateRow {
|
||||
type: MeasurementType;
|
||||
source: MeasurementSource;
|
||||
externalId: string | null;
|
||||
measuredAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a row is old enough, and of the right kind, for its hour or day to
|
||||
* have been folded already. Pure: whether a live `stats:` row actually covers
|
||||
* it is the lookup's question.
|
||||
*/
|
||||
export function isFoldedWindowCandidate(
|
||||
row: FoldedWindowCandidateRow,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
if (row.source !== "APPLE_HEALTH") return false;
|
||||
if (row.externalId?.startsWith("stats:")) return false;
|
||||
const age = now.getTime() - row.measuredAt.getTime();
|
||||
return FOLDED_WINDOW_RULES.some(
|
||||
(rule) => rule.types.has(row.type) && age > rule.foldedAfterMs,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexes (into `rows`) of the candidates whose hour or day a live `stats:`
|
||||
* row already covers. Stub: returns none.
|
||||
*/
|
||||
export async function findFoldedWindowDuplicates(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
rows: readonly FoldedWindowCandidateRow[],
|
||||
): Promise<Set<number>> {
|
||||
void tx;
|
||||
void userId;
|
||||
void rows;
|
||||
return new Set();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Which measurement types an account has live rows of (v1.42).
|
||||
*
|
||||
* Several readers ask this ("which types exist?") on the request path and in
|
||||
* nightly passes. Since v1.42 they ask through this one helper, so the query
|
||||
* can move to a loose index scan over `measurements_live_covering_idx`
|
||||
* without each caller re-learning the plan.
|
||||
*
|
||||
* Contract stub: the signature is final; the body is the `groupBy` the callers
|
||||
* run today.
|
||||
*/
|
||||
import type {
|
||||
MeasurementSource,
|
||||
MeasurementType,
|
||||
} from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function listLiveMeasurementTypes(
|
||||
userId: string,
|
||||
options: {
|
||||
source?: MeasurementSource;
|
||||
types?: readonly MeasurementType[];
|
||||
} = {},
|
||||
): Promise<MeasurementType[]> {
|
||||
const rows = await prisma.measurement.groupBy({
|
||||
by: ["type"],
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
...(options.source ? { source: options.source } : {}),
|
||||
...(options.types ? { type: { in: [...options.types] } } : {}),
|
||||
},
|
||||
orderBy: { type: "asc" },
|
||||
});
|
||||
return rows.map((row) => row.type);
|
||||
}
|
||||
@@ -403,6 +403,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"modules_conflict",
|
||||
"mood_tag_layout_conflict",
|
||||
"not_configured",
|
||||
"not_implemented",
|
||||
"notification_prefs_conflict",
|
||||
"oidc_only",
|
||||
"private_origin_not_approved",
|
||||
|
||||
@@ -38,7 +38,12 @@ import {
|
||||
} from "@/lib/validations/managed-profiles";
|
||||
import { onboardingStateResource } from "./onboarding";
|
||||
import { aiAccountBlock, moduleAccessMap } from "./profile";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
notImplementedResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
/**
|
||||
* The four request bodies this table publishes, and the one thing to know about
|
||||
@@ -545,7 +550,113 @@ const managedProfileEnvelope = dataEnvelope(
|
||||
"ManagedProfileEnvelope",
|
||||
);
|
||||
|
||||
// v1.42 (#959) — handing a managed profile over to the person it describes.
|
||||
const handoverProposal = z
|
||||
.enum(["end", "read", "manage"])
|
||||
.describe(
|
||||
"What a guardian's access becomes after the handover: ended, view only, or still managing. The new owner confirms or changes it when claiming.",
|
||||
)
|
||||
.meta({ id: "ManagedProfileHandoverProposal" });
|
||||
|
||||
const createHandoverRequest = z
|
||||
.object({
|
||||
expiresInDays: z
|
||||
.union([z.literal(1), z.literal(7), z.literal(14)])
|
||||
.default(7)
|
||||
.describe("How long the link stays valid."),
|
||||
proposals: z
|
||||
.array(
|
||||
z.object({
|
||||
grantId: z.string().describe("The guardian's MANAGE grant."),
|
||||
proposal: handoverProposal,
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"One proposal per active guardian. A guardian left out is proposed `read`.",
|
||||
),
|
||||
})
|
||||
.strict()
|
||||
.meta({ id: "CreateManagedProfileHandoverRequest" });
|
||||
|
||||
const managedProfileHandover = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
token: z
|
||||
.string()
|
||||
.describe(
|
||||
"The one-time `hlp_` token. Returned only in this response and never again; only its hash is stored.",
|
||||
),
|
||||
url: z.string().describe("The link to hand over: `<app>/claim/<token>`."),
|
||||
expiresAt: z.iso.datetime({ offset: true }),
|
||||
})
|
||||
.meta({ id: "ManagedProfileHandover" });
|
||||
|
||||
export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/managed-profiles/{id}/handover": {
|
||||
post: {
|
||||
tags: ["Account sharing"],
|
||||
summary: "Create a handover link for a managed profile",
|
||||
description:
|
||||
"Mints the one-time link that lets the person a managed profile describes take it over as their own account. No data moves: claiming sets credentials on the profile's own record. Each guardian's access afterwards follows the proposal recorded here unless the new owner changes it. Creating a new link withdraws an open one. Pending MANAGE invitations on the profile are withdrawn when it is claimed. Cookie-only and step-up gated, like every act on a managed profile. Refused on an instance that allows single sign-on only.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: createHandoverRequest } },
|
||||
},
|
||||
responses: {
|
||||
...stdResponses,
|
||||
"201": {
|
||||
description: "The link, shown once.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
managedProfileHandover,
|
||||
"ManagedProfileHandoverEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The instance allows single sign-on only (`meta.errorCode: profile_claim.oidc_only_unsupported`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Account sharing"],
|
||||
summary: "Withdraw the open handover link",
|
||||
description:
|
||||
"Withdraws the profile's open handover link, if there is one; the link then answers like any unknown one. Cookie-only; no step-up, because withdrawing only takes something away.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
...stdResponses,
|
||||
"200": {
|
||||
description: "Withdrawn, or there was no open link.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ revoked: z.boolean() }),
|
||||
"RevokeManagedProfileHandoverEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/me": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -9,7 +9,12 @@ import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { inviteCreateSchema } from "@/lib/validations/invite";
|
||||
import { adminReminderCheckSchema } from "@/lib/validations/notifications";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
notImplementedResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
// v1.4.48 H-APNs-1 — admin diagnostic endpoint for the notification
|
||||
// subsystem. Mirrors the runtime types in
|
||||
@@ -243,7 +248,72 @@ const adminReminderCheckResult = z
|
||||
"What the sweep found and what it sent. `notificationsSent` counts DISPATCHES, not deliveries — each one enters the channel cascade and the per-channel verdict lands in the `push_attempts` ledger the notification diagnostic surfaces.",
|
||||
});
|
||||
|
||||
// v1.42 — operator-triggered maintenance of the measurements table.
|
||||
const adminMeasurementMaintenanceRequest = z
|
||||
.object({
|
||||
vacuum: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe("Run `VACUUM (ANALYZE)` on `measurements`."),
|
||||
reindex: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe(
|
||||
"Rebuild every index of `measurements` with `REINDEX INDEX CONCURRENTLY`, largest first, one at a time.",
|
||||
),
|
||||
})
|
||||
.strict()
|
||||
.meta({ id: "AdminMeasurementMaintenanceRequest" });
|
||||
|
||||
const adminMeasurementMaintenanceResult = z
|
||||
.object({
|
||||
enqueued: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"False when a maintenance run is already queued or running; nothing new was queued.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "AdminMeasurementMaintenanceResult" });
|
||||
|
||||
export const adminDiagnosticPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/admin/maintenance/measurements": {
|
||||
post: {
|
||||
tags: ["Admin"],
|
||||
summary: "Queue measurement table maintenance",
|
||||
description:
|
||||
"Queues one `VACUUM (ANALYZE)` and `REINDEX INDEX CONCURRENTLY` pass over the `measurements` table, for after a large purge. Runs in the background worker, one statement at a time; pick a quiet window, because a rebuild needs free disk the size of the index it rebuilds. At most one run at a time. Cookie auth only: `requireAdmin()` refuses every Bearer caller. Runbook: `docs/ops/measurement-maintenance.md`.",
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: {
|
||||
"application/json": { schema: adminMeasurementMaintenanceRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"202": {
|
||||
description: "Queued, or already queued.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
adminMeasurementMaintenanceResult,
|
||||
"AdminMeasurementMaintenanceResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description: "Caller is not an admin.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"503": {
|
||||
description:
|
||||
"No background worker is bound, so nothing would run it.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/admin/notifications/reminder-check": {
|
||||
post: {
|
||||
tags: ["Admin"],
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
stdResponses,
|
||||
errorEnvelope,
|
||||
loginPasswordSchema,
|
||||
notImplementedResponse,
|
||||
} from "./shared";
|
||||
import { passkeyLoginOptionsSchema } from "@/lib/validations/auth";
|
||||
|
||||
@@ -697,7 +698,137 @@ const codexDevicePollResponse = z
|
||||
})
|
||||
.meta({ id: "CodexDevicePollResponse" });
|
||||
|
||||
// v1.42 (#959) — claiming a managed profile through its handover link.
|
||||
const claimTokenField = z
|
||||
.string()
|
||||
.describe(
|
||||
"The `hlp_` token from the handover link. Sent in the body, never in a URL.",
|
||||
);
|
||||
|
||||
const claimPreviewRequest = z
|
||||
.object({ token: claimTokenField })
|
||||
.strict()
|
||||
.meta({ id: "ProfileClaimPreviewRequest" });
|
||||
|
||||
const claimPreview = z
|
||||
.object({
|
||||
displayName: z.string(),
|
||||
expiresAt: z.iso.datetime({ offset: true }),
|
||||
guardians: z.array(
|
||||
z.object({
|
||||
grantId: z.string(),
|
||||
displayName: z.string(),
|
||||
proposal: z.enum(["end", "read", "manage"]),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.meta({ id: "ProfileClaimPreview" });
|
||||
|
||||
const claimRequest = z
|
||||
.object({
|
||||
token: claimTokenField,
|
||||
username: z.string(),
|
||||
email: z.string().describe("Required: the account needs a way back in."),
|
||||
password: z.string(),
|
||||
guardians: z
|
||||
.array(
|
||||
z.object({
|
||||
grantId: z.string(),
|
||||
decision: z.enum(["end", "read", "manage"]),
|
||||
}),
|
||||
)
|
||||
.describe("The new owner's decision per guardian."),
|
||||
})
|
||||
.strict()
|
||||
.meta({ id: "ProfileClaimRequest" });
|
||||
|
||||
export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/auth/claim/preview": {
|
||||
post: {
|
||||
// No credential: the person claiming the profile has none yet. The
|
||||
// one-time token in the body is the credential.
|
||||
security: [],
|
||||
tags: ["Auth"],
|
||||
summary: "Preview a managed-profile handover link",
|
||||
description:
|
||||
"What a handover link would hand over: the profile's display name, the link's expiry, and each guardian's proposal. Anonymous; the token travels in the body. An unknown, expired, used or withdrawn token, and a link whose creator lost access, all answer the same 404. Rate-limited per source address.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: claimPreviewRequest } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The preview.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(claimPreview, "ProfileClaimPreviewEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The instance allows single sign-on only (`meta.errorCode: profile_claim.oidc_only_unsupported`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"No usable link behind this token (`meta.errorCode: profile_claim.invalid`). Every failure class answers identically.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"The request carries a signed-in session (`meta.errorCode: auth.already_authenticated`); sign out first.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/claim": {
|
||||
post: {
|
||||
// No credential: see the preview above.
|
||||
security: [],
|
||||
tags: ["Auth"],
|
||||
summary: "Claim a managed profile",
|
||||
description:
|
||||
"Turns the managed profile behind a handover link into the claimant's own account: sets the username, email and password on the profile's record, clears the managed marker, settles each guardian's access by the claimant's decision, withdraws pending MANAGE invitations, and resets the disclaimer, onboarding and AI consent so the new owner gives them personally. No data moves. Signs the new owner in. Anonymous; the token travels in the body, and every token failure answers the same 404.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: claimRequest } },
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Claimed; the response sets the session cookie.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ userId: z.string(), username: z.string() }),
|
||||
"ProfileClaimEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The instance allows single sign-on only (`meta.errorCode: profile_claim.oidc_only_unsupported`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"No usable link behind this token (`meta.errorCode: profile_claim.invalid`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"The request carries a signed-in session (`meta.errorCode: auth.already_authenticated`), or the username or email is taken.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/login": {
|
||||
post: {
|
||||
// No credential: this operation is reachable before one exists.
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
moduleDisabledResponse,
|
||||
notImplementedResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
@@ -159,7 +160,58 @@ const geocodeResult = z
|
||||
})
|
||||
.meta({ id: "EnvironmentGeocodeResult" });
|
||||
|
||||
// v1.42 (#615) — the per-user environment switches.
|
||||
const environmentPreferencesRequest = z
|
||||
.object({
|
||||
airQualityEnabled: z
|
||||
.boolean()
|
||||
.describe("Fetch and show air quality, pollen and UV for this account."),
|
||||
})
|
||||
.strict()
|
||||
.meta({ id: "EnvironmentPreferencesRequest" });
|
||||
|
||||
const environmentPreferences = z
|
||||
.object({
|
||||
airQualityEnabled: z.boolean(),
|
||||
operatorDisabled: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when the operator turned air quality off for the whole instance; the account switch then has no effect.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "EnvironmentPreferences" });
|
||||
|
||||
export const environmentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/environment/preferences": {
|
||||
patch: {
|
||||
tags: ["Environment"],
|
||||
summary: "Change the environment switches",
|
||||
description:
|
||||
"Turns the air-quality part of the module on or off for this account (on by default). Off means the nightly fetch skips the air-quality feed and the overview leaves the fields out; days already stored are kept. Module-gated. Strict body.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: environmentPreferencesRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The stored switches.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
environmentPreferences,
|
||||
"EnvironmentPreferencesEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...moduleDisabledResponse,
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/environment": {
|
||||
get: {
|
||||
tags: ["Environment"],
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
measurementTypeEnum,
|
||||
notImplementedResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
@@ -177,7 +178,124 @@ const jsonImportStats = z
|
||||
description: "Per-section outcome counts of a JSON import.",
|
||||
});
|
||||
|
||||
// v1.42 (#972) — Android Health Connect export import.
|
||||
const healthConnectImportJob = z
|
||||
.object({
|
||||
jobId: z.string(),
|
||||
status: z
|
||||
.string()
|
||||
.describe(
|
||||
"`queued`, `unpacking`, `parsing`, `upserting`, `done` or `failed`. A plain string, like the Apple Health status.",
|
||||
),
|
||||
startedAt: z.iso.datetime({ offset: true }),
|
||||
completedAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
uploadBytes: z.number().int(),
|
||||
progress: z
|
||||
.record(z.string(), z.unknown())
|
||||
.describe("Free-form phase progress. Shape is not part of the contract."),
|
||||
result: z
|
||||
.record(z.string(), z.unknown())
|
||||
.nullable()
|
||||
.describe("The worker's outcome summary, null until it finishes."),
|
||||
failureReason: z.string().nullable(),
|
||||
})
|
||||
.meta({ id: "HealthConnectImportJob" });
|
||||
|
||||
export const importPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/import/health-connect-export": {
|
||||
post: {
|
||||
tags: ["Import"],
|
||||
summary: "Upload a Health Connect export for background import",
|
||||
description:
|
||||
"Takes the ZIP the Android Health Connect app writes (it holds `health_connect_export.db`) and hands it to a background worker. The response is **202 with a `jobId`**; poll `GET /api/import/health-connect-export/status`. Body is `multipart/form-data` with the file under `file`, streamed to disk after authentication like the Apple Health upload. Re-uploading the same bytes resolves to the still-viable job and returns `idempotent: true`. Imported rows carry `source = HEALTH_CONNECT`. Cookie or wildcard Bearer.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
schema: z.object({
|
||||
file: z
|
||||
.string()
|
||||
.describe(
|
||||
"The Health Connect export ZIP, under the field name `file`.",
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"202": {
|
||||
description:
|
||||
"Accepted and queued, or matched to a job already carrying these bytes.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
jobId: z.string(),
|
||||
status: z.enum([
|
||||
"queued",
|
||||
"unpacking",
|
||||
"parsing",
|
||||
"upserting",
|
||||
"done",
|
||||
]),
|
||||
idempotent: z.boolean().optional(),
|
||||
}),
|
||||
"HealthConnectImportKickoffEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": {
|
||||
description: "The request carried no body at all.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"An import for this account is already queued or running with different bytes. The upload was discarded.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"413": {
|
||||
description: "The declared `Content-Length` exceeds the upload cap.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
"422": {
|
||||
description:
|
||||
"The upload is not a Health Connect export the importer can read: no `file` part, no export database in the ZIP, or a database version older than the importer supports.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"503": {
|
||||
description:
|
||||
"No background worker is bound, so nothing would run the import.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/import/health-connect-export/status": {
|
||||
get: {
|
||||
tags: ["Import"],
|
||||
summary: "The caller's latest Health Connect import",
|
||||
description:
|
||||
"The most recent Health Connect import job of the caller, or `null` when there has never been one.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The latest job, or null.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ job: healthConnectImportJob.nullable() }),
|
||||
"HealthConnectImportStatusEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/import/csv": {
|
||||
post: {
|
||||
tags: ["Import"],
|
||||
|
||||
@@ -49,7 +49,7 @@ const healthKitDirectionEnum = z
|
||||
|
||||
// Mirror the batch route's `syncTriggerEnum` — what woke the client for a sync.
|
||||
const healthKitSyncTriggerEnum = z
|
||||
.enum(["foreground", "background", "push"])
|
||||
.enum(["foreground", "background", "push", "manual"])
|
||||
.describe("What triggered a HealthKit batch.");
|
||||
|
||||
// Resolved entry (defaults merged): `kind` + `enabled` are always present.
|
||||
|
||||
@@ -127,10 +127,10 @@ const batchPayloadSchema = z
|
||||
// used in attribution — sending it or omitting it produces byte-identical
|
||||
// row outcomes. Omit on any client that does not track its wake reason.
|
||||
syncTrigger: z
|
||||
.enum(["foreground", "background", "push"])
|
||||
.enum(["foreground", "background", "push", "manual"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, or a push wake). Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.",
|
||||
'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.',
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
|
||||
@@ -269,6 +269,21 @@ export const malformedJsonResponse = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The 501 a route answers while its contract is published ahead of its
|
||||
* implementation on a release branch. Every stub carries it; the owner of the
|
||||
* route drops it with the stub. No tagged release answers it: the error-code
|
||||
* catalogue check fails once `not_implemented` is listed and nothing emits
|
||||
* it, and fails while something still does and it is not listed.
|
||||
*/
|
||||
export const notImplementedResponse = {
|
||||
"501": {
|
||||
description:
|
||||
"Not implemented yet: the contract is published ahead of the implementation on a release branch. `meta.errorCode: not_implemented`.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ── Standard 401 / 422 / 429 responses ───────────────────────────────
|
||||
|
||||
export const stdResponses = {
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
errorEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
notImplementedResponse,
|
||||
recordRefusal,
|
||||
recordWriteRateLimitResponse,
|
||||
stdResponses,
|
||||
@@ -243,7 +244,150 @@ const vaccinationNotFound = {
|
||||
|
||||
const idPath = { path: z.object({ id: z.string() }) };
|
||||
|
||||
// v1.42 (#1005) — the record's own vaccine definitions.
|
||||
const customVaccine = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
components: z
|
||||
.array(z.string())
|
||||
.describe("Catalogue antigen slugs the product covers."),
|
||||
typicalSeriesDoses: z.number().int().nullable(),
|
||||
boosterIntervalMonths: z.number().int().nullable(),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
updatedAt: z.iso.datetime({ offset: true }),
|
||||
})
|
||||
.meta({ id: "CustomVaccine" });
|
||||
|
||||
const customVaccineCreateRequest = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
components: z.array(z.string()),
|
||||
typicalSeriesDoses: z.number().int().nullable().optional(),
|
||||
boosterIntervalMonths: z.number().int().nullable().optional(),
|
||||
})
|
||||
.strict()
|
||||
.meta({ id: "CreateCustomVaccineRequest" });
|
||||
|
||||
const customVaccineUpdateRequest = customVaccineCreateRequest
|
||||
.partial()
|
||||
.meta({ id: "UpdateCustomVaccineRequest" });
|
||||
|
||||
export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/vaccinations/custom": {
|
||||
get: {
|
||||
tags: ["Records"],
|
||||
summary: "List the record's own vaccines",
|
||||
description:
|
||||
"Vaccine definitions the record's owner added for products the shipped catalogue does not list. Soft-deleted definitions are excluded.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The definitions, by name.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.array(customVaccine),
|
||||
"ListCustomVaccinesEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Records"],
|
||||
summary: "Add an own vaccine",
|
||||
description:
|
||||
"Defines a vaccine in the same shape a catalogue entry resolves to, so series and booster logic treat both alike. Names are unique per record.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: customVaccineCreateRequest } },
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Created.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
customVaccine,
|
||||
"CreateCustomVaccineEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"409": {
|
||||
description: "A live definition with this name already exists.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...recordWriteRateLimitResponse,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/vaccinations/custom/{id}": {
|
||||
patch: {
|
||||
tags: ["Records"],
|
||||
summary: "Edit an own vaccine",
|
||||
description: "Changes one definition. Owner-scoped; an unknown id 404s.",
|
||||
requestParams: idPath,
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: customVaccineUpdateRequest } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Updated.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
customVaccine,
|
||||
"UpdateCustomVaccineEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "No such definition on this record.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description: "A live definition with this name already exists.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Records"],
|
||||
summary: "Remove an own vaccine",
|
||||
description:
|
||||
"Soft-deletes one definition. Doses logged against it stay, on their `vaccineName`.",
|
||||
requestParams: idPath,
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Removed.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ deleted: z.boolean() }),
|
||||
"DeleteCustomVaccineEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "No such definition on this record.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/vaccinations": {
|
||||
get: {
|
||||
tags: ["Records"],
|
||||
|
||||
@@ -1725,6 +1725,58 @@ export const SIGNALS: Record<string, SignalDefinition> = {
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
}, // ── v1.42 (#615) air-quality exposure channels ───────────────────────────
|
||||
// Three correlation channels out of the air-quality feed: fine particles,
|
||||
// the ozone 8-hour high, and the day's highest pollen count. UV, NO2 and
|
||||
// dust are display and Coach context only. Same surface flags as the weather
|
||||
// channels above; the channel series builder carries the wiring.
|
||||
ENV_PM25: {
|
||||
key: "ENV_PM25",
|
||||
kind: "environment",
|
||||
source: { environmentChannelKey: "ENV_PM25" },
|
||||
i18nPrefix: "environment.fields.pm25",
|
||||
displayName: "Fine particles (PM2.5)",
|
||||
unit: "µg/m³",
|
||||
direction: "target-band",
|
||||
archetype: "environmental-exposure",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: true,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
ENV_OZONE_8H: {
|
||||
key: "ENV_OZONE_8H",
|
||||
kind: "environment",
|
||||
source: { environmentChannelKey: "ENV_OZONE_8H" },
|
||||
i18nPrefix: "environment.fields.ozone8h",
|
||||
displayName: "Ozone (8-hour high)",
|
||||
unit: "µg/m³",
|
||||
direction: "target-band",
|
||||
archetype: "environmental-exposure",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: true,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
ENV_POLLEN_MAX: {
|
||||
key: "ENV_POLLEN_MAX",
|
||||
kind: "environment",
|
||||
source: { environmentChannelKey: "ENV_POLLEN_MAX" },
|
||||
i18nPrefix: "environment.fields.pollenMax",
|
||||
displayName: "Pollen (highest)",
|
||||
unit: "grains/m³",
|
||||
direction: "target-band",
|
||||
archetype: "environmental-exposure",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: true,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
parseSourcePriority,
|
||||
sourcePrioritySchema,
|
||||
} from "../source-priority";
|
||||
import { measurementSourceEnum } from "../measurement";
|
||||
|
||||
/**
|
||||
* v1.4.25 W5e — per-user, per-metric-class source-priority foundation.
|
||||
@@ -116,21 +117,17 @@ describe("sourcePrioritySchema", () => {
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects more than 8 sources in a single metric class", () => {
|
||||
const result = sourcePrioritySchema.safeParse({
|
||||
weight: [
|
||||
"WITHINGS",
|
||||
"APPLE_HEALTH",
|
||||
"MANUAL",
|
||||
"IMPORT",
|
||||
"WITHINGS",
|
||||
"APPLE_HEALTH",
|
||||
"MANUAL",
|
||||
"IMPORT",
|
||||
"WITHINGS",
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
// The cap is the number of sources: every source may be ranked once (a
|
||||
// ladder that ranks all sixteen is valid), and one entry more is refused.
|
||||
it("accepts a ladder that ranks every source, and rejects one entry more", () => {
|
||||
const every = [...measurementSourceEnum.options];
|
||||
expect(every.length).toBeGreaterThan(8);
|
||||
expect(sourcePrioritySchema.safeParse({ weight: every }).success).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
sourcePrioritySchema.safeParse({ weight: [...every, "MANUAL"] }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a non-array value for a metric class", () => {
|
||||
|
||||
@@ -1091,6 +1091,10 @@ const labResultBackupSchema = z
|
||||
sourceReferenceLow: z.number().nullable().optional(),
|
||||
sourceReferenceHigh: z.number().nullable().optional(),
|
||||
sourceReferenceText: z.string().nullable().optional(),
|
||||
// v1.42 (#1095) — the value and unit as printed, kept beside a converted
|
||||
// reading. Optional: absent on every file written before the columns.
|
||||
sourceValue: z.number().nullable().optional(),
|
||||
sourceUnit: z.string().nullable().optional(),
|
||||
takenAt: isoDateTime,
|
||||
source: z.string().min(1),
|
||||
biomarkerName: z.string().nullable().optional(),
|
||||
@@ -1112,6 +1116,8 @@ const biomarkerBackupSchema = z
|
||||
upperBound: z.number().nullable().optional(),
|
||||
panel: z.string().nullable().optional(),
|
||||
hidden: z.boolean().optional(),
|
||||
// v1.42 (#1095) — the catalogue analyte the marker resolves to.
|
||||
analyteKey: z.string().nullable().optional(),
|
||||
context: z.string().nullable().optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
@@ -1293,6 +1299,9 @@ const vaccinationBackupSchema = z
|
||||
// Remapped against the restored reminders (they travel since v1.37.20);
|
||||
// dropped to NULL, with the drop named, only when the file lacks the row.
|
||||
reminderId: z.string().nullable().optional(),
|
||||
// v1.42 (#1005) — the person's own vaccine definition the dose names;
|
||||
// remapped against the restored definitions like `reminderId`.
|
||||
customVaccineId: z.string().nullable().optional(),
|
||||
// Ciphertext on a disaster-recovery file, readable on a portable one.
|
||||
noteEncrypted: base64BytesSchema.nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
@@ -1421,9 +1430,12 @@ const environmentDayKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, {
|
||||
const environmentContextBackupSchema = z
|
||||
.object({
|
||||
date: environmentDayKey,
|
||||
lat: z.number(),
|
||||
lon: z.number(),
|
||||
locationLabel: z.string(),
|
||||
// Nullable since v1.42 (migration 0382): once the encryption backfill has
|
||||
// run, the location travels sealed in `locationEncrypted` instead.
|
||||
lat: z.number().nullable(),
|
||||
lon: z.number().nullable(),
|
||||
locationLabel: z.string().nullable(),
|
||||
locationEncrypted: base64BytesSchema.nullable().optional(),
|
||||
source: z.enum(EnvironmentLocationSource),
|
||||
tempMin: z.number().nullable().optional(),
|
||||
tempMax: z.number().nullable().optional(),
|
||||
@@ -1437,6 +1449,29 @@ const environmentContextBackupSchema = z
|
||||
humidityMean: z.number().nullable().optional(),
|
||||
cloudMean: z.number().nullable().optional(),
|
||||
weatherCode: z.number().int().nullable().optional(),
|
||||
// v1.42 (#615) — air quality, pollen and UV. Optional throughout.
|
||||
apparentMax: z.number().nullable().optional(),
|
||||
pm25Mean: z.number().nullable().optional(),
|
||||
pm25Max: z.number().nullable().optional(),
|
||||
pm10Mean: z.number().nullable().optional(),
|
||||
no2Mean: z.number().nullable().optional(),
|
||||
so2Mean: z.number().nullable().optional(),
|
||||
coMean: z.number().nullable().optional(),
|
||||
o3Max8h: z.number().nullable().optional(),
|
||||
eaqiMax: z.number().nullable().optional(),
|
||||
usaqiMax: z.number().nullable().optional(),
|
||||
uvIndexMax: z.number().nullable().optional(),
|
||||
dustMax: z.number().nullable().optional(),
|
||||
aodMax: z.number().nullable().optional(),
|
||||
pollenAlderMax: z.number().nullable().optional(),
|
||||
pollenBirchMax: z.number().nullable().optional(),
|
||||
pollenGrassMax: z.number().nullable().optional(),
|
||||
pollenMugwortMax: z.number().nullable().optional(),
|
||||
pollenOliveMax: z.number().nullable().optional(),
|
||||
pollenRagweedMax: z.number().nullable().optional(),
|
||||
aqDomain: z.string().nullable().optional(),
|
||||
aqHours: z.number().int().nullable().optional(),
|
||||
aqFetchedAt: isoDateTime.nullable().optional(),
|
||||
fetchedAt: isoDateTime.optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
@@ -1452,9 +1487,11 @@ const environmentTravelLocationBackupSchema = z
|
||||
.object({
|
||||
startDate: environmentDayKey,
|
||||
endDate: environmentDayKey,
|
||||
lat: z.number(),
|
||||
lon: z.number(),
|
||||
label: z.string(),
|
||||
// Nullable since v1.42 (migration 0382), as above.
|
||||
lat: z.number().nullable(),
|
||||
lon: z.number().nullable(),
|
||||
label: z.string().nullable(),
|
||||
locationEncrypted: base64BytesSchema.nullable().optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
})
|
||||
@@ -1903,6 +1940,10 @@ const accountSettingsBackupSchema = z
|
||||
homeLabel: z.string().nullable().optional(),
|
||||
homeTimezone: z.string().nullable().optional(),
|
||||
homeSince: isoDateTime.nullable().optional(),
|
||||
// v1.42 (#615) — the sealed home location rides a disaster-recovery file
|
||||
// as ciphertext; a portable file carries the readable home above.
|
||||
homeLocationEncrypted: base64BytesSchema.nullable().optional(),
|
||||
environmentAirQualityEnabled: z.boolean().optional(),
|
||||
timezone: z.string().optional(),
|
||||
locale: z.string().nullable().optional(),
|
||||
unitPreference: z.string().nullable().optional(),
|
||||
|
||||
@@ -234,6 +234,12 @@ export const measurementSourceEnum = z.enum([
|
||||
// above these rows stay value-editable; see
|
||||
// `USER_CORRECTABLE_MEASUREMENT_SOURCES`.
|
||||
"EXTERNAL",
|
||||
// v1.42 — rows imported from an Android Health Connect export (#972).
|
||||
// Server-owned: only the Health Connect import worker writes them, so the
|
||||
// value is deliberately absent from `WRITABLE_MEASUREMENT_SOURCES` and the
|
||||
// batch allowlist, like IMPORT. Part of this enum so the read/response
|
||||
// shapes (and the iOS decoder) can decode the rows it surfaces.
|
||||
"HEALTH_CONNECT",
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -602,10 +608,10 @@ export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
|
||||
STRESS_SCORE: { min: 0, max: 100 },
|
||||
STRAIN_SCORE: { min: 0, max: 100 },
|
||||
// ── v1.11.0 — WHOOP-native score classes ──
|
||||
// RMSSD HRV (ms). Same plausibility band as the SDNN variant: lows reach
|
||||
// single digits in stressed samples, 200 ms is a generous upper bound for
|
||||
// relaxed athletic windows.
|
||||
HRV_RMSSD: { min: 1, max: 200 },
|
||||
// RMSSD HRV (ms). Lows reach single digits in stressed samples. RMSSD runs
|
||||
// higher than SDNN in relaxed, well-trained sleepers, and nightly readings
|
||||
// above 200 ms are real (#1110), so the ceiling sits at 300 ms.
|
||||
HRV_RMSSD: { min: 1, max: 300 },
|
||||
// Day / workout strain on WHOOP's bounded 0–21 scale.
|
||||
DAY_STRAIN: { min: 0, max: 21 },
|
||||
WORKOUT_STRAIN: { min: 0, max: 21 },
|
||||
|
||||
@@ -94,10 +94,14 @@ export type DeviceType = z.infer<typeof deviceTypeEnum>;
|
||||
* keys; lifted into a nested object so the schema has room for the new
|
||||
* `deviceTypePriority` sibling without clobbering the metric keys.
|
||||
*
|
||||
* The 8-entry cap is a sanity bound (we have 4 sources today, the cap
|
||||
* leaves headroom without inviting a megabyte-blob payload).
|
||||
* The cap is the number of `MeasurementSource` values: a ladder may rank
|
||||
* every source once, and no more. A fixed 8 stopped fitting once the
|
||||
* longest default ladders reached eight entries and a ninth source
|
||||
* (Health Connect, v1.42) had to be sortable into them.
|
||||
*/
|
||||
const metricSourceLadder = z.array(measurementSourceEnum).max(8);
|
||||
const metricSourceLadder = z
|
||||
.array(measurementSourceEnum)
|
||||
.max(measurementSourceEnum.options.length);
|
||||
|
||||
/**
|
||||
* Metric-class keys carried by `SourcePriority`. Listed once here so
|
||||
@@ -144,6 +148,10 @@ export const SOURCE_PRIORITY_METRIC_KEYS = [
|
||||
// Laddering it now keeps the picker deterministic — and the choice user-
|
||||
// configurable, not WHOOP-hardcoded — the instant a native producer lands.
|
||||
"sleepDebt",
|
||||
// v1.42 (#1110) — RMSSD HRV as its own class. WHOOP, Oura and Polar report
|
||||
// RMSSD natively, Apple Health relays it from third-party apps; SDNN keeps
|
||||
// the `hrv` ladder, since the two measures are not interchangeable.
|
||||
"hrvRmssd",
|
||||
] as const;
|
||||
|
||||
export type SourcePriorityMetricKey =
|
||||
@@ -395,6 +403,9 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
// rolling-balance debt; the COMPUTED engine is the fallback every user lands
|
||||
// on now. Mirrors the recovery ladder's native-above-computed ordering.
|
||||
sleepDebt: ["WHOOP", "OURA", "POLAR", "COMPUTED"],
|
||||
// v1.42 (#1110) — the straps and rings that measure RMSSD overnight lead;
|
||||
// the HealthKit relay of a third-party reading is the fallback.
|
||||
hrvRmssd: ["WHOOP", "OURA", "POLAR", "APPLE_HEALTH"],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+32
-1
@@ -75,6 +75,14 @@ export const PUBLIC_PATHS = [
|
||||
// and is not an enumeration oracle — so it must reach the page without
|
||||
// an auth gate, like the `/auth/` register surface it forwards to.
|
||||
"/invite/",
|
||||
// v1.42 (#959) — `/claim/<hlp_token>` is the managed-profile handover link.
|
||||
// Like `/invite/` it is a shape-validated edge redirect onto
|
||||
// `/auth/claim?token=…` (handled below), carries no session and touches no
|
||||
// database. `/api/auth/claim` (preview + claim) authenticates by the
|
||||
// one-time token in the request body, never by a session: the person
|
||||
// claiming the profile has no credentials yet.
|
||||
"/claim/",
|
||||
"/api/auth/claim",
|
||||
// Locale-catalog boot script (`/i18n/<locale>?v=…`). The login page loads
|
||||
// it pre-auth, and the body is the same public catalog JSON that ships in
|
||||
// the repository — no tenant data, no secrets. Immutable-cacheable.
|
||||
@@ -406,6 +414,29 @@ export function proxy(request: NextRequest) {
|
||||
return redirected;
|
||||
}
|
||||
|
||||
// v1.42 (#959) — the managed-profile handover link answers at the edge for
|
||||
// the same reasons as the invite link above: no HTML, no hydration race, the
|
||||
// shape gate drops anything that is not a handover token rather than
|
||||
// reflecting it, and the hop is uncacheable, unindexable and never leaked as
|
||||
// a `Referer`. The shape is restated from `src/lib/auth/handover-token.ts`.
|
||||
if (pathname.startsWith("/claim/")) {
|
||||
const token = pathname.slice("/claim/".length).split("/")[0] ?? "";
|
||||
const target = new URL("/auth/claim", request.url);
|
||||
if (/^hlp_[0-9a-f]{64}$/.test(token)) {
|
||||
target.searchParams.set("token", token);
|
||||
}
|
||||
const redirected = applyBaselineSecurityHeaders(
|
||||
NextResponse.redirect(target, 307),
|
||||
);
|
||||
redirected.headers.set(
|
||||
"Cache-Control",
|
||||
"no-store, no-cache, must-revalidate",
|
||||
);
|
||||
redirected.headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||
redirected.headers.set("Referrer-Policy", "no-referrer");
|
||||
return redirected;
|
||||
}
|
||||
|
||||
// Server-side route protection for pages (not API routes — those have their own getSession checks)
|
||||
const isApiRoute = pathname.startsWith("/api/");
|
||||
const isStaticFile = /\.\w+$/.test(pathname);
|
||||
@@ -718,6 +749,6 @@ export const config = {
|
||||
// the matcher and `apiHandler` performs the proxy's duties for it. The
|
||||
// alternation must stay equal to that list; `proxy-body-limit.test.ts`
|
||||
// compiles this exactly as Next does and checks.
|
||||
"/((?!_next/static|_next/image|favicon.ico|sw\\.js|manifest\\.json|robots\\.txt|sitemap\\.xml|api/(?:import(?:/csv|/apple-health-export)?|admin/import-apple-health-export|admin/backups/upload|documents/inbound|medications/intake/(?:dose-history-import|bulk)|labs/ocr/extract|user/avatar|measurements/batch|workouts/batch|insights/ecg|mood-entries/bulk|cycle/day-logs/bulk)/?$|.*\\.(?:svg|png|jpg|jpeg|gif|webp|txt|xml)$).*)",
|
||||
"/((?!_next/static|_next/image|favicon.ico|sw\\.js|manifest\\.json|robots\\.txt|sitemap\\.xml|api/(?:import(?:/csv|/apple-health-export|/health-connect-export)?|admin/import-apple-health-export|admin/backups/upload|documents/inbound|medications/intake/(?:dose-history-import|bulk)|labs/ocr/extract|user/avatar|measurements/batch|workouts/batch|insights/ecg|mood-entries/bulk|cycle/day-logs/bulk)/?$|.*\\.(?:svg|png|jpg|jpeg|gif|webp|txt|xml)$).*)",
|
||||
],
|
||||
};
|
||||
Reference in new issue
Block a user