mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Bundles the deferred v1.4.47 Mediums + Lows with the v1.4.48-deferred items into a single release. Headline: server-side suppression of MEDICATION_REMINDER APNs for iOS clients that own their local reminders (closes GH #206), a push_attempts table backing /api/admin/notifications/diagnostic, and a sweep of v1.4.48 QA forward-findings. Performance (the user-visible win): - /api/analytics cold-fallback path splits the heavy single-statement aggregate into two parallel queries. The linearly composable columns (count / min / max / mean) keep the full-partition scan; the windowed 7 / 30 / 90-day avg / slope / r² columns take a 90-day outer cap so the planner does an index range scan on (user_id, type, measured_at) instead of a full-partition sequential scan. Output is bit-identical; cold slim slice drops from multi-second to sub-second on tenants with large measurement partitions. Server-side notifications: - GET + PATCH /api/auth/me/notification-prefs with deep-merge semantics. Initial category medication.clientManaged: boolean (default false). Cron medicationReminderJob skips dose-due APNs when the flag is true, emitting a medication_reminder.suppressed_client_managed wide-event annotation per skip. - push_attempts table backs the diagnostic endpoint. Every APNS / Web-Push / Telegram / NTFY sender writes a fire-and-forget row. Daily 03:35 Europe/Berlin cleanup prunes rows older than 90 days. - /api/admin/notifications/diagnostic — admin-only endpoint surfacing device APNs token presence (masked), channel config presence, and recent push attempts. Registered in OpenAPI. - Per-channel APNs "Send test" button now fires with eventType: "MEDICATION_REMINDER" so the dispatcher's time-sensitive + priority-10 branch runs. A successful self-test now exercises the exact path production reminders take; an unsuccessful one points the user at iOS Settings → Time Sensitive Notifications rather than appearing to be a server-side bug. Withings reliability: - OAuth callback consumes the nonce atomically (delete-first with P2025 catch for the replay branch). The previous read-then-delete pattern allowed a race between two concurrent callbacks. - ?reason=state redirect differentiated into csrf1 / replay / expired / cross_user so operators can tell mismatch types apart without DB shell access. - connect endpoint rate-limited to 10/60s/user; row-create failures redirect to a consistent error surface. Observability hardening: - redactSensitiveFields(body) denylist helper covers password / token / secret / apiKey / authorization / csrfState / nonce, recursive, case-insensitive. Caught: /api/devices invalid_format issues were echoing apnsToken values verbatim into the audit log. - sanitiseZodIssues(issues, { stripValuesFromMessage: true }) overload applied to 14 audit-log sites. Migrations: 0079 (notification_prefs), 0080 (push_attempts). Tests: 4815 → 5272 unit + integration. Workout-batch integration suite that had been red on main since v1.4.45 (three release commits shipped through it) now green again — updated to match v1.4.42 cross-source dedup semantics. Resolves PR #207.
1059 lines
36 KiB
TypeScript
1059 lines
36 KiB
TypeScript
/**
|
|
* OpenAPI route table — populated incrementally.
|
|
*
|
|
* v1.4.23 baseline covers the routes the v1.5 iOS app touches end-to-end:
|
|
* - POST /api/auth/login
|
|
* - POST /api/auth/passkey/login-verify
|
|
* - POST /api/auth/refresh
|
|
* - GET /api/measurements
|
|
* - POST /api/measurements
|
|
* - POST /api/measurements/batch (W2 — Apple Health ingest)
|
|
* - POST /api/devices (W3 — APNs registration)
|
|
* - GET /api/insights/comprehensive
|
|
*
|
|
* Schemas come from `src/lib/validations/*` so the wire contract stays
|
|
* single-source-of-truth. The `.meta()` annotations on each schema land
|
|
* the title + description in `components.schemas.*` automatically.
|
|
*
|
|
* Routes are intentionally registered as inline operation objects
|
|
* rather than via a wrapping helper — `zod-openapi`'s `createDocument`
|
|
* type-checks the route table directly, which catches schema-shape
|
|
* regressions at typecheck time.
|
|
*/
|
|
import { z } from "zod/v4";
|
|
import type { ZodOpenApiObject } from "zod-openapi";
|
|
|
|
import {
|
|
createMeasurementSchema,
|
|
listMeasurementsSchema,
|
|
measurementTypeEnum,
|
|
measurementSourceEnum,
|
|
} from "@/lib/validations/measurement";
|
|
import { loginPasswordSchema } from "@/lib/validations/auth";
|
|
import { coachPrefsSchema } from "@/lib/validations/coach-prefs";
|
|
import {
|
|
deviceTypeEnum,
|
|
sourcePrioritySchema,
|
|
} from "@/lib/validations/source-priority";
|
|
import { createBatchWorkoutSchema } from "@/lib/validations/workout";
|
|
|
|
/**
|
|
* Common envelopes — every HealthLog API response wraps payload in
|
|
* `{ data, error, meta? }`. The OpenAPI surface mirrors that contract
|
|
* so iOS / external-ingest clients can decode uniformly.
|
|
*/
|
|
const errorEnvelope = z
|
|
.object({
|
|
data: z.null(),
|
|
error: z.string(),
|
|
meta: z
|
|
.object({
|
|
requestId: z.string().optional(),
|
|
errorCode: z.string().optional(),
|
|
})
|
|
.optional(),
|
|
})
|
|
.meta({
|
|
id: "ErrorEnvelope",
|
|
description: "Standard error response: data is null, error is human prose.",
|
|
});
|
|
|
|
function dataEnvelope<T extends z.ZodType>(payload: T, id: string) {
|
|
return z
|
|
.object({
|
|
data: payload,
|
|
error: z.null(),
|
|
meta: z.object({ requestId: z.string().optional() }).optional(),
|
|
})
|
|
.meta({ id });
|
|
}
|
|
|
|
// ── Schemas — annotated for spec emission ────────────────────────────
|
|
|
|
measurementTypeEnum.meta({
|
|
id: "MeasurementType",
|
|
description:
|
|
"DB-stored measurement category. v1.4.23 added 7 Apple Health values (HRV, resting HR, active energy, flights, walking/running distance, VO2 max, body temperature).",
|
|
});
|
|
|
|
measurementSourceEnum.meta({
|
|
id: "MeasurementSource",
|
|
description:
|
|
"Origin of the measurement. v1.4.23 added APPLE_HEALTH for the iOS HealthKit batch ingest path.",
|
|
});
|
|
|
|
loginPasswordSchema.meta({
|
|
id: "LoginPasswordRequest",
|
|
description:
|
|
"Email-or-username login. The native-client flow returns a paired access + refresh token when X-Client-Type: native or the iOS UA prefix is present.",
|
|
});
|
|
|
|
createMeasurementSchema.meta({
|
|
id: "CreateMeasurementRequest",
|
|
description:
|
|
"Single-measurement ingest body. Plausibility-range guard runs server-side; out-of-range values fail 422.",
|
|
});
|
|
|
|
listMeasurementsSchema.meta({
|
|
id: "ListMeasurementsQuery",
|
|
description:
|
|
"Query params for the measurements list endpoint. `limit` capped at 500.",
|
|
});
|
|
|
|
coachPrefsSchema.meta({
|
|
id: "CoachPrefs",
|
|
description:
|
|
"Per-user Coach prompt-tuning preferences (v1.4.23 H4). All fields default to the legacy v1.4.22 behaviour when omitted.",
|
|
});
|
|
|
|
createBatchWorkoutSchema.meta({
|
|
id: "CreateBatchWorkoutRequest",
|
|
description:
|
|
"v1.4.25 W16b — typed workout batch ingest. Each entry is an HKWorkout-aligned record with an optional nested GeoJSON LineString route. Up to 100 workouts per call; nested route geometry capped at 20 000 points. Withings server-to-server callers pass source: WITHINGS and ship no route (Withings reports aggregates only).",
|
|
});
|
|
|
|
const coachMessageFeedbackBody = z
|
|
.object({
|
|
rating: z.enum(["helpful", "unhelpful"]),
|
|
reason: z.string().min(1).max(200).optional(),
|
|
})
|
|
.meta({
|
|
id: "CoachMessageFeedbackRequest",
|
|
description:
|
|
"Per-message helpful/unhelpful feedback (v1.4.23 H7). Optional `reason` is free-form prose, capped at 200 chars.",
|
|
});
|
|
|
|
// v1.4.49 — single schema for the per-user Coach opt-out flag. Previously
|
|
// split into `disableCoachBody` (PATCH request) and `disableCoachData`
|
|
// (response payload), both `z.object({ disableCoach: z.boolean() })`. The
|
|
// payload was passed through `dataEnvelope(..., "GetDisableCoachResponse")`
|
|
// / `dataEnvelope(..., "PatchDisableCoachResponse")` which IDs the
|
|
// envelope wrapper — the inner flag carries its own `.meta()` and now
|
|
// renders as a single `$ref: "#/components/schemas/DisableCoachFlag"` in
|
|
// both the request body and both response envelopes.
|
|
const disableCoachFlag = z
|
|
.object({
|
|
disableCoach: z.boolean(),
|
|
})
|
|
.meta({
|
|
id: "DisableCoachFlag",
|
|
description:
|
|
"Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.",
|
|
});
|
|
|
|
// ── Sub-schemas owned here (route-specific shapes) ───────────────────
|
|
|
|
const passkeyLoginVerifyRequest = z
|
|
.object({
|
|
challengeId: z
|
|
.string()
|
|
.min(1)
|
|
.describe("Server-issued WebAuthn challenge id."),
|
|
credential: z
|
|
.object({
|
|
id: z.string(),
|
|
rawId: z.string(),
|
|
type: z.literal("public-key"),
|
|
response: z.record(z.string(), z.unknown()),
|
|
authenticatorAttachment: z
|
|
.enum(["platform", "cross-platform"])
|
|
.optional(),
|
|
clientExtensionResults: z.record(z.string(), z.unknown()).optional(),
|
|
})
|
|
.describe("SimpleWebAuthn-style assertion response payload."),
|
|
})
|
|
.meta({
|
|
id: "PasskeyLoginVerifyRequest",
|
|
description:
|
|
"Passkey assertion verification. Same native-client token issuance as the password path.",
|
|
});
|
|
|
|
const refreshRequest = z
|
|
.object({
|
|
refreshToken: z
|
|
.string()
|
|
.min(1)
|
|
.describe("Caller-presented refresh token (`hlr_<64hex>`)."),
|
|
revoke: z
|
|
.boolean()
|
|
.optional()
|
|
.describe("When true, revoke the supplied token instead of rotating."),
|
|
})
|
|
.meta({
|
|
id: "RefreshTokenRequest",
|
|
description:
|
|
"Exchange a one-time-use refresh token for a fresh access + refresh pair.",
|
|
});
|
|
|
|
const accessRefreshBundle = z
|
|
.object({
|
|
user: z.object({ id: z.string(), username: z.string() }).optional(),
|
|
token: z.string().describe("Access token (`hlk_<64hex>`)."),
|
|
tokenExpiresAt: z.iso.datetime({ offset: true }),
|
|
refreshToken: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"Refresh token (`hlr_<64hex>`); only present for native-policy callers.",
|
|
),
|
|
refreshTokenExpiresAt: z.iso.datetime({ offset: true }).optional(),
|
|
})
|
|
.meta({
|
|
id: "AccessRefreshBundle",
|
|
description:
|
|
"Native-client token bundle returned by login + refresh. Web cookie-only callers see only `user`.",
|
|
});
|
|
|
|
const deviceRegisterRequest = z
|
|
.object({
|
|
token: z
|
|
.string()
|
|
.min(8)
|
|
.max(512)
|
|
.regex(/^[A-Za-z0-9+/=._:-]+$/)
|
|
.describe("Generic device identifier (legacy; APNs token below)."),
|
|
bundleId: z.string().min(1).max(128),
|
|
locale: z.string().min(2).max(16).optional(),
|
|
appVersion: z.string().min(1).max(32).optional(),
|
|
model: z.string().min(1).max(64).optional(),
|
|
apnsToken: z
|
|
.string()
|
|
.min(8)
|
|
.max(256)
|
|
.regex(/^[A-Fa-f0-9]+$/)
|
|
.optional()
|
|
.describe(
|
|
"Hex-encoded APNs device token. Must be paired with `apnsEnvironment`.",
|
|
),
|
|
apnsEnvironment: z
|
|
.enum(["sandbox", "production"])
|
|
.optional()
|
|
.describe(
|
|
"Gateway the iOS client received `apnsToken` from. Server never auto-detects.",
|
|
),
|
|
})
|
|
.meta({
|
|
id: "DeviceRegisterRequest",
|
|
description:
|
|
"Native device registration. Re-registering an APNs token belonging to another user returns 409 (cross-user-hijack guard).",
|
|
});
|
|
|
|
const batchEntrySchema = z
|
|
.object({
|
|
hkIdentifier: z
|
|
.string()
|
|
.min(1)
|
|
.max(120)
|
|
.describe(
|
|
"HealthKit identifier (e.g. `HKQuantityTypeIdentifierBodyMass`).",
|
|
),
|
|
value: z.number().finite(),
|
|
unit: z.string().min(1).max(60),
|
|
startDate: z.iso.datetime({ offset: true }),
|
|
endDate: z.iso.datetime({ offset: true }),
|
|
sleepStage: z
|
|
.number()
|
|
.int()
|
|
.min(0)
|
|
.max(20)
|
|
.optional()
|
|
.describe(
|
|
"HKCategoryValueSleepAnalysis codepoint; only for sleep samples.",
|
|
),
|
|
externalId: z
|
|
.string()
|
|
.min(1)
|
|
.max(120)
|
|
.describe("HKSample.uuid string — the dedup key."),
|
|
externalSourceVersion: z.string().min(1).max(120).optional(),
|
|
// v1.4.25 W8c — optional device-type tag fed into the canonical
|
|
// source picker's second axis. NULL is treated as `unknown`;
|
|
// legacy iOS builds that don't ship the field continue to work.
|
|
deviceType: deviceTypeEnum
|
|
.nullable()
|
|
.optional()
|
|
.describe(
|
|
"Device class mapped from `HKDevice.model`. Used by the analytics aggregator to break ties when the same source contributed multiple devices for the same day. Omit (or send null) on legacy clients — the server treats it as `unknown` and the picker falls through.",
|
|
),
|
|
})
|
|
.meta({ id: "AppleHealthBatchEntry" });
|
|
|
|
const batchPayloadSchema = z
|
|
.object({
|
|
entries: z.array(batchEntrySchema).min(1).max(500),
|
|
})
|
|
.meta({
|
|
id: "AppleHealthBatchRequest",
|
|
description:
|
|
"Apple Health batch ingest. ≤500 entries per call; idempotent via `Idempotency-Key`.",
|
|
});
|
|
|
|
const batchEntryResult = z
|
|
.object({
|
|
index: z.number().int().nonnegative(),
|
|
status: z.enum(["inserted", "duplicate", "skipped"]),
|
|
reason: z.string().optional(),
|
|
})
|
|
.meta({ id: "AppleHealthBatchEntryResult" });
|
|
|
|
const batchResponse = z
|
|
.object({
|
|
inserted: z.number().int().nonnegative(),
|
|
duplicate: z.number().int().nonnegative(),
|
|
skipped: z.number().int().nonnegative(),
|
|
entries: z.array(batchEntryResult),
|
|
})
|
|
.meta({ id: "AppleHealthBatchResponse" });
|
|
|
|
// v1.4.25 W16b — typed workout batch ingest response envelope. Mirrors
|
|
// the measurements batch shape but reports the `workouts` count rather
|
|
// than the entries count, and the `skipped` field is reserved (the
|
|
// Zod schema rejects malformed entries with a 400 before the per-entry
|
|
// pass, so today's responses always carry an empty array).
|
|
const workoutBatchEntryResult = z
|
|
.object({
|
|
index: z.number().int().nonnegative(),
|
|
status: z.enum(["inserted", "duplicate", "skipped"]),
|
|
reason: z.string().optional(),
|
|
})
|
|
.meta({ id: "WorkoutBatchEntryResult" });
|
|
|
|
const workoutBatchResponse = z
|
|
.object({
|
|
processed: z.number().int().nonnegative(),
|
|
inserted: z.number().int().nonnegative(),
|
|
duplicates: z.number().int().nonnegative(),
|
|
skipped: z.array(
|
|
z.object({
|
|
index: z.number().int().nonnegative(),
|
|
reason: z.string(),
|
|
}),
|
|
),
|
|
entries: z.array(workoutBatchEntryResult),
|
|
})
|
|
.meta({ id: "WorkoutBatchResponse" });
|
|
|
|
// v1.4.32 — workout list / detail wire shape. The field names follow
|
|
// the iOS handoff contract (`distanceM` + `activeEnergyKcal`) rather
|
|
// than the Prisma column names so iOS and web consumers share one
|
|
// JSON envelope.
|
|
const workoutListEntry = z
|
|
.object({
|
|
id: z.string(),
|
|
sportType: z.string(),
|
|
startedAt: z.iso.datetime({ offset: true }),
|
|
endedAt: z.iso.datetime({ offset: true }),
|
|
durationSec: z.number().int().nonnegative(),
|
|
distanceM: z.number().nullable(),
|
|
activeEnergyKcal: z.number().nullable(),
|
|
avgHr: z.number().int().nullable(),
|
|
maxHr: z.number().int().nullable(),
|
|
source: measurementSourceEnum,
|
|
externalId: z.string().nullable(),
|
|
})
|
|
.meta({ id: "WorkoutListEntry" });
|
|
|
|
const workoutListResponse = z
|
|
.object({
|
|
workouts: z.array(workoutListEntry),
|
|
meta: z.object({
|
|
total: z.number().int().nonnegative(),
|
|
limit: z.number().int().positive(),
|
|
offset: z.number().int().nonnegative(),
|
|
droppedDuplicates: z.number().int().nonnegative(),
|
|
}),
|
|
})
|
|
.meta({ id: "WorkoutListResponse" });
|
|
|
|
const workoutRouteGeometry = z
|
|
.object({
|
|
geometry: z.unknown(),
|
|
sampleTimestamps: z.array(z.string()).nullable(),
|
|
})
|
|
.meta({ id: "WorkoutRouteGeometry" });
|
|
|
|
const workoutDetailResponse = z
|
|
.object({
|
|
id: z.string(),
|
|
sportType: z.string(),
|
|
startedAt: z.iso.datetime({ offset: true }),
|
|
endedAt: z.iso.datetime({ offset: true }),
|
|
durationSec: z.number().int().nonnegative(),
|
|
distanceM: z.number().nullable(),
|
|
activeEnergyKcal: z.number().nullable(),
|
|
avgHr: z.number().int().nullable(),
|
|
maxHr: z.number().int().nullable(),
|
|
minHr: z.number().int().nullable(),
|
|
stepCount: z.number().int().nullable(),
|
|
elevationM: z.number().nullable(),
|
|
pauseDurationSec: z.number().int().nullable(),
|
|
source: measurementSourceEnum,
|
|
externalId: z.string().nullable(),
|
|
metadata: z.unknown().nullable(),
|
|
route: workoutRouteGeometry.nullable(),
|
|
canonicalId: z.string(),
|
|
})
|
|
.meta({ id: "WorkoutDetailResponse" });
|
|
|
|
const deleteByExternalIdsRequest = z
|
|
.object({
|
|
externalIds: z.array(z.string().min(1).max(120)).min(0).max(500),
|
|
})
|
|
.meta({
|
|
id: "MeasurementsDeleteByExternalIdsRequest",
|
|
description:
|
|
"iOS deletion-sync. Up to 500 externalIds per call; matching rows owned by other users are silently skipped (cross-user 404 guard).",
|
|
});
|
|
|
|
const deleteByExternalIdsResponse = z
|
|
.object({
|
|
deletedCount: z.number().int().nonnegative(),
|
|
})
|
|
.meta({ id: "MeasurementsDeleteByExternalIdsResult" });
|
|
|
|
const measurementResource = z
|
|
.object({
|
|
id: z.string(),
|
|
type: measurementTypeEnum,
|
|
value: z.number(),
|
|
unit: z.string(),
|
|
measuredAt: z.iso.datetime({ offset: true }),
|
|
source: measurementSourceEnum,
|
|
notes: z.string().nullable().optional(),
|
|
})
|
|
.meta({
|
|
id: "MeasurementResource",
|
|
description: "Server-shaped measurement row returned by GET endpoints.",
|
|
});
|
|
|
|
// v1.4.48 H-APNs-1 — admin diagnostic endpoint for the notification
|
|
// subsystem. Mirrors the runtime types in
|
|
// `src/app/api/admin/notifications/diagnostic/route.ts`: APNs tokens
|
|
// are surfaced only as 8-char hex prefix + suffix (never the full
|
|
// token), per-channel `enabled` + `configPresent` booleans, and a
|
|
// `recentPushAttempts` array reserved for the v1.4.48+ follow-up that
|
|
// adds the dedicated PushAttempt table (currently always `[]` so the
|
|
// shape stays stable for iOS consumers).
|
|
const adminDiagnosticDevice = z
|
|
.object({
|
|
id: z.string(),
|
|
platform: z.string(),
|
|
hasApnsToken: z.boolean(),
|
|
apnsTokenPrefix: z.string().nullable(),
|
|
apnsTokenSuffix: z.string().nullable(),
|
|
apnsEnvironment: z.string().nullable(),
|
|
lastSeenAt: z.iso.datetime({ offset: true }),
|
|
})
|
|
.meta({
|
|
id: "AdminDiagnosticDevice",
|
|
description:
|
|
"Per-device APNs registration snapshot. The APNs token is masked to its 8-char hex prefix + 8-char suffix so an operator can correlate with iOS-side logs without disclosing the delivery target.",
|
|
});
|
|
|
|
const adminDiagnosticChannel = z
|
|
.object({
|
|
type: z.string(),
|
|
enabled: z.boolean(),
|
|
configPresent: z.boolean(),
|
|
})
|
|
.meta({
|
|
id: "AdminDiagnosticChannel",
|
|
description:
|
|
"Per-channel state. `configPresent` is true when the channel's encrypted-JSON config blob carries the field the corresponding sender will read (chatId+botToken for TELEGRAM, topic for NTFY); WEB_PUSH/APNS rely on sibling tables so the row's existence is the signal.",
|
|
});
|
|
|
|
const adminDiagnosticPushAttempt = z
|
|
.object({
|
|
eventType: z.string(),
|
|
channel: z.string(),
|
|
result: z.string(),
|
|
reason: z.string().nullable(),
|
|
at: z.iso.datetime({ offset: true }),
|
|
})
|
|
.meta({
|
|
id: "AdminDiagnosticPushAttempt",
|
|
description:
|
|
"Single push delivery attempt. Reserved for the v1.4.48+ follow-up that adds the dedicated PushAttempt table — currently always returned as an empty array so the response shape stays stable for iOS consumers.",
|
|
});
|
|
|
|
const adminDiagnosticData = z
|
|
.object({
|
|
devices: z.array(adminDiagnosticDevice),
|
|
notificationChannels: z.array(adminDiagnosticChannel),
|
|
recentPushAttempts: z.array(adminDiagnosticPushAttempt),
|
|
})
|
|
.meta({
|
|
id: "AdminDiagnosticData",
|
|
description:
|
|
"Admin notification diagnostic snapshot for the calling user — what the dispatcher would see when targeting this account. Surfaces device tokens (masked), channel state, and recent push attempts so an operator can debug an iOS / Web Push / Telegram / ntfy issue without DB shell access.",
|
|
});
|
|
|
|
const insightsComprehensiveResponse = z
|
|
.object({
|
|
summary: z.string(),
|
|
recommendations: z.array(z.record(z.string(), z.unknown())),
|
|
citations: z.array(z.record(z.string(), z.unknown())),
|
|
warnings: z.array(z.record(z.string(), z.unknown())),
|
|
dailyBriefing: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
trendAnnotations: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
storyboardAnnotations: z
|
|
.array(z.record(z.string(), z.unknown()))
|
|
.optional(),
|
|
metricSource: z.record(z.string(), z.unknown()).optional(),
|
|
})
|
|
.meta({
|
|
id: "InsightsComprehensiveResponse",
|
|
description:
|
|
"AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface needs day-level grounding.",
|
|
});
|
|
|
|
// ── Standard 401 / 422 / 429 responses ───────────────────────────────
|
|
|
|
const stdResponses = {
|
|
"401": {
|
|
description: "Authentication required or invalid credentials.",
|
|
content: { "application/json": { schema: errorEnvelope } },
|
|
},
|
|
"422": {
|
|
description: "Request validation failed.",
|
|
content: { "application/json": { schema: errorEnvelope } },
|
|
},
|
|
"429": {
|
|
description: "Rate limit exceeded.",
|
|
content: { "application/json": { schema: errorEnvelope } },
|
|
},
|
|
};
|
|
|
|
// ── Path table ───────────────────────────────────────────────────────
|
|
|
|
export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
|
"/api/auth/login": {
|
|
post: {
|
|
tags: ["Auth"],
|
|
summary: "Email-or-username login (password)",
|
|
description:
|
|
"Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA prefix) additionally receive a paired access + refresh token.",
|
|
requestBody: {
|
|
required: true,
|
|
content: { "application/json": { schema: loginPasswordSchema } },
|
|
},
|
|
responses: {
|
|
"200": {
|
|
description: "Login succeeded.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(accessRefreshBundle, "LoginResponse"),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/auth/passkey/login-verify": {
|
|
post: {
|
|
tags: ["Auth"],
|
|
summary: "Passkey assertion verification",
|
|
requestBody: {
|
|
required: true,
|
|
content: { "application/json": { schema: passkeyLoginVerifyRequest } },
|
|
},
|
|
responses: {
|
|
"200": {
|
|
description: "Assertion verified — session + optional bearer issued.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
accessRefreshBundle,
|
|
"PasskeyLoginVerifyResponse",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/auth/refresh": {
|
|
post: {
|
|
tags: ["Auth"],
|
|
summary: "Rotate refresh token (one-time use)",
|
|
description:
|
|
"Reuse of a consumed refresh token revokes every refresh token still active for the originating device (per-device blast radius from v1.4.23). Legacy tokens issued before v1.4.23 with a null deviceId fall back to revoke-all-for-user.",
|
|
requestBody: {
|
|
required: true,
|
|
content: { "application/json": { schema: refreshRequest } },
|
|
},
|
|
responses: {
|
|
"200": {
|
|
description: "Rotation succeeded — new pair issued.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(accessRefreshBundle, "RefreshResponse"),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/measurements": {
|
|
get: {
|
|
tags: ["Measurements"],
|
|
summary: "List measurements",
|
|
description:
|
|
"Filter by type + date range. Response is paged via offset/limit; default limit 100, hard cap 500.",
|
|
requestParams: {
|
|
query: listMeasurementsSchema,
|
|
},
|
|
responses: {
|
|
"200": {
|
|
description: "Measurement page.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
z.object({
|
|
measurements: z.array(measurementResource),
|
|
total: z.number().int().nonnegative(),
|
|
}),
|
|
"ListMeasurementsResponse",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
post: {
|
|
tags: ["Measurements"],
|
|
summary: "Create one measurement",
|
|
description:
|
|
"Single ingest. Use `/api/measurements/batch` for Apple Health upload streams.",
|
|
requestBody: {
|
|
required: true,
|
|
content: { "application/json": { schema: createMeasurementSchema } },
|
|
},
|
|
responses: {
|
|
"201": {
|
|
description: "Created.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
measurementResource,
|
|
"CreateMeasurementResponse",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/measurements/batch": {
|
|
post: {
|
|
tags: ["Measurements"],
|
|
summary: "Apple Health batch ingest",
|
|
description:
|
|
"Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status lets the iOS client advance its sync cursor accurately. v1.4.25 W8c adds an optional `deviceType` per entry — feed it from `HKDevice.model` so the cross-source canonical picker can break Apple-Watch-vs-iPhone ties; null/absent stays backward-compatible with v1.4.23 clients.",
|
|
requestBody: {
|
|
required: true,
|
|
content: { "application/json": { schema: batchPayloadSchema } },
|
|
},
|
|
responses: {
|
|
"200": {
|
|
description: "Batch processed.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(batchResponse, "BatchMeasurementsResponse"),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/measurements/by-external-ids": {
|
|
delete: {
|
|
tags: ["Measurements"],
|
|
summary: "Delete measurements by external ID (iOS deletion-sync)",
|
|
description:
|
|
"Removes the user's measurement rows whose externalId is in the request list. Rows owned by another user are silently skipped (cross-user 404 guard). Up to 500 externalIds per call.",
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
"application/json": { schema: deleteByExternalIdsRequest },
|
|
},
|
|
},
|
|
responses: {
|
|
"200": {
|
|
description: "Delete batch processed.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
deleteByExternalIdsResponse,
|
|
"MeasurementsDeleteByExternalIdsResponse",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/workouts": {
|
|
get: {
|
|
tags: ["Measurements"],
|
|
summary: "List workouts (v1.4.32)",
|
|
description:
|
|
"Paginated workout list with cross-source canonical-row dedup. The picker reads the per-user source-priority ladder and buckets by `(startedAt 5 min slot, sportType)` — twin workouts (Apple Watch + Withings ScanWatch) collapse to a single canonical row per cluster. Field names mirror the iOS handoff contract: `distanceM`, `activeEnergyKcal`, `avgHr`, `maxHr`.",
|
|
requestParams: {
|
|
query: z.object({
|
|
limit: z.coerce.number().int().min(1).max(200).optional(),
|
|
offset: z.coerce.number().int().min(0).optional(),
|
|
since: z.iso.datetime({ offset: true }).optional(),
|
|
until: z.iso.datetime({ offset: true }).optional(),
|
|
sportType: z.string().optional(),
|
|
}),
|
|
},
|
|
responses: {
|
|
"200": {
|
|
description: "Canonical workout page.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(workoutListResponse, "ListWorkoutsResponse"),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/workouts/{id}": {
|
|
get: {
|
|
tags: ["Measurements"],
|
|
summary: "Workout detail (v1.4.32)",
|
|
description:
|
|
"Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Cross-user rows surface as 404 (existence channel sealed).",
|
|
requestParams: {
|
|
path: z.object({ id: z.string() }),
|
|
},
|
|
responses: {
|
|
"200": {
|
|
description: "Workout detail.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(workoutDetailResponse, "GetWorkoutResponse"),
|
|
},
|
|
},
|
|
},
|
|
"404": {
|
|
description: "Workout not found (or owned by another user).",
|
|
content: { "application/json": { schema: errorEnvelope } },
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/workouts/batch": {
|
|
post: {
|
|
tags: ["Measurements"],
|
|
summary: "Typed workout batch ingest (v1.4.25 W16b)",
|
|
description:
|
|
"Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status (`inserted | duplicate | skipped`) lets the iOS sync cursor checkpoint accurately. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with `workout.batch.payload_too_large`.",
|
|
requestBody: {
|
|
required: true,
|
|
content: { "application/json": { schema: createBatchWorkoutSchema } },
|
|
},
|
|
responses: {
|
|
"200": {
|
|
description: "Batch processed.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
workoutBatchResponse,
|
|
"BatchWorkoutsResponse",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
"400": {
|
|
description:
|
|
"Batch validation failed (over-cap, schema reject, or oversized route).",
|
|
content: { "application/json": { schema: errorEnvelope } },
|
|
},
|
|
"413": {
|
|
description: "Request body exceeds the 5 MB ceiling.",
|
|
content: { "application/json": { schema: errorEnvelope } },
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/devices": {
|
|
post: {
|
|
tags: ["Devices"],
|
|
summary: "Register native device + APNs token",
|
|
description:
|
|
"Idempotent upsert by `token`. APNs token + environment are paired — supplying one without the other returns 422. Cross-user re-registration of either identifier returns 409.",
|
|
requestBody: {
|
|
required: true,
|
|
content: { "application/json": { schema: deviceRegisterRequest } },
|
|
},
|
|
responses: {
|
|
"201": {
|
|
description: "Device registered or refreshed.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
z.object({ id: z.string() }),
|
|
"DeviceRegisterResponse",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
"409": {
|
|
description:
|
|
"Device or APNs token already registered to another user.",
|
|
content: { "application/json": { schema: errorEnvelope } },
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/auth/me/coach-prefs": {
|
|
get: {
|
|
tags: ["Auth"],
|
|
summary: "Read per-user Coach prompt-tuning preferences",
|
|
description:
|
|
"Returns the persisted preferences with defaults filled in. Null persisted state resolves to the legacy v1.4.22 defaults.",
|
|
responses: {
|
|
"200": {
|
|
description: "Resolved preferences.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(coachPrefsSchema, "GetCoachPrefsResponse"),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
put: {
|
|
tags: ["Auth"],
|
|
summary: "Replace per-user Coach prompt-tuning preferences",
|
|
description:
|
|
"Persists the supplied prefs. Body is validated against `CoachPrefs`; missing keys fall back to the documented defaults.",
|
|
requestBody: {
|
|
required: true,
|
|
content: { "application/json": { schema: coachPrefsSchema } },
|
|
},
|
|
responses: {
|
|
"200": {
|
|
description: "Saved preferences echoed back.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(coachPrefsSchema, "PutCoachPrefsResponse"),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/auth/me/disable-coach": {
|
|
get: {
|
|
tags: ["Auth"],
|
|
summary: "Read per-user Coach opt-out flag",
|
|
description:
|
|
"Returns the user's per-account Coach opt-out flag. Default `false` (Coach visible). Powers the Settings → Insights \"Hide Coach\" Switch and the layout FAB short-circuit.",
|
|
responses: {
|
|
"200": {
|
|
description: "Resolved flag.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(disableCoachFlag, "GetDisableCoachResponse"),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
patch: {
|
|
tags: ["Auth"],
|
|
summary: "Toggle per-user Coach opt-out flag",
|
|
description:
|
|
"Flips the per-account Coach opt-out flag. Idempotent — the DB write fires even when the value matches so the audit-log row mirrors the API call. Rate-limit 60/min per user.",
|
|
requestBody: {
|
|
required: true,
|
|
content: { "application/json": { schema: disableCoachFlag } },
|
|
},
|
|
responses: {
|
|
"200": {
|
|
description:
|
|
"Resolved next-state echoed back for optimistic-update consumers.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
disableCoachFlag,
|
|
"PatchDisableCoachResponse",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/auth/me/source-priority": {
|
|
get: {
|
|
tags: ["Auth"],
|
|
summary: "Read per-user source priority",
|
|
description:
|
|
"Returns the per-metric-class source priority used by the analytics aggregator. Missing keys fall back to `DEFAULT_SOURCE_PRIORITY`. Null persisted state resolves to the documented defaults.",
|
|
responses: {
|
|
"200": {
|
|
description: "Resolved priority.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
sourcePrioritySchema,
|
|
"GetSourcePriorityResponse",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
put: {
|
|
tags: ["Auth"],
|
|
summary: "Replace per-user source priority",
|
|
description:
|
|
"Persists the supplied priority. Body is validated against `SourcePriority`; missing keys read as `DEFAULT_SOURCE_PRIORITY` at the call site.",
|
|
requestBody: {
|
|
required: true,
|
|
content: { "application/json": { schema: sourcePrioritySchema } },
|
|
},
|
|
responses: {
|
|
"200": {
|
|
description: "Saved priority (defaulted) echoed back.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
sourcePrioritySchema,
|
|
"PutSourcePriorityResponse",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/insights/chat/messages/{id}/feedback": {
|
|
post: {
|
|
tags: ["Insights"],
|
|
summary: "Rate a Coach assistant message",
|
|
description:
|
|
"Persists a helpful/unhelpful rating for a single Coach reply. Reuses the v1.4.16 RecommendationFeedback table via the polymorphic `targetType` column. The aggregator buckets ratings by (promptVersion, tone, verbosity).",
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
"application/json": { schema: coachMessageFeedbackBody },
|
|
},
|
|
},
|
|
responses: {
|
|
"201": {
|
|
description: "Feedback saved.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
z.object({
|
|
id: z.string(),
|
|
createdAt: z.iso.datetime({ offset: true }),
|
|
}),
|
|
"CoachMessageFeedbackResponse",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
"404": {
|
|
description: "Message not found or not owned by the caller.",
|
|
content: { "application/json": { schema: errorEnvelope } },
|
|
},
|
|
"409": {
|
|
description: "Caller has already rated this message text.",
|
|
content: { "application/json": { schema: errorEnvelope } },
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/admin/notifications/diagnostic": {
|
|
get: {
|
|
tags: ["Admin"],
|
|
summary: "Admin notification diagnostic snapshot",
|
|
description:
|
|
"Admin-only. Returns what the dispatcher would see when targeting the calling admin's account: registered devices (APNs tokens masked to prefix + suffix), per-channel enabled + configPresent flags, and recent push attempts (currently always `[]` pending the v1.4.48+ PushAttempt table). Cookie auth only — never Bearer.",
|
|
responses: {
|
|
"200": {
|
|
description: "Diagnostic snapshot.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
adminDiagnosticData,
|
|
"AdminDiagnosticResponse",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
"403": {
|
|
description: "Caller is not an admin.",
|
|
content: { "application/json": { schema: errorEnvelope } },
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/insights/comprehensive": {
|
|
get: {
|
|
tags: ["Insights"],
|
|
summary: "Comprehensive AI insights bundle",
|
|
description:
|
|
"Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard annotations. Strict-schema validated server-side.",
|
|
responses: {
|
|
"200": {
|
|
description: "Insights bundle.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
insightsComprehensiveResponse,
|
|
"InsightsComprehensiveResponseEnvelope",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
|
|
securitySchemes: {
|
|
bearerAuth: {
|
|
type: "http",
|
|
scheme: "bearer",
|
|
bearerFormat: "hlk_<64hex>",
|
|
description:
|
|
"Native-client API token. Use the `token` field returned by /api/auth/login on a native client.",
|
|
},
|
|
cookieAuth: {
|
|
type: "apiKey",
|
|
in: "cookie",
|
|
name: "healthlog_session",
|
|
description: "Browser session cookie — set by /api/auth/login.",
|
|
},
|
|
},
|
|
};
|