diff --git a/.gitignore b/.gitignore index 5939d3a11..3cbd8e489 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,7 @@ next-env.d.ts # Docs scratch — temporary spec drafts that live in the repo only # while a release is in flight. /docs/ios/ + +# Per-release internal forensic notes — retained locally for +# traceability across releases but not part of the public docs surface. +/docs/audit/ diff --git a/docs/api/openapi-v1422-legacy.yaml b/docs/api/openapi-v1422-legacy.yaml deleted file mode 100644 index f0715aa19..000000000 --- a/docs/api/openapi-v1422-legacy.yaml +++ /dev/null @@ -1,5468 +0,0 @@ -openapi: 3.1.0 - -info: - title: HealthLog API - version: 1.4.0 - description: | - REST API for HealthLog — a personal health-tracking PWA covering weight, - blood pressure, pulse, mood, medication compliance, blood glucose, body - composition (total body water + bone mass), pulse oximetry (SpO2), and - sleep. The same API powers the Next.js web client and the native iOS - client. - - ## Authentication - - All non-public endpoints accept either a session cookie (`healthlog_session`, - obtained via password login or passkey verification) or a long-lived Bearer - token (`Authorization: Bearer hlk_...`) issued via `/api/tokens`. The two - schemes are interchangeable on user endpoints — clients pick whichever - suits them (cookies for the web app, Bearer for the iOS client and - machine-to-machine integrations). Admin endpoints (`/api/admin/*`) accept - only the session cookie; Bearer tokens never elevate to admin. - - The API uses an envelope response shape: `{ data, error }`. On success, - `data` carries the payload and `error` is `null`. On failure, `data` is - `null` and `error` is a human-readable message. - - ## Conventions - - - All timestamps are ISO 8601 (`date-time`) in UTC. - - All numeric IDs are CUIDs (opaque strings). - - Pagination uses `limit` + `offset` query parameters; responses include - a `meta: { total, limit, offset }` block. - - Rate limiting is applied per-IP or per-user on auth, ingest, export, - AI, and bug-report endpoints. Exceeding the budget yields a `429`. - - ## Admin endpoints - - Routes under `/api/admin/*` are tagged `Admin` and are intended for the - internal admin UI only. iOS clients should not consume them. - contact: - name: HealthLog - url: https://healthlog.bombeck.io - license: - name: MIT - url: https://github.com/MBombeck/healthlog/blob/main/LICENSE - -servers: - - url: https://healthlog.bombeck.io - description: Production - - url: http://localhost:3000 - description: Local development - -tags: - - name: Auth - description: Registration, login (password + passkey/WebAuthn), logout, session info, profile, password change. - - name: Measurements - description: CRUD for measurements (weight, BP, pulse, body fat, glucose, sleep, steps). - - name: Medications - description: Medication management, schedules, intake events, compliance, reminder phase config. - - name: Mood - description: Mood entries (manual + moodLog imports) and mood analytics. - - name: Dashboard - description: User-customizable dashboard widget layout. - - name: Insights - description: AI-generated insights, classifications (BMI, BP, mood, etc.), per-metric status, target ranges. - - name: Achievements - description: Gamification achievements (streaks, milestones). - - name: DoctorReport - description: Aggregated health data for client-side PDF export. - - name: Integrations - description: Withings, moodLog, Codex (ChatGPT) OAuth integrations. - - name: User - description: User-level configuration (AI provider, threshold overrides). - - name: Tokens - description: User-issued API tokens for external ingest. - - name: Notifications - description: Notification channels and preferences (Telegram, ntfy, Web Push). - - name: Onboarding - description: First-run profile setup. - - name: Health - description: Service health probe. - - name: Settings - description: Per-user settings (account deletion, data wipe, integrations, notifications). - - name: Analytics - description: Aggregated analytics summaries. - - name: Export - description: User data export (CSV/JSON) and re-import. - - name: AuditLog - description: User-facing audit log of own activity. - - name: Feedback - description: User feedback submission and history. - - name: BugReport - description: User bug reports (creates a GitHub issue when configured). - - name: Monitoring - description: Public monitoring/error reporting endpoints (CSP, Glitchtip, Umami proxy). - - name: Webhooks - description: Inbound webhooks (Withings, moodLog, Telegram). - - name: Ingest - description: External ingest endpoints authenticated via Bearer token. - - name: Admin - description: Internal admin-only endpoints. Not consumed by the iOS client. - -security: - - bearerAuth: [] - - sessionCookie: [] - -paths: - # ─── Auth ──────────────────────────────────────────────────────── - /api/auth/register: - post: - tags: [Auth] - summary: Register a new user (password) - operationId: registerUser - security: [] - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/RegisterInput" } - responses: - "201": - description: Created - content: - application/json: - schema: - $ref: "#/components/schemas/RegisterResponseEnvelope" - "403": { $ref: "#/components/responses/Forbidden" } - "409": { $ref: "#/components/responses/Conflict" } - "422": { $ref: "#/components/responses/Unprocessable" } - "429": { $ref: "#/components/responses/RateLimited" } - "500": { $ref: "#/components/responses/ServerError" } - - /api/auth/login: - post: - tags: [Auth] - summary: Login with email/username + password - operationId: loginUser - security: [] - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/LoginInput" } - responses: - "200": - description: Authenticated - content: - application/json: - schema: { $ref: "#/components/schemas/LoginResponseEnvelope" } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - "429": { $ref: "#/components/responses/RateLimited" } - "500": { $ref: "#/components/responses/ServerError" } - - /api/auth/logout: - post: - tags: [Auth] - summary: Destroy current session - operationId: logoutUser - responses: - "200": - description: Logged out - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - loggedOut: { type: boolean } - "500": { $ref: "#/components/responses/ServerError" } - - /api/auth/me: - get: - tags: [Auth] - summary: Get current authenticated user - operationId: getCurrentUser - responses: - "200": - description: Current user - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/CurrentUser" } - "401": { $ref: "#/components/responses/Unauthorized" } - "500": { $ref: "#/components/responses/ServerError" } - - /api/auth/profile: - put: - tags: [Auth] - summary: Update profile (email, height, DOB, gender) - operationId: updateProfile - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/ProfileInput" } - responses: - "200": - description: Updated profile - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/User" } - "401": { $ref: "#/components/responses/Unauthorized" } - "409": { $ref: "#/components/responses/Conflict" } - "422": { $ref: "#/components/responses/Unprocessable" } - "500": { $ref: "#/components/responses/ServerError" } - - /api/auth/password: - post: - tags: [Auth] - summary: Change password (invalidates other sessions) - operationId: changePassword - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/ChangePasswordInput" } - responses: - "200": - description: Password changed - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - changed: { type: boolean } - "400": { $ref: "#/components/responses/BadRequest" } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - "429": { $ref: "#/components/responses/RateLimited" } - "500": { $ref: "#/components/responses/ServerError" } - - /api/auth/registration-status: - get: - tags: [Auth] - summary: Whether public registration is enabled - operationId: getRegistrationStatus - security: [] - responses: - "200": - description: Registration status - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - registrationEnabled: { type: boolean } - - # ─── Passkeys (WebAuthn) ─────────────────────────────────────── - /api/auth/passkey/register-options: - post: - tags: [Auth] - summary: Begin passkey registration — get WebAuthn options - operationId: getPasskeyRegisterOptions - responses: - "200": - description: WebAuthn registration options - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - { $ref: "#/components/schemas/PasskeyRegisterOptions" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/auth/passkey/register-verify: - post: - tags: [Auth] - summary: Complete passkey registration — verify attestation - operationId: verifyPasskeyRegistration - requestBody: - required: true - content: - application/json: - schema: - { $ref: "#/components/schemas/PasskeyVerifyRegistrationInput" } - responses: - "200": - description: Passkey registered - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - verified: { type: boolean } - "400": { $ref: "#/components/responses/BadRequest" } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - - /api/auth/passkey/login-options: - post: - tags: [Auth] - summary: Begin passkey login — get WebAuthn options - operationId: getPasskeyLoginOptions - security: [] - responses: - "200": - description: WebAuthn authentication options - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/PasskeyLoginOptions" } - "429": { $ref: "#/components/responses/RateLimited" } - - /api/auth/passkey/login-verify: - post: - tags: [Auth] - summary: Complete passkey login — verify assertion - operationId: verifyPasskeyLogin - security: [] - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/PasskeyVerifyLoginInput" } - responses: - "200": - description: Authenticated via passkey - content: - application/json: - schema: { $ref: "#/components/schemas/LoginResponseEnvelope" } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - "422": { $ref: "#/components/responses/Unprocessable" } - "429": { $ref: "#/components/responses/RateLimited" } - - /api/auth/passkeys: - get: - tags: [Auth] - summary: List the user's passkeys - operationId: listPasskeys - responses: - "200": - description: List of passkeys - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: array - items: { $ref: "#/components/schemas/PasskeySummary" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/auth/passkeys/{id}: - delete: - tags: [Auth] - summary: Delete a passkey - operationId: deletePasskey - parameters: - - $ref: "#/components/parameters/IdPathParam" - responses: - "200": - description: Deleted - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - success: { type: boolean } - "400": { $ref: "#/components/responses/BadRequest" } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - - # ─── Codex (ChatGPT) OAuth ──────────────────────────────────── - /api/auth/codex/authorize: - get: - tags: [Integrations] - summary: Begin ChatGPT OAuth flow (redirects to ChatGPT) - operationId: codexAuthorize - responses: - "302": - description: Redirect to ChatGPT auth URL - "401": { $ref: "#/components/responses/Unauthorized" } - "429": { $ref: "#/components/responses/RateLimited" } - - /api/auth/codex/callback: - get: - tags: [Integrations] - summary: ChatGPT OAuth callback — exchange code for tokens - operationId: codexCallback - parameters: - - in: query - name: code - schema: { type: string } - - in: query - name: state - schema: { type: string } - - in: query - name: error - schema: { type: string } - responses: - "302": - description: Redirect back to /settings with status - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/auth/codex/disconnect: - delete: - tags: [Integrations] - summary: Disconnect ChatGPT integration - operationId: codexDisconnect - responses: - "200": - description: Disconnected - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - disconnected: { type: boolean } - "401": { $ref: "#/components/responses/Unauthorized" } - "429": { $ref: "#/components/responses/RateLimited" } - - # ─── Measurements ──────────────────────────────────────────────── - /api/measurements: - get: - tags: [Measurements] - summary: List measurements (paginated, filterable) - operationId: listMeasurements - parameters: - - in: query - name: type - schema: { $ref: "#/components/schemas/MeasurementType" } - - in: query - name: from - schema: { type: string, format: date-time } - - in: query - name: to - schema: { type: string, format: date-time } - - $ref: "#/components/parameters/LimitParam" - - $ref: "#/components/parameters/OffsetParam" - - in: query - name: sortBy - schema: - type: string - enum: [type, value, measuredAt, source] - default: measuredAt - - in: query - name: sortDir - schema: - type: string - enum: [asc, desc] - default: desc - responses: - "200": - description: List of measurements - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - measurements: - type: array - items: { $ref: "#/components/schemas/Measurement" } - meta: { $ref: "#/components/schemas/PageMeta" } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - "500": { $ref: "#/components/responses/ServerError" } - post: - tags: [Measurements] - summary: Create a measurement (single or batch) - operationId: createMeasurement - requestBody: - required: true - content: - application/json: - schema: - oneOf: - - $ref: "#/components/schemas/CreateMeasurementInput" - - type: array - items: { $ref: "#/components/schemas/CreateMeasurementInput" } - minItems: 1 - maxItems: 5 - responses: - "201": - description: Created - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - oneOf: - - $ref: "#/components/schemas/Measurement" - - type: array - items: { $ref: "#/components/schemas/Measurement" } - "401": { $ref: "#/components/responses/Unauthorized" } - "409": { $ref: "#/components/responses/Conflict" } - "422": { $ref: "#/components/responses/Unprocessable" } - "500": { $ref: "#/components/responses/ServerError" } - - /api/measurements/{id}: - parameters: - - $ref: "#/components/parameters/IdPathParam" - get: - tags: [Measurements] - summary: Get a single measurement - operationId: getMeasurement - responses: - "200": - description: Measurement - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/Measurement" } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - put: - tags: [Measurements] - summary: Update a measurement - operationId: updateMeasurement - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/UpdateMeasurementInput" } - responses: - "200": - description: Updated - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/Measurement" } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - "422": { $ref: "#/components/responses/Unprocessable" } - delete: - tags: [Measurements] - summary: Delete a measurement - operationId: deleteMeasurement - responses: - "200": - description: Deleted - content: - application/json: - schema: { $ref: "#/components/schemas/DeletedEnvelope" } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - - # ─── Medications ───────────────────────────────────────────────── - /api/medications: - get: - tags: [Medications] - summary: List medications (with today's intake summary) - operationId: listMedications - responses: - "200": - description: List - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: array - items: - { - $ref: "#/components/schemas/MedicationWithSchedules", - } - "401": { $ref: "#/components/responses/Unauthorized" } - post: - tags: [Medications] - summary: Create a medication with schedules - operationId: createMedication - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/CreateMedicationInput" } - responses: - "201": - description: Created - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - { $ref: "#/components/schemas/MedicationWithSchedules" } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - - /api/medications/{id}: - parameters: - - $ref: "#/components/parameters/IdPathParam" - get: - tags: [Medications] - summary: Get a single medication - operationId: getMedication - responses: - "200": - description: Medication - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - { $ref: "#/components/schemas/MedicationWithSchedules" } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - put: - tags: [Medications] - summary: Update a medication - operationId: updateMedication - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/UpdateMedicationInput" } - responses: - "200": - description: Updated - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - { $ref: "#/components/schemas/MedicationWithSchedules" } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - "422": { $ref: "#/components/responses/Unprocessable" } - delete: - tags: [Medications] - summary: Delete a medication (revokes its API tokens) - operationId: deleteMedication - responses: - "200": - description: Deleted - content: - application/json: - schema: { $ref: "#/components/schemas/DeletedEnvelope" } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - - /api/medications/{id}/intake: - parameters: - - $ref: "#/components/parameters/IdPathParam" - get: - tags: [Medications] - summary: List intake events for a medication - operationId: listMedicationIntakes - parameters: - - $ref: "#/components/parameters/LimitParam" - - $ref: "#/components/parameters/OffsetParam" - - in: query - name: sortBy - schema: - type: string - enum: [scheduledFor, takenAt, source, createdAt] - default: scheduledFor - - in: query - name: sortDir - schema: { type: string, enum: [asc, desc], default: desc } - responses: - "200": - description: Events - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - events: - type: array - items: - { - $ref: "#/components/schemas/MedicationIntakeEvent", - } - meta: { $ref: "#/components/schemas/PageMeta" } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - "422": { $ref: "#/components/responses/Unprocessable" } - post: - tags: [Medications] - summary: Log a medication intake or skip - operationId: createMedicationIntake - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/CreateIntakeInput" } - responses: - "200": - description: Idempotent return of existing event - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - { $ref: "#/components/schemas/MedicationIntakeEvent" } - "201": - description: Created - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - { $ref: "#/components/schemas/MedicationIntakeEvent" } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - "422": { $ref: "#/components/responses/Unprocessable" } - - /api/medications/{id}/intake/{eventId}: - parameters: - - $ref: "#/components/parameters/IdPathParam" - - in: path - name: eventId - required: true - schema: { type: string } - put: - tags: [Medications] - summary: Update an intake event - operationId: updateMedicationIntake - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/UpdateIntakeInput" } - responses: - "200": - description: Updated - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - { $ref: "#/components/schemas/MedicationIntakeEvent" } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - "422": { $ref: "#/components/responses/Unprocessable" } - delete: - tags: [Medications] - summary: Delete an intake event - operationId: deleteMedicationIntake - responses: - "200": - description: Deleted - content: - application/json: - schema: { $ref: "#/components/schemas/DeletedEnvelope" } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - - /api/medications/{id}/intake/import: - parameters: - - $ref: "#/components/parameters/IdPathParam" - post: - tags: [Medications] - summary: Bulk-import historical intakes for one medication - operationId: importMedicationIntakes - requestBody: - required: true - content: - application/json: - schema: - oneOf: - - type: array - items: { $ref: "#/components/schemas/IntakeImportEntry" } - minItems: 1 - maxItems: 1000 - - type: object - description: Object whose first array-valued property is treated as the entries. - additionalProperties: true - responses: - "201": - description: Import result - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/IntakeImportResult" } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - "422": { $ref: "#/components/responses/Unprocessable" } - - /api/medications/{id}/intake/purge: - parameters: - - $ref: "#/components/parameters/IdPathParam" - delete: - tags: [Medications] - summary: Delete ALL intake events for a medication - operationId: purgeMedicationIntakes - responses: - "200": - description: Purged - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - purged: { type: boolean } - count: { type: integer } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - - /api/medications/{id}/compliance: - parameters: - - $ref: "#/components/parameters/IdPathParam" - get: - tags: [Medications] - summary: Compliance summary for a medication (7d, 30d, 90 daily) - operationId: getMedicationCompliance - responses: - "200": - description: Compliance - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - { - $ref: "#/components/schemas/MedicationComplianceResponse", - } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - - /api/medications/{id}/phase-config: - parameters: - - $ref: "#/components/parameters/IdPathParam" - get: - tags: [Medications] - summary: Get reminder-phase config (or defaults) - operationId: getPhaseConfig - responses: - "200": - description: Phase config - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/ReminderPhaseConfig" } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - put: - tags: [Medications] - summary: Update reminder-phase config - operationId: updatePhaseConfig - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/ReminderPhaseConfig" } - responses: - "200": - description: Updated - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/ReminderPhaseConfig" } - "400": { $ref: "#/components/responses/BadRequest" } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - delete: - tags: [Medications] - summary: Reset reminder-phase config to defaults - operationId: resetPhaseConfig - responses: - "200": - description: Reset - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - reset: { type: boolean } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - - /api/medications/{id}/api-endpoint: - parameters: - - $ref: "#/components/parameters/IdPathParam" - get: - tags: [Medications] - summary: Whether an external API token is enabled for this medication - operationId: getMedicationApiEndpoint - responses: - "200": - description: Status - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - enabled: { type: boolean } - activeTokenCount: { type: integer } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - put: - tags: [Medications] - summary: Enable / disable a medication-scoped API token - operationId: setMedicationApiEndpoint - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - enabled: { type: boolean } - required: [enabled] - responses: - "200": - description: Existing token reused or revocation result - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - { - $ref: "#/components/schemas/MedicationApiEndpointResponse", - } - "201": - description: New medication-scoped token created - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - { - $ref: "#/components/schemas/MedicationApiEndpointResponse", - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "422": { $ref: "#/components/responses/Unprocessable" } - - /api/medications/intake-summary: - get: - tags: [Medications] - summary: Daily intake summary (for streak chart) - operationId: getIntakeSummary - parameters: - - in: query - name: from - schema: { type: string, format: date-time } - - in: query - name: to - schema: { type: string, format: date-time } - responses: - "200": - description: Summary - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - { $ref: "#/components/schemas/IntakeSummaryResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - - # ─── Mood ───────────────────────────────────────────────────── - /api/mood-entries: - get: - tags: [Mood] - summary: List mood entries (paginated, filterable) - operationId: listMoodEntries - parameters: - - in: query - name: mood - schema: { $ref: "#/components/schemas/MoodLevel" } - - in: query - name: from - description: "YYYY-MM-DD lower bound (inclusive)." - schema: { type: string } - - in: query - name: to - description: "YYYY-MM-DD upper bound (inclusive)." - schema: { type: string } - - $ref: "#/components/parameters/LimitParam" - - $ref: "#/components/parameters/OffsetParam" - - in: query - name: sortBy - schema: { type: string, default: moodLoggedAt } - - in: query - name: sortDir - schema: { type: string, enum: [asc, desc], default: desc } - responses: - "200": - description: Mood entries - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - entries: - type: array - items: { $ref: "#/components/schemas/MoodEntry" } - meta: { $ref: "#/components/schemas/PageMeta" } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - post: - tags: [Mood] - summary: Create a mood entry - operationId: createMoodEntry - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/CreateMoodEntryInput" } - responses: - "201": - description: Created - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/MoodEntry" } - "401": { $ref: "#/components/responses/Unauthorized" } - "409": { $ref: "#/components/responses/Conflict" } - "422": { $ref: "#/components/responses/Unprocessable" } - - /api/mood-entries/{id}: - parameters: - - $ref: "#/components/parameters/IdPathParam" - get: - tags: [Mood] - summary: Get a mood entry - operationId: getMoodEntry - responses: - "200": - description: Mood entry - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/MoodEntry" } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - put: - tags: [Mood] - summary: Update a mood entry - operationId: updateMoodEntry - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/UpdateMoodEntryInput" } - responses: - "200": - description: Updated - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/MoodEntry" } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - "422": { $ref: "#/components/responses/Unprocessable" } - delete: - tags: [Mood] - summary: Delete a mood entry - operationId: deleteMoodEntry - responses: - "200": - description: Deleted - content: - application/json: - schema: { $ref: "#/components/schemas/DeletedEnvelope" } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - - /api/mood/analytics: - get: - tags: [Mood] - summary: Mood analytics (daily averages + summary) - operationId: getMoodAnalytics - responses: - "200": - description: Analytics - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/MoodAnalytics" } - "401": { $ref: "#/components/responses/Unauthorized" } - - # ─── Dashboard ──────────────────────────────────────────────── - /api/dashboard/widgets: - get: - tags: [Dashboard] - summary: Get the resolved dashboard widget layout - operationId: getDashboardWidgets - responses: - "200": - description: Layout - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/DashboardLayout" } - "401": { $ref: "#/components/responses/Unauthorized" } - put: - tags: [Dashboard] - summary: Replace the dashboard widget layout - operationId: updateDashboardWidgets - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/DashboardLayout" } - responses: - "200": - description: Updated - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/DashboardLayout" } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - delete: - tags: [Dashboard] - summary: Reset dashboard layout to defaults - operationId: resetDashboardWidgets - responses: - "200": - description: Reset - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/DashboardLayout" } - "401": { $ref: "#/components/responses/Unauthorized" } - - # ─── Insights ──────────────────────────────────────────────── - /api/insights/comprehensive: - get: - tags: [Insights] - summary: Comprehensive 90-day analytics bundle - operationId: getComprehensiveInsights - responses: - "200": - description: Insights bundle - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - { $ref: "#/components/schemas/ComprehensiveInsights" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/insights/generate: - post: - tags: [Insights] - summary: Generate AI-narrative insights (cached for 24h) - operationId: generateInsights - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - force: { type: boolean, description: "Bypass 24h cache" } - responses: - "200": - description: Insights - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - { $ref: "#/components/schemas/InsightGenerateResult" } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - "429": { $ref: "#/components/responses/RateLimited" } - "502": - description: Upstream AI provider failure - content: - application/json: - schema: { $ref: "#/components/schemas/ApiError" } - - /api/insights/settings: - get: - tags: [Insights] - summary: Get insights settings (privacy mode, provider status) - operationId: getInsightsSettings - responses: - "200": - description: Settings - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/InsightsSettings" } - "401": { $ref: "#/components/responses/Unauthorized" } - put: - tags: [Insights] - summary: Update insights settings (privacy mode) - operationId: updateInsightsSettings - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - privacyMode: { type: string, enum: [aggregated, raw] } - responses: - "200": - description: Updated - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - updated: { type: boolean } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - - /api/insights/targets: - get: - tags: [Insights] - summary: Per-metric target ranges + classification - operationId: getInsightTargets - responses: - "200": - description: Targets - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - { $ref: "#/components/schemas/InsightTargetsResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/insights/blood-pressure-status: - get: - tags: [Insights] - summary: BP-specific narrative status - operationId: getBloodPressureStatus - parameters: - - $ref: "#/components/parameters/LocaleParam" - responses: - "200": - description: BP status - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/InsightStatusBundle" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/insights/bmi-status: - get: - tags: [Insights] - summary: BMI narrative status - operationId: getBmiStatus - parameters: - - $ref: "#/components/parameters/LocaleParam" - responses: - "200": - description: BMI status - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/InsightStatusBundle" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/insights/general-status: - get: - tags: [Insights] - summary: General health narrative status - operationId: getGeneralStatus - parameters: - - $ref: "#/components/parameters/LocaleParam" - responses: - "200": - description: Status - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/InsightStatusBundle" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/insights/medication-compliance-status: - get: - tags: [Insights] - summary: Medication compliance narrative status - operationId: getMedicationComplianceStatus - parameters: - - $ref: "#/components/parameters/LocaleParam" - responses: - "200": - description: Status - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/InsightStatusBundle" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/insights/mood-status: - get: - tags: [Insights] - summary: Mood narrative status - operationId: getMoodStatus - parameters: - - $ref: "#/components/parameters/LocaleParam" - responses: - "200": - description: Status - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/InsightStatusBundle" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/insights/pulse-status: - get: - tags: [Insights] - summary: Pulse narrative status - operationId: getPulseStatus - parameters: - - $ref: "#/components/parameters/LocaleParam" - responses: - "200": - description: Status - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/InsightStatusBundle" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/insights/weight-status: - get: - tags: [Insights] - summary: Weight narrative status - operationId: getWeightStatus - parameters: - - $ref: "#/components/parameters/LocaleParam" - responses: - "200": - description: Status - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/InsightStatusBundle" } - "401": { $ref: "#/components/responses/Unauthorized" } - - # ─── Achievements ───────────────────────────────────────────── - /api/gamification/achievements: - get: - tags: [Achievements] - summary: Get achievements progress + unlocks - operationId: getAchievements - responses: - "200": - description: Achievements - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/AchievementsResult" } - "401": { $ref: "#/components/responses/Unauthorized" } - - # ─── DoctorReport ───────────────────────────────────────────── - /api/doctor-report: - post: - tags: [DoctorReport] - summary: Aggregated health data for doctor-report PDF - operationId: getDoctorReport - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - days: - type: integer - minimum: 1 - maximum: 365 - default: 90 - responses: - "200": - description: Report data - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - { $ref: "#/components/schemas/DoctorReportResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "429": { $ref: "#/components/responses/RateLimited" } - - # ─── Onboarding ─────────────────────────────────────────────── - /api/onboarding/complete: - post: - tags: [Onboarding] - summary: Mark onboarding complete and save initial profile - operationId: completeOnboarding - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/OnboardingInput" } - responses: - "200": - description: Completed - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - completed: { type: boolean } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - - # ─── Health probe ───────────────────────────────────────────── - /api/health: - get: - tags: [Health] - summary: Service liveness probe - operationId: healthCheck - security: [] - responses: - "200": - description: Healthy - content: - application/json: - schema: { $ref: "#/components/schemas/HealthStatus" } - "503": - description: Degraded - content: - application/json: - schema: { $ref: "#/components/schemas/HealthStatus" } - - # ─── Tokens ────────────────────────────────────────────────── - /api/tokens: - get: - tags: [Tokens] - summary: List the user's API tokens (metadata only — secrets never returned) - operationId: listTokens - responses: - "200": - description: Tokens - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: array - items: { $ref: "#/components/schemas/ApiTokenSummary" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - post: - tags: [Tokens] - summary: Create an API token (token returned ONCE) - operationId: createToken - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/CreateTokenInput" } - responses: - "201": - description: Created (token visible once) - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - token: { type: string, example: "hlk_" } - name: { type: string } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "422": { $ref: "#/components/responses/Unprocessable" } - - /api/tokens/{id}: - parameters: - - $ref: "#/components/parameters/IdPathParam" - delete: - tags: [Tokens] - summary: Revoke an API token - operationId: revokeToken - responses: - "200": - description: Revoked - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - revoked: { type: boolean } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - - # ─── Ingest (external) ──────────────────────────────────────── - /api/ingest/medication: - post: - tags: [Ingest] - summary: External medication intake ingest (Bearer token, idempotent) - operationId: ingestMedicationIntake - security: - - bearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/ExternalIntakeInput" } - responses: - "200": - description: Existing event returned (idempotent replay) - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - { $ref: "#/components/schemas/MedicationIntakeEvent" } - "201": - description: Created - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - { $ref: "#/components/schemas/MedicationIntakeEvent" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "422": { $ref: "#/components/responses/Unprocessable" } - "429": { $ref: "#/components/responses/RateLimited" } - - # ─── Notifications ──────────────────────────────────────────── - /api/notifications/preferences: - get: - tags: [Notifications] - summary: List notification channels + preferences - operationId: getNotificationPreferences - responses: - "200": - description: Preferences - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - { - $ref: "#/components/schemas/NotificationPreferencesResponse", - } - "401": { $ref: "#/components/responses/Unauthorized" } - put: - tags: [Notifications] - summary: Toggle a single channel/event preference - operationId: updateNotificationPreference - requestBody: - required: true - content: - application/json: - schema: - { $ref: "#/components/schemas/UpdateNotificationPreferenceInput" } - responses: - "200": - description: Updated - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - { $ref: "#/components/schemas/NotificationPreference" } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - "422": { $ref: "#/components/responses/Unprocessable" } - - /api/notifications/vapid: - get: - tags: [Notifications] - summary: Get VAPID public key (Web Push) - operationId: getVapidKey - security: [] - responses: - "200": - description: VAPID key - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - publicKey: { type: string } - "503": - description: Not configured - content: - application/json: - schema: { $ref: "#/components/schemas/ApiError" } - - /api/notifications/web-push: - post: - tags: [Notifications] - summary: Subscribe a Web Push endpoint - operationId: subscribeWebPush - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/WebPushSubscribeInput" } - responses: - "200": - description: Subscribed - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - subscribed: { type: boolean } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - delete: - tags: [Notifications] - summary: Unsubscribe a Web Push endpoint - operationId: unsubscribeWebPush - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - endpoint: { type: string, format: uri } - required: [endpoint] - responses: - "200": - description: Unsubscribed - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - unsubscribed: { type: boolean } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - - # ─── User configuration ─────────────────────────────────────── - /api/user/ai-provider: - get: - tags: [User] - summary: Get user-level AI provider config - operationId: getUserAiProvider - responses: - "200": - description: Config - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/UserAiProvider" } - "401": { $ref: "#/components/responses/Unauthorized" } - patch: - tags: [User] - summary: Update user-level AI provider config - operationId: updateUserAiProvider - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/UpdateUserAiProviderInput" } - responses: - "200": - description: Updated - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - updated: { type: boolean } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - - /api/user/thresholds: - get: - tags: [User] - summary: Get effective + override threshold ranges - operationId: getThresholds - responses: - "200": - description: Thresholds - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/ThresholdsResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - put: - tags: [User] - summary: Set per-metric threshold overrides (partial) - operationId: updateThresholds - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/ThresholdOverrides" } - responses: - "200": - description: Updated - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - overrides: - { $ref: "#/components/schemas/ThresholdOverrides" } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - delete: - tags: [User] - summary: Reset threshold overrides (all or one metric) - operationId: resetThresholds - parameters: - - in: query - name: metric - schema: { type: string } - description: "If supplied, only this metric is reset; otherwise all overrides are cleared." - responses: - "200": - description: Reset - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - overrides: - { $ref: "#/components/schemas/ThresholdOverrides" } - "400": { $ref: "#/components/responses/BadRequest" } - "401": { $ref: "#/components/responses/Unauthorized" } - - # ─── Analytics + AI test ────────────────────────────────────── - /api/analytics: - get: - tags: [Analytics] - summary: Per-type measurement summaries + BMI + BP-in-target - operationId: getAnalytics - responses: - "200": - description: Analytics - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/AnalyticsResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/ai/test: - post: - tags: [User] - summary: Test the configured AI provider - operationId: testAiProvider - responses: - "200": - description: AI test result - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/AiTestResult" } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - "429": { $ref: "#/components/responses/RateLimited" } - "502": - description: Upstream AI provider failure - content: - application/json: - schema: { $ref: "#/components/schemas/ApiError" } - - # ─── Export / Import ────────────────────────────────────────── - /api/export: - get: - tags: [Export] - summary: Export user data (CSV or JSON) - operationId: exportData - parameters: - - in: query - name: format - schema: { type: string, enum: [csv, json], default: json } - - in: query - name: type - schema: - type: string - enum: [measurements, medications, intake, mood, all] - default: all - responses: - "200": - description: Export payload - content: - application/json: - schema: - type: object - properties: - data: - type: object - additionalProperties: true - text/csv: - schema: { type: string } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - "429": { $ref: "#/components/responses/RateLimited" } - - /api/import: - post: - tags: [Export] - summary: Import previously exported data (JSON) - operationId: importData - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/ImportInput" } - responses: - "200": - description: Import stats - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/ImportResult" } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - - # ─── Audit log + Feedback + Bug report ──────────────────────── - /api/audit-log: - get: - tags: [AuditLog] - summary: Personal audit log entries - operationId: getAuditLog - parameters: - - $ref: "#/components/parameters/LimitParam" - - $ref: "#/components/parameters/OffsetParam" - responses: - "200": - description: Entries - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/AuditLogResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/feedback: - get: - tags: [Feedback] - summary: List the user's submitted feedback - operationId: listFeedback - parameters: - - $ref: "#/components/parameters/LimitParam" - - $ref: "#/components/parameters/OffsetParam" - responses: - "200": - description: Feedback - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - items: - type: array - items: - { $ref: "#/components/schemas/FeedbackSummary" } - meta: { $ref: "#/components/schemas/PageMeta" } - "401": { $ref: "#/components/responses/Unauthorized" } - post: - tags: [Feedback] - summary: Submit feedback (with optional screenshot) - operationId: submitFeedback - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/CreateFeedbackInput" } - responses: - "201": - description: Created - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/FeedbackSummary" } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - "429": { $ref: "#/components/responses/RateLimited" } - - /api/bugreport: - post: - tags: [BugReport] - summary: File a bug report (creates a GitHub issue when configured) - operationId: submitBugReport - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/BugReportInput" } - responses: - "200": - description: Issue created - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - issueNumber: { type: integer } - issueUrl: { type: string, format: uri } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - "429": { $ref: "#/components/responses/RateLimited" } - "500": { $ref: "#/components/responses/ServerError" } - "503": - description: Bug report not configured - content: - application/json: - schema: { $ref: "#/components/schemas/ApiError" } - - /api/bugreport/status: - get: - tags: [BugReport] - summary: Whether bug-report submission is configured - operationId: getBugReportStatus - responses: - "200": - description: Status - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - configured: { type: boolean } - isAdmin: { type: boolean } - "401": { $ref: "#/components/responses/Unauthorized" } - "429": { $ref: "#/components/responses/RateLimited" } - - # ─── Settings (per-user) ────────────────────────────────────── - /api/settings/account: - delete: - tags: [Settings] - summary: Delete the user's account (and all data) - operationId: deleteAccount - responses: - "200": - description: Account deleted - content: - application/json: - schema: { $ref: "#/components/schemas/DeletedEnvelope" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/settings/data: - delete: - tags: [Settings] - summary: Wipe the user's data (keep account) - operationId: wipeData - responses: - "200": - description: Data wiped - content: - application/json: - schema: { $ref: "#/components/schemas/DeletedEnvelope" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/settings/global-services: - get: - tags: [Settings] - summary: Global service availability flags (telegram, ntfy, web-push, …) - operationId: getGlobalServices - responses: - "200": - description: Flags - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/GlobalServicesFlags" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/settings/moodlog: - put: - tags: [Settings] - summary: Configure moodLog integration - operationId: updateMoodLogSettings - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/MoodLogSettingsInput" } - responses: - "200": - description: Updated - content: - application/json: - schema: { $ref: "#/components/schemas/UpdatedEnvelope" } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - delete: - tags: [Settings] - summary: Disconnect moodLog integration - operationId: deleteMoodLogSettings - responses: - "200": - description: Removed - content: - application/json: - schema: { $ref: "#/components/schemas/DeletedEnvelope" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/settings/ntfy: - get: - tags: [Settings] - summary: Get ntfy channel settings - operationId: getNtfySettings - responses: - "200": - description: Config - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/NtfySettings" } - "401": { $ref: "#/components/responses/Unauthorized" } - put: - tags: [Settings] - summary: Update ntfy channel settings - operationId: updateNtfySettings - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/NtfySettingsInput" } - responses: - "200": - description: Updated - content: - application/json: - schema: { $ref: "#/components/schemas/UpdatedEnvelope" } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - - /api/settings/ntfy/test: - post: - tags: [Settings] - summary: Send a test ntfy notification - operationId: testNtfy - responses: - "200": - description: Test result - content: - application/json: - schema: { $ref: "#/components/schemas/ApiEnvelope" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/settings/reminder-thresholds: - get: - tags: [Settings] - summary: Get reminder lateness thresholds (admin-defined defaults) - operationId: getReminderThresholds - responses: - "200": - description: Thresholds - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/ReminderThresholds" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/settings/telegram: - get: - tags: [Settings] - summary: Get Telegram channel settings - operationId: getTelegramSettings - responses: - "200": - description: Config - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/TelegramSettings" } - "401": { $ref: "#/components/responses/Unauthorized" } - put: - tags: [Settings] - summary: Update Telegram channel settings - operationId: updateTelegramSettings - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/TelegramSettingsInput" } - responses: - "200": - description: Updated - content: - application/json: - schema: { $ref: "#/components/schemas/UpdatedEnvelope" } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - - /api/settings/telegram/test: - post: - tags: [Settings] - summary: Send a test Telegram message - operationId: testTelegram - responses: - "200": - description: Test result - content: - application/json: - schema: { $ref: "#/components/schemas/ApiEnvelope" } - "401": { $ref: "#/components/responses/Unauthorized" } - - # ─── Withings integration ──────────────────────────────────── - /api/withings/connect: - get: - tags: [Integrations] - summary: Begin Withings OAuth (302 redirect) - operationId: withingsConnect - responses: - "302": - description: Redirect to Withings - "400": { $ref: "#/components/responses/BadRequest" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/withings/callback: - get: - tags: [Integrations] - summary: Withings OAuth callback (302 to /settings) - operationId: withingsCallback - parameters: - - in: query - name: code - schema: { type: string } - - in: query - name: state - schema: { type: string } - responses: - "302": - description: Redirect back to /settings with status - - /api/withings/disconnect: - post: - tags: [Integrations] - summary: Disconnect Withings - operationId: withingsDisconnect - responses: - "200": - description: Disconnected - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - disconnected: { type: boolean } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - - /api/withings/credentials: - get: - tags: [Integrations] - summary: Whether Withings client credentials are stored - operationId: getWithingsCredentialsStatus - responses: - "200": - description: Status - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - hasCredentials: { type: boolean } - "401": { $ref: "#/components/responses/Unauthorized" } - put: - tags: [Integrations] - summary: Save Withings client ID + secret (encrypted) - operationId: saveWithingsCredentials - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/WithingsCredentialsInput" } - responses: - "200": - description: Saved - content: - application/json: - schema: { $ref: "#/components/schemas/UpdatedEnvelope" } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - delete: - tags: [Integrations] - summary: Delete Withings credentials (and disconnect) - operationId: deleteWithingsCredentials - responses: - "200": - description: Deleted - content: - application/json: - schema: { $ref: "#/components/schemas/DeletedEnvelope" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/withings/status: - get: - tags: [Integrations] - summary: Withings connection status - operationId: withingsStatus - responses: - "200": - description: Status - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/WithingsStatus" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/withings/sync: - post: - tags: [Integrations] - summary: Trigger a Withings sync (incremental or full) - operationId: withingsSync - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - fullSync: { type: boolean, default: false } - responses: - "200": - description: Sync result - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - imported: { type: integer } - fullSync: { type: boolean } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/withings/webhook: - post: - tags: [Webhooks] - summary: Withings webhook (notification of new measurements) - operationId: withingsWebhook - security: [] - parameters: - - in: query - name: secret - schema: { type: string } - description: "Shared secret for webhook authentication." - responses: - "200": - description: Acknowledged - content: - application/json: - schema: - type: object - properties: - status: { type: string } - "401": - description: Unauthorized - "429": - description: Rate limited - get: - tags: [Webhooks] - summary: Withings webhook URL verification - operationId: withingsWebhookVerifyGet - security: [] - parameters: - - in: query - name: secret - schema: { type: string } - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - status: { type: string } - "401": - description: Unauthorized - head: - tags: [Webhooks] - summary: Withings webhook URL verification (HEAD) - operationId: withingsWebhookVerifyHead - security: [] - parameters: - - in: query - name: secret - schema: { type: string } - responses: - "200": - description: OK - "401": - description: Unauthorized - - # ─── moodLog integration ───────────────────────────────────── - /api/integrations/moodlog/status: - get: - tags: [Integrations] - summary: moodLog connection status - operationId: moodLogStatus - responses: - "200": - description: Status - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/MoodLogStatus" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/integrations/moodlog/sync: - post: - tags: [Integrations] - summary: Trigger a moodLog sync - operationId: moodLogSync - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - fullSync: { type: boolean, default: false } - responses: - "200": - description: Sync result - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - imported: { type: integer } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - - /api/integrations/moodlog/webhook: - post: - tags: [Webhooks] - summary: Inbound webhook from moodLog - operationId: moodLogWebhook - security: [] - parameters: - - in: header - name: X-Webhook-Secret - required: true - schema: { type: string } - requestBody: - required: true - content: - application/json: - schema: - type: object - additionalProperties: true - responses: - "200": - description: Acknowledged - "400": { $ref: "#/components/responses/BadRequest" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "429": { $ref: "#/components/responses/RateLimited" } - - /api/telegram/webhook: - post: - tags: [Webhooks] - summary: Telegram inbound webhook - operationId: telegramWebhook - security: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - additionalProperties: true - responses: - "200": - description: Acknowledged - get: - tags: [Webhooks] - summary: Telegram webhook health probe - operationId: telegramWebhookGet - security: [] - responses: - "200": - description: OK - - # ─── Public monitoring / proxies ───────────────────────────── - /api/monitoring/csp-report: - post: - tags: [Monitoring] - summary: Browser CSP violation report sink - operationId: cspReport - security: [] - requestBody: - required: true - content: - application/csp-report: - schema: - type: object - additionalProperties: true - application/json: - schema: - type: object - additionalProperties: true - responses: - "204": - description: Accepted (no body) - - /api/monitoring/glitchtip: - post: - tags: [Monitoring] - summary: Forward errors to Glitchtip - operationId: glitchtipReport - security: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - additionalProperties: true - responses: - "204": - description: Accepted - - /api/monitoring/settings: - get: - tags: [Monitoring] - summary: Public monitoring settings (umami / glitchtip flags) - operationId: getMonitoringSettings - security: [] - responses: - "200": - description: Public settings - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - { - $ref: "#/components/schemas/PublicMonitoringSettings", - } - - /api/monitoring/umami-script: - get: - tags: [Monitoring] - summary: Server-side proxy for the Umami tracker script - operationId: getUmamiScript - security: [] - responses: - "200": - description: Tracker script - content: - application/javascript: - schema: { type: string } - - /api/send: - post: - tags: [Monitoring] - summary: Server-side proxy for Umami /api/send - operationId: umamiProxySend - security: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - additionalProperties: true - responses: - "200": - description: Forwarded - "204": - description: No-op when umami is disabled - "413": - description: Payload too large - - # ─── Admin (internal) ──────────────────────────────────────── - /api/admin/ai-settings: - get: - tags: [Admin] - security: - - sessionCookie: [] - summary: "Internal: get admin-managed AI fallback settings" - operationId: adminGetAiSettings - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - put: - tags: [Admin] - security: - - sessionCookie: [] - summary: "Internal: update admin-managed AI fallback settings" - operationId: adminUpdateAiSettings - requestBody: - required: true - content: - application/json: - schema: { type: object, additionalProperties: true } - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "422": { $ref: "#/components/responses/Unprocessable" } - - /api/admin/audit-log: - get: - tags: [Admin] - security: - - sessionCookie: [] - summary: "Internal: cross-user audit log" - operationId: adminGetAuditLog - parameters: - - $ref: "#/components/parameters/LimitParam" - - $ref: "#/components/parameters/OffsetParam" - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - - /api/admin/data: - delete: - tags: [Admin] - security: - - sessionCookie: [] - summary: "Internal: wipe per-user or all data" - operationId: adminWipeData - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - - /api/admin/feedback: - get: - tags: [Admin] - security: - - sessionCookie: [] - summary: "Internal: list all feedback" - operationId: adminListFeedback - parameters: - - $ref: "#/components/parameters/LimitParam" - - $ref: "#/components/parameters/OffsetParam" - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - - /api/admin/feedback/{id}: - parameters: - - $ref: "#/components/parameters/IdPathParam" - patch: - tags: [Admin] - security: - - sessionCookie: [] - summary: "Internal: update feedback status / admin note" - operationId: adminUpdateFeedback - requestBody: - required: true - content: - application/json: - schema: { type: object, additionalProperties: true } - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - delete: - tags: [Admin] - security: - - sessionCookie: [] - summary: "Internal: delete feedback" - operationId: adminDeleteFeedback - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - - /api/admin/feedback/{id}/github: - parameters: - - $ref: "#/components/parameters/IdPathParam" - post: - tags: [Admin] - security: - - sessionCookie: [] - summary: "Internal: escalate feedback to a GitHub issue" - operationId: adminEscalateFeedback - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - - /api/admin/monitoring/glitchtip-test: - post: - tags: [Admin] - security: - - sessionCookie: [] - summary: "Internal: send Glitchtip test event" - operationId: adminGlitchtipTest - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - - /api/admin/monitoring/umami-test: - post: - tags: [Admin] - security: - - sessionCookie: [] - summary: "Internal: send Umami test ping" - operationId: adminUmamiTest - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - - /api/admin/notifications/reminder-check: - post: - tags: [Admin] - security: - - sessionCookie: [] - summary: "Internal: trigger medication reminder evaluation" - operationId: adminTriggerReminderCheck - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - - /api/admin/notifications/test: - post: - tags: [Admin] - security: - - sessionCookie: [] - summary: "Internal: send a test notification" - operationId: adminTestNotification - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - - /api/admin/settings: - get: - tags: [Admin] - security: - - sessionCookie: [] - summary: "Internal: get app-wide settings" - operationId: adminGetSettings - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - put: - tags: [Admin] - security: - - sessionCookie: [] - summary: "Internal: update app-wide settings" - operationId: adminUpdateSettings - requestBody: - required: true - content: - application/json: - schema: { type: object, additionalProperties: true } - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "422": { $ref: "#/components/responses/Unprocessable" } - - /api/admin/status: - get: - tags: [Admin] - security: - - sessionCookie: [] - summary: "Internal: deep system status" - operationId: adminStatus - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - - /api/admin/tokens: - get: - tags: [Admin] - security: - - sessionCookie: [] - summary: "Internal: list all API tokens (cross-user)" - operationId: adminListTokens - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - - /api/admin/users: - get: - tags: [Admin] - security: - - sessionCookie: [] - summary: "Internal: list all users" - operationId: adminListUsers - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - - /api/admin/users/{id}: - parameters: - - $ref: "#/components/parameters/IdPathParam" - put: - tags: [Admin] - security: - - sessionCookie: [] - summary: "Internal: update a user (username/email/role)" - operationId: adminUpdateUser - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/AdminUpdateUserInput" } - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "400": { $ref: "#/components/responses/BadRequest" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "422": { $ref: "#/components/responses/Unprocessable" } - - /api/admin/users/{id}/reset-password: - parameters: - - $ref: "#/components/parameters/IdPathParam" - post: - tags: [Admin] - security: - - sessionCookie: [] - summary: "Internal: reset a user's password" - operationId: adminResetUserPassword - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - - /api/admin/status-overview: - get: - tags: [Admin] - security: - - sessionCookie: [] - summary: "Internal: aggregator for the 6-card status grid (v1.4)" - description: | - Returns the precomputed status payload the admin top strip - renders — users / integrations / monitoring / backups / - maintenance / audit-log summaries with severity already - resolved server-side. Single batched query, no N+1. - operationId: adminStatusOverview - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - - /api/admin/backup/test: - post: - tags: [Admin] - security: - - sessionCookie: [] - summary: "Internal: probe the configured S3-compatible backup target (v1.4)" - description: | - Round-trips a small object against the bucket using the - configured `BACKUP_S3_*` credentials. Returns latency and the - sanitised endpoint host on success; never echoes the access key. - operationId: adminBackupTest - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "422": { $ref: "#/components/responses/Unprocessable" } - - # ─── Public + v1.4 additions ───────────────────────────────────────── - - /api/version: - get: - tags: [Public] - summary: "Public: running version + build SHA + license" - description: | - Public, unauthenticated. Returns - `{ version, buildSha, builtAt, license, repository, changelog, docs }`. - Used by the docker container healthcheck and the Settings → - About surface's "Check for updates" button. - operationId: getVersion - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - - /api/integrations/withings/test: - post: - tags: [Withings] - security: - - sessionCookie: [] - summary: "Probe the saved Withings OAuth connection (v1.4)" - operationId: withingsTest - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - "429": { $ref: "#/components/responses/RateLimited" } - - /api/integrations/moodlog/test: - post: - tags: [Moodlog] - security: - - sessionCookie: [] - summary: "Probe moodLog.app reachability (v1.4)" - description: | - SSRF-guarded HEAD against the configured moodLog endpoint. - Returns `{ ok, latency_ms, errorCode? }` so the UI can - localise the failure message. - operationId: moodlogTest - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "429": { $ref: "#/components/responses/RateLimited" } - - /api/notifications/web-push/test: - post: - tags: [Notifications] - security: - - sessionCookie: [] - summary: "Send a test Web Push to the current user (v1.4)" - operationId: webPushTest - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - "429": { $ref: "#/components/responses/RateLimited" } - - /api/monitoring/glitchtip/test: - post: - tags: [Monitoring] - security: - - sessionCookie: [] - summary: "Probe the Glitchtip ingest endpoint (v1.4)" - operationId: glitchtipTest - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - "429": { $ref: "#/components/responses/RateLimited" } - - /api/monitoring/umami/test: - post: - tags: [Monitoring] - security: - - sessionCookie: [] - summary: "Verify the Umami script + website ID resolve (v1.4)" - operationId: umamiTest - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - "429": { $ref: "#/components/responses/RateLimited" } - - /api/auth/refresh: - post: - tags: [Auth] - summary: "Native client: refresh-token rotation (v1.4)" - description: | - Exchanges a refresh token (`hlr_<64hex>`) for a fresh 24h - access token and a new refresh token. Cookie-session browsers - do not use this. Reuse-detection: presenting the same refresh - token a second time revokes every refresh token for the user. - operationId: authRefresh - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [refreshToken] - properties: - refreshToken: { type: string, pattern: "^hlr_[A-Fa-f0-9]{64}$" } - revoke: - type: boolean - default: false - description: "When true, revokes the refresh row + paired access token instead of rotating." - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - "429": { $ref: "#/components/responses/RateLimited" } - - /api/auth/refresh/revoke: - post: - tags: [Auth] - summary: "Native client: revoke a refresh token + paired access token (v1.4)" - operationId: authRefreshRevoke - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [refreshToken] - properties: - refreshToken: { type: string, pattern: "^hlr_[A-Fa-f0-9]{64}$" } - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - - # ─── iOS adapter endpoints (v1.3) ──────────────────────────────────── - - /api/dashboard/summary: - get: - tags: [Dashboard] - summary: "iOS: aggregated dashboard summary" - description: | - Single-call aggregate for the iOS DashboardSummary view. Combines - greeting, current/longest activity-day streak, today's medication - compliance, optional highlight insight, and per-metric latest + - 7-day sparkline + trend. - operationId: getDashboardSummary - responses: - "200": - description: Dashboard summary - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/DashboardSummary" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/measurements/series: - get: - tags: [Measurements] - summary: "iOS: time-series + summary stats for a metric kind" - operationId: getMeasurementSeries - parameters: - - in: query - name: kind - required: true - schema: { $ref: "#/components/schemas/MetricKind" } - - in: query - name: days - schema: - type: integer - minimum: 1 - maximum: 365 - default: 30 - responses: - "200": - description: Series payload - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/MeasurementSeries" } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - - /api/integrations/healthkit: - get: - tags: [Integrations] - summary: "iOS: HealthKit per-metric direction config" - operationId: getHealthKitConfig - responses: - "200": - description: HealthKit config - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/HealthKitConfig" } - "401": { $ref: "#/components/responses/Unauthorized" } - patch: - tags: [Integrations] - summary: "iOS: update HealthKit per-metric direction config" - operationId: updateHealthKitConfig - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/HealthKitConfigPatch" } - responses: - "200": - description: Updated config - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/HealthKitConfig" } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - - /api/user/profile: - get: - tags: [Auth] - summary: "iOS: flattened user profile" - operationId: getUserProfile - responses: - "200": - description: User profile - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/IosUserProfile" } - "401": { $ref: "#/components/responses/Unauthorized" } - patch: - tags: [Auth] - summary: "iOS: update profile fields" - operationId: updateUserProfile - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/IosUserProfilePatch" } - responses: - "200": - description: Updated profile - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: { $ref: "#/components/schemas/IosUserProfile" } - "401": { $ref: "#/components/responses/Unauthorized" } - "409": { $ref: "#/components/responses/Conflict" } - "422": { $ref: "#/components/responses/Unprocessable" } - - /api/devices: - post: - tags: [Notifications] - summary: "iOS: register an APNs device token" - operationId: registerDevice - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/DeviceRegisterInput" } - responses: - "201": - description: Device registered (or token transferred) - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - id: { type: string } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - - /api/medications/intake: - get: - tags: [Medications] - summary: "iOS: today list or compliance buckets" - operationId: listIntakeAggregator - parameters: - - in: query - name: scope - required: true - schema: { type: string, enum: [today, compliance] } - - in: query - name: days - schema: - type: integer - minimum: 1 - maximum: 365 - default: 30 - responses: - "200": - description: Aggregator payload - content: - application/json: - schema: { $ref: "#/components/schemas/ApiEnvelope" } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - post: - tags: [Medications] - summary: "iOS: update an intake event by id (taken/skipped/snoozed)" - operationId: updateIntakeAggregator - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/IntakeUpdateInput" } - responses: - "200": - description: Updated intake event - content: - application/json: - schema: { $ref: "#/components/schemas/ApiEnvelope" } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } - "422": { $ref: "#/components/responses/Unprocessable" } - - /api/insights/cards: - get: - tags: [Insights] - summary: "iOS: rule-based insight cards" - operationId: getInsightCards - responses: - "200": - description: Insight cards - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: array - items: { $ref: "#/components/schemas/InsightCard" } - "401": { $ref: "#/components/responses/Unauthorized" } - - /api/insights/correlations: - get: - tags: [Insights] - summary: "iOS: cross-metric correlations (placeholder)" - operationId: getInsightCorrelations - description: | - Returns an empty array until the correlations engine is wired up - (Phase 7). Audited via `insights.correlations.empty`. - responses: - "200": - description: Correlations - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: array - items: - { $ref: "#/components/schemas/InsightCorrelation" } - "401": { $ref: "#/components/responses/Unauthorized" } - -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer - bearerFormat: "hlk_" - description: | - Long-lived API token issued via `POST /api/tokens`. Accepted on all - user endpoints (interchangeable with the session cookie) and required - on external ingest endpoints (e.g. `/api/ingest/medication`). Tokens - are prefixed `hlk_` and stored as HMAC-SHA-256 hashes server-side - (keyed with `API_TOKEN_HMAC_KEY` so a DB dump alone does not allow - precomputed-rainbow lookups). Bearer tokens never grant admin access - — admin routes require the session cookie. Permission scopes are - enforced from the token's `permissions` array; a token MUST declare - either the explicit required permission for the route or the - wildcard `"*"`. - sessionCookie: - type: apiKey - in: cookie - name: healthlog_session - description: | - HttpOnly session cookie set by `/api/auth/login` and the passkey - login flow. Authenticates the web app and (optionally) the iOS - client when used in a web view. - - parameters: - IdPathParam: - in: path - name: id - required: true - schema: - type: string - description: "CUID identifier." - LimitParam: - in: query - name: limit - schema: - type: integer - minimum: 1 - maximum: 500 - default: 100 - OffsetParam: - in: query - name: offset - schema: - type: integer - minimum: 0 - default: 0 - LocaleParam: - in: query - name: locale - schema: - type: string - enum: [de, en] - description: "Override the resolved locale for narrative text." - - responses: - BadRequest: - description: Bad request - content: - application/json: - schema: { $ref: "#/components/schemas/ApiError" } - Unauthorized: - description: Authentication required - content: - application/json: - schema: { $ref: "#/components/schemas/ApiError" } - Forbidden: - description: Forbidden - content: - application/json: - schema: { $ref: "#/components/schemas/ApiError" } - NotFound: - description: Not found - content: - application/json: - schema: { $ref: "#/components/schemas/ApiError" } - Conflict: - description: Conflict (e.g. duplicate) - content: - application/json: - schema: { $ref: "#/components/schemas/ApiError" } - Unprocessable: - description: Validation failure - content: - application/json: - schema: { $ref: "#/components/schemas/ApiError" } - RateLimited: - description: Rate limit exceeded - content: - application/json: - schema: { $ref: "#/components/schemas/ApiError" } - ServerError: - description: Internal server error - content: - application/json: - schema: { $ref: "#/components/schemas/ApiError" } - - schemas: - # ─── Envelope / errors ───────────────────────────────────── - ApiEnvelope: - type: object - description: Standard envelope. On success `error` is `null`; on failure `data` is `null` and `error` is a string. - properties: - data: - description: Response payload (typed per-endpoint). - error: - type: [string, "null"] - required: [data, error] - - ApiError: - type: object - properties: - data: - type: "null" - error: - type: string - required: [data, error] - - DeletedEnvelope: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - deleted: { type: boolean } - - UpdatedEnvelope: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - updated: { type: boolean } - - PageMeta: - type: object - properties: - total: { type: integer } - limit: { type: integer } - offset: { type: integer } - required: [total, limit, offset] - - # ─── Enums ───────────────────────────────────────────────── - MeasurementType: - type: string - enum: - - WEIGHT - - BLOOD_PRESSURE_SYS - - BLOOD_PRESSURE_DIA - - PULSE - - BODY_FAT - - SLEEP_DURATION - - ACTIVITY_STEPS - - BLOOD_GLUCOSE - - TOTAL_BODY_WATER - - BONE_MASS - - OXYGEN_SATURATION - - MeasurementSource: - type: string - enum: [MANUAL, WITHINGS, IMPORT] - - GlucoseContext: - type: string - enum: [FASTING, POSTPRANDIAL, RANDOM, BEDTIME] - - IntakeSource: - type: string - enum: [WEB, API, REMINDER, IMPORT] - - ReminderPhase: - type: string - enum: [GREEN, YELLOW, ORANGE, RED] - - PhaseMode: - type: string - enum: [MINUTES, PERCENT] - - UserRole: - type: string - enum: [ADMIN, USER] - - Gender: - type: string - enum: [MALE, FEMALE] - - MoodLevel: - type: string - enum: [SUPER_GUT, GUT, OKAY, SCHLECHT, LAUSIG] - - MedicationCategory: - type: string - enum: - - BLOOD_PRESSURE - - VITAMIN - - SUPPLEMENT - - PAIN_RELIEF - - ALLERGY - - DIGESTIVE - - THYROID - - HORMONE - - SKIN - - SLEEP_AID - - OTHER - - NotificationChannelType: - type: string - enum: [TELEGRAM, NTFY, WEB_PUSH] - - AiProviderType: - type: string - enum: [OPENAI, ANTHROPIC, LOCAL, CHATGPT_OAUTH] - - InsightSeverity: - type: string - enum: [normal, mild, moderate, severe, info] - - Trend: - type: [string, "null"] - enum: [up, down, stable, null] - - # ─── Auth DTOs ───────────────────────────────────────────── - RegisterInput: - type: object - properties: - email: { type: string, format: email } - username: - type: string - minLength: 3 - maxLength: 30 - pattern: "^[a-zA-Z0-9_-]+$" - password: - type: string - minLength: 12 - required: [email, username, password] - - LoginInput: - type: object - properties: - email: - type: string - description: Email or username. - password: { type: string } - required: [email, password] - - LoginResponseEnvelope: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - user: - type: object - properties: - id: { type: string } - username: { type: string } - required: [id, username] - token: - type: string - description: | - Long-lived `hlk_*` Bearer token. Only returned when the - request carries `X-Client-Type: native` or a User-Agent - starting with `HealthLog-iOS`. Use it for subsequent - requests as `Authorization: Bearer `. - tokenExpiresAt: - type: string - format: date-time - description: ISO timestamp at which the auto-issued token expires. - - RegisterResponseEnvelope: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - type: object - properties: - user: - type: object - properties: - id: { type: string } - username: { type: string } - email: { type: [string, "null"] } - - ProfileInput: - type: object - properties: - email: { type: [string, "null"], format: email } - heightCm: - type: [number, "null"] - minimum: 50 - maximum: 300 - dateOfBirth: - type: [string, "null"] - format: date - gender: - oneOf: - - $ref: "#/components/schemas/Gender" - - type: "null" - - ChangePasswordInput: - type: object - properties: - currentPassword: { type: string } - newPassword: { type: string, minLength: 12 } - confirmPassword: { type: string } - required: [currentPassword, newPassword, confirmPassword] - - User: - type: object - description: Public-facing user shape. - properties: - id: { type: string } - username: { type: string } - email: { type: [string, "null"], format: email } - role: { $ref: "#/components/schemas/UserRole" } - heightCm: { type: [number, "null"] } - dateOfBirth: { type: [string, "null"], format: date-time } - gender: - oneOf: - - $ref: "#/components/schemas/Gender" - - type: "null" - timezone: { type: string } - required: [id, username, role, timezone] - - CurrentUser: - allOf: - - $ref: "#/components/schemas/User" - - type: object - properties: - onboardingCompletedAt: - type: [string, "null"] - format: date-time - gravatarUrl: - type: [string, "null"] - glucoseUnit: - type: [string, "null"] - enum: ["mg/dL", "mmol/L", null] - - # ─── Passkey DTOs ────────────────────────────────────────── - PasskeyRegisterOptions: - type: object - description: SimpleWebAuthn `PublicKeyCredentialCreationOptionsJSON` plus a server-issued challenge id. - properties: - options: - type: object - additionalProperties: true - description: WebAuthn registration options. - challengeId: { type: string } - required: [options, challengeId] - - PasskeyLoginOptions: - type: object - properties: - options: - type: object - additionalProperties: true - description: WebAuthn authentication options. - challengeId: { type: string } - required: [options, challengeId] - - PasskeyVerifyRegistrationInput: - type: object - properties: - challengeId: { type: string } - credential: - type: object - additionalProperties: true - description: "`RegistrationResponseJSON` from `@simplewebauthn/browser`." - required: [challengeId, credential] - - PasskeyVerifyLoginInput: - type: object - properties: - challengeId: { type: string } - credential: - type: object - additionalProperties: true - description: "`AuthenticationResponseJSON` from `@simplewebauthn/browser`." - required: [challengeId, credential] - - PasskeySummary: - type: object - properties: - id: { type: string } - name: { type: string } - credentialDeviceType: { type: string } - credentialBackedUp: { type: boolean } - createdAt: { type: string, format: date-time } - required: [id, name, credentialDeviceType, credentialBackedUp, createdAt] - - # ─── Measurement DTOs ────────────────────────────────────── - Measurement: - type: object - properties: - id: { type: string } - userId: { type: string } - type: { $ref: "#/components/schemas/MeasurementType" } - value: { type: number } - unit: { type: string } - source: { $ref: "#/components/schemas/MeasurementSource" } - measuredAt: { type: string, format: date-time } - notes: { type: [string, "null"], maxLength: 25 } - externalId: { type: [string, "null"] } - glucoseContext: - oneOf: - - $ref: "#/components/schemas/GlucoseContext" - - type: "null" - createdAt: { type: string, format: date-time } - updatedAt: { type: string, format: date-time } - required: - [ - id, - userId, - type, - value, - unit, - source, - measuredAt, - createdAt, - updatedAt, - ] - - CreateMeasurementInput: - type: object - properties: - type: { $ref: "#/components/schemas/MeasurementType" } - value: { type: number } - measuredAt: { type: string, format: date-time } - notes: { type: string, maxLength: 25 } - source: - allOf: [{ $ref: "#/components/schemas/MeasurementSource" }] - default: MANUAL - glucoseContext: - $ref: "#/components/schemas/GlucoseContext" - required: [type, value, measuredAt] - - UpdateMeasurementInput: - type: object - properties: - value: { type: number, minimum: 0, maximum: 500000 } - measuredAt: { type: string, format: date-time } - notes: { type: [string, "null"], maxLength: 25 } - - # ─── Medication DTOs ─────────────────────────────────────── - MedicationSchedule: - type: object - properties: - id: { type: string } - medicationId: { type: string } - windowStart: - type: string - pattern: '^([01]\d|2[0-3]):([0-5]\d)$' - description: HH:mm - windowEnd: - type: string - pattern: '^([01]\d|2[0-3]):([0-5]\d)$' - label: { type: [string, "null"] } - dose: - { type: [string, "null"], description: "Per-schedule dose override" } - daysOfWeek: - type: [string, "null"] - description: "Serialized recurrence (CSV of 0..6, optional intervalWeeks suffix)." - required: [id, medicationId, windowStart, windowEnd] - - Medication: - type: object - properties: - id: { type: string } - userId: { type: string } - name: { type: string } - dose: { type: string } - active: { type: boolean } - notificationsEnabled: { type: boolean } - pausedAt: { type: [string, "null"], format: date-time } - snoozedUntil: { type: [string, "null"], format: date-time } - createdAt: { type: string, format: date-time } - updatedAt: { type: string, format: date-time } - required: - [ - id, - userId, - name, - dose, - active, - notificationsEnabled, - createdAt, - updatedAt, - ] - - MedicationWithSchedules: - allOf: - - $ref: "#/components/schemas/Medication" - - type: object - properties: - schedules: - type: array - items: { $ref: "#/components/schemas/MedicationSchedule" } - category: { $ref: "#/components/schemas/MedicationCategory" } - lastTakenAt: - type: [string, "null"] - format: date-time - todayEventCount: - type: integer - - MedicationScheduleInput: - type: object - properties: - windowStart: { type: string, pattern: '^([01]\d|2[0-3]):([0-5]\d)$' } - windowEnd: { type: string, pattern: '^([01]\d|2[0-3]):([0-5]\d)$' } - label: { type: string, maxLength: 50 } - dose: { type: string, maxLength: 50 } - daysOfWeek: - type: array - items: { type: integer, minimum: 0, maximum: 6 } - intervalWeeks: - type: integer - minimum: 1 - maximum: 4 - required: [windowStart, windowEnd] - - CreateMedicationInput: - type: object - properties: - name: { type: string, minLength: 1, maxLength: 100 } - dose: { type: string, minLength: 1, maxLength: 50 } - category: { $ref: "#/components/schemas/MedicationCategory" } - schedules: - type: array - minItems: 1 - items: { $ref: "#/components/schemas/MedicationScheduleInput" } - required: [name, dose, schedules] - - UpdateMedicationInput: - type: object - properties: - name: { type: string, minLength: 1, maxLength: 100 } - dose: { type: string, minLength: 1, maxLength: 50 } - category: { $ref: "#/components/schemas/MedicationCategory" } - active: { type: boolean } - notificationsEnabled: { type: boolean } - schedules: - type: array - items: { $ref: "#/components/schemas/MedicationScheduleInput" } - - MedicationIntakeEvent: - type: object - properties: - id: { type: string } - userId: { type: string } - medicationId: { type: string } - scheduledFor: { type: string, format: date-time } - takenAt: { type: [string, "null"], format: date-time } - skipped: { type: boolean } - source: { $ref: "#/components/schemas/IntakeSource" } - idempotencyKey: { type: [string, "null"] } - createdAt: { type: string, format: date-time } - required: - [id, userId, medicationId, scheduledFor, skipped, source, createdAt] - - CreateIntakeInput: - type: object - properties: - scheduledFor: { type: string, format: date-time } - takenAt: { type: string, format: date-time } - skipped: { type: boolean, default: false } - idempotencyKey: { type: string, maxLength: 128 } - - UpdateIntakeInput: - type: object - properties: - takenAt: { type: [string, "null"], format: date-time } - skipped: { type: boolean } - scheduledFor: { type: string, format: date-time } - - IntakeImportEntry: - type: object - properties: - datum: - type: string - pattern: '^\d{4}-\d{2}-\d{2}$' - description: YYYY-MM-DD - uhrzeit: - type: string - pattern: '^\d{2}:\d{2}:\d{2}$' - description: HH:mm:ss - zaehler: - oneOf: - - { type: integer } - - { type: string } - required: [datum, uhrzeit] - - IntakeImportResult: - type: object - properties: - imported: { type: integer } - skippedDuplicates: { type: integer } - skippedInvalid: { type: integer } - - ExternalIntakeInput: - type: object - properties: - medicationName: { type: string, maxLength: 200 } - takenAt: { type: string, format: date-time } - idempotencyKey: { type: string, maxLength: 128 } - required: [medicationName, idempotencyKey] - - DailyComplianceEntry: - type: object - properties: - expected: { type: integer } - taken: { type: integer } - skipped: { type: integer } - onTime: { type: integer } - late: { type: integer } - veryLate: { type: integer } - - ComplianceSummary: - type: object - properties: - rate: { type: number, description: "Fraction 0..1" } - taken: { type: integer } - skipped: { type: integer } - missed: { type: integer } - streak: { type: integer } - - MedicationComplianceResponse: - type: object - properties: - compliance7: { $ref: "#/components/schemas/ComplianceSummary" } - compliance30: { $ref: "#/components/schemas/ComplianceSummary" } - dailyCompliance: - type: object - additionalProperties: - { $ref: "#/components/schemas/DailyComplianceEntry" } - - ReminderPhaseConfig: - type: object - properties: - greenValue: { type: integer, default: 60 } - greenMode: - { - allOf: [{ $ref: "#/components/schemas/PhaseMode" }], - default: MINUTES, - } - yellowValue: { type: integer, default: 30 } - yellowMode: - { - allOf: [{ $ref: "#/components/schemas/PhaseMode" }], - default: MINUTES, - } - orangeValue: { type: integer, default: 0 } - orangeMode: - { - allOf: [{ $ref: "#/components/schemas/PhaseMode" }], - default: MINUTES, - } - redValue: { type: integer, default: 240 } - redMode: - { - allOf: [{ $ref: "#/components/schemas/PhaseMode" }], - default: MINUTES, - } - - MedicationApiEndpointResponse: - type: object - properties: - enabled: { type: boolean } - activeTokenCount: { type: integer } - token: - type: [string, "null"] - description: "Plaintext token, only returned once on creation." - created: { type: boolean } - revokedTokenCount: { type: integer } - - IntakeSummaryPoint: - type: object - properties: - date: - type: string - description: YYYY-MM-DD (Berlin local). - total: { type: integer } - medications: - type: object - additionalProperties: { type: integer } - required: [date, total] - - IntakeSummaryResponse: - type: object - properties: - points: - type: array - items: { $ref: "#/components/schemas/IntakeSummaryPoint" } - medications: - type: array - items: { type: string } - - # ─── Mood DTOs ───────────────────────────────────────────── - MoodEntry: - type: object - properties: - id: { type: string } - userId: { type: string } - date: - type: string - description: YYYY-MM-DD - mood: { $ref: "#/components/schemas/MoodLevel" } - score: - type: integer - minimum: 1 - maximum: 5 - tags: - type: array - items: { type: string } - source: { type: string, default: MANUAL } - moodLoggedAt: { type: string, format: date-time } - syncedAt: { type: string, format: date-time } - createdAt: { type: string, format: date-time } - updatedAt: { type: string, format: date-time } - required: [id, userId, date, mood, score, source, moodLoggedAt] - - CreateMoodEntryInput: - type: object - properties: - mood: { $ref: "#/components/schemas/MoodLevel" } - moodLoggedAt: { type: string, format: date-time } - tags: - type: array - items: { type: string } - source: { type: string } - required: [mood, moodLoggedAt] - - UpdateMoodEntryInput: - type: object - properties: - mood: { $ref: "#/components/schemas/MoodLevel" } - moodLoggedAt: { type: string, format: date-time } - tags: - type: array - items: { type: string } - - MoodAnalytics: - type: object - properties: - entries: - type: array - items: - type: object - properties: - date: { type: string } - score: { type: number } - samples: { type: integer } - summary: - type: object - additionalProperties: true - - # ─── Dashboard DTOs ──────────────────────────────────────── - DashboardWidget: - type: object - properties: - id: - type: string - enum: - [ - weight, - bp, - pulse, - bodyFat, - mood, - medications, - sleep, - steps, - glucose, - bpInTarget, - ] - visible: { type: boolean } - order: { type: integer, minimum: 0, maximum: 99 } - required: [id, visible, order] - - DashboardLayout: - type: object - properties: - version: { type: integer, enum: [1] } - widgets: - type: array - minItems: 1 - maxItems: 20 - items: { $ref: "#/components/schemas/DashboardWidget" } - required: [version, widgets] - - # ─── Insights DTOs ───────────────────────────────────────── - Classification: - type: object - properties: - category: { type: string } - color: { type: string } - severity: { $ref: "#/components/schemas/InsightSeverity" } - - SummaryStats: - type: object - properties: - latest: { type: [number, "null"] } - avg7: { type: [number, "null"] } - avg30: { type: [number, "null"] } - min: { type: [number, "null"] } - max: { type: [number, "null"] } - count: { type: integer } - slope30: - type: [object, "null"] - properties: - slope: { type: number } - r2: { type: number } - anomalyCount: { type: integer } - - CorrelationResult: - type: [object, "null"] - properties: - r: { type: number } - strength: { type: string } - n: { type: integer } - - Alert: - type: object - properties: - severity: { $ref: "#/components/schemas/InsightSeverity" } - message: { type: string } - type: { type: string } - - BpTargets: - type: [object, "null"] - properties: - sysLow: { type: integer } - sysHigh: { type: integer } - diaLow: { type: integer } - diaHigh: { type: integer } - - ComprehensiveInsights: - type: object - properties: - summaries: - type: object - additionalProperties: { $ref: "#/components/schemas/SummaryStats" } - bmi: { type: [number, "null"] } - bmiClassification: - oneOf: - - $ref: "#/components/schemas/Classification" - - type: "null" - bpClassification: - oneOf: - - $ref: "#/components/schemas/Classification" - - type: "null" - bpPctInTarget: { type: [integer, "null"] } - bpTargets: { $ref: "#/components/schemas/BpTargets" } - weightBpCorrelation: { $ref: "#/components/schemas/CorrelationResult" } - scatterData: - type: array - items: - type: object - properties: - weight: { type: number } - sysBP: { type: number } - bpMedicationCorrelation: - type: [object, "null"] - properties: - r: { type: number } - strength: { type: string } - n: { type: integer } - medicationCount: { type: integer } - bpMedicationScatterData: - type: array - items: - type: object - properties: - continuityPct: { type: integer } - sysBP: { type: number } - moodSummary: - oneOf: - - $ref: "#/components/schemas/SummaryStats" - - type: "null" - moodBpCorrelation: { $ref: "#/components/schemas/CorrelationResult" } - moodBpScatterData: - type: array - items: - type: object - properties: - mood: { type: number } - sysBP: { type: number } - moodWeightCorrelation: - { $ref: "#/components/schemas/CorrelationResult" } - moodWeightScatterData: - type: array - items: - type: object - properties: - mood: { type: number } - weight: { type: number } - moodPulseCorrelation: { $ref: "#/components/schemas/CorrelationResult" } - moodPulseScatterData: - type: array - items: - type: object - properties: - mood: { type: number } - pulse: { type: number } - medications: - type: array - items: - type: object - properties: - id: { type: string } - name: { type: string } - dose: { type: string } - category: { $ref: "#/components/schemas/MedicationCategory" } - compliance7: { type: number } - compliance30: { type: number } - streak: { type: integer } - taken7: { type: integer } - skipped7: { type: integer } - missed7: { type: integer } - alerts: - type: array - items: { $ref: "#/components/schemas/Alert" } - hasProvider: { type: boolean } - dataSpanDays: { type: integer } - totalMeasurements: { type: integer } - - InsightTargetItem: - type: object - properties: - type: { type: string } - label: { type: string } - current: { type: [number, "null"] } - average30: { type: [number, "null"] } - trend: { $ref: "#/components/schemas/Trend" } - unit: { type: string } - range: - type: [object, "null"] - properties: - min: { type: number } - max: { type: number } - classification: - oneOf: - - $ref: "#/components/schemas/Classification" - - type: "null" - source: { type: string } - details: - type: object - properties: - medications: - type: array - items: - type: object - properties: - name: { type: string } - compliance7: { type: number } - compliance30: { type: number } - - InsightTargetsResponse: - type: object - properties: - targets: - type: array - items: { $ref: "#/components/schemas/InsightTargetItem" } - bpDiastolic: - type: object - properties: - current: { type: [number, "null"] } - average30: { type: [number, "null"] } - range: - type: [object, "null"] - properties: - min: { type: number } - max: { type: number } - profile: - type: object - properties: - heightCm: { type: [number, "null"] } - age: { type: [integer, "null"] } - gender: - oneOf: - - $ref: "#/components/schemas/Gender" - - type: "null" - glucoseUnit: { type: string } - - InsightStatusBundle: - type: object - description: Narrative status payload (shape varies per status route). - additionalProperties: true - - InsightGenerateResult: - type: object - properties: - insights: - type: object - additionalProperties: true - cached: { type: boolean } - cachedAt: { type: string, format: date-time } - - InsightsSettings: - type: object - properties: - codexStatus: { type: string, enum: [connected, disconnected] } - codexConnectedAt: { type: [string, "null"], format: date-time } - hasAdminKey: { type: boolean } - privacyMode: { type: string, enum: [aggregated, raw] } - lastInsightAt: { type: [string, "null"], format: date-time } - - # ─── Achievements ────────────────────────────────────────── - Achievement: - type: object - properties: - id: { type: string } - title: { type: string } - description: { type: string } - target: { type: integer } - progress: { type: integer } - unlocked: { type: boolean } - completedAt: - type: [string, "null"] - format: date-time - - AchievementsResult: - type: object - properties: - achievements: - type: array - items: { $ref: "#/components/schemas/Achievement" } - unlockedCount: { type: integer } - totalCount: { type: integer } - - # ─── Doctor report ───────────────────────────────────────── - DoctorReportResponse: - type: object - properties: - period: - type: object - properties: - days: { type: integer } - since: { type: string, format: date-time } - patient: - type: object - properties: - username: { type: [string, "null"] } - dateOfBirth: { type: [string, "null"], format: date-time } - gender: - oneOf: - - $ref: "#/components/schemas/Gender" - - type: "null" - heightCm: { type: [number, "null"] } - measurements: - type: object - additionalProperties: - type: array - items: - type: object - properties: - value: { type: number } - measuredAt: { type: string, format: date-time } - stats: - type: object - additionalProperties: - type: object - properties: - avg: { type: number } - min: { type: number } - max: { type: number } - count: { type: integer } - latest: { type: number } - glucoseStats: - type: object - additionalProperties: - type: object - properties: - avg: { type: number } - min: { type: number } - max: { type: number } - count: { type: integer } - latest: { type: number } - glucoseRanges: - type: object - additionalProperties: - type: object - properties: - min: { type: number } - max: { type: number } - glucoseUnit: { type: string } - bmi: { type: [number, "null"] } - compliance: - type: object - additionalProperties: - type: object - properties: - total: { type: integer } - taken: { type: integer } - skipped: { type: integer } - missed: { type: integer } - medications: - type: array - items: - type: object - properties: - name: { type: string } - dose: { type: string } - schedules: - type: array - items: - type: object - properties: - windowStart: { type: string } - windowEnd: { type: string } - label: { type: [string, "null"] } - mood: - type: [object, "null"] - properties: - avg: { type: number } - min: { type: number } - max: { type: number } - count: { type: integer } - distribution: - type: object - additionalProperties: { type: integer } - - # ─── Onboarding ──────────────────────────────────────────── - OnboardingInput: - type: object - properties: - heightCm: { type: number, minimum: 50, maximum: 300 } - dateOfBirth: { type: string, format: date } - gender: { $ref: "#/components/schemas/Gender" } - - # ─── Health probe ────────────────────────────────────────── - HealthStatus: - type: object - properties: - status: { type: string, enum: [ok, degraded] } - timestamp: { type: string, format: date-time } - database: { type: string } - worker: { type: string } - workerLastHeartbeat: { type: string, format: date-time } - - # ─── Tokens ──────────────────────────────────────────────── - ApiTokenSummary: - type: object - properties: - id: { type: string } - name: { type: string } - permissions: - type: array - items: { type: string } - lastUsedAt: { type: [string, "null"], format: date-time } - expiresAt: { type: [string, "null"], format: date-time } - createdAt: { type: string, format: date-time } - revoked: { type: boolean } - - CreateTokenInput: - type: object - properties: - name: { type: string, minLength: 1, maxLength: 100 } - expiresInDays: { type: integer, minimum: 1, maximum: 365 } - required: [name] - - # ─── Notifications ───────────────────────────────────────── - NotificationChannel: - type: object - properties: - id: { type: string } - type: { $ref: "#/components/schemas/NotificationChannelType" } - label: { type: string } - enabled: { type: boolean } - globallyEnabled: { type: boolean } - - NotificationPreference: - type: object - properties: - channelId: { type: string } - eventType: { type: string } - enabled: { type: boolean } - - NotificationPreferencesResponse: - type: object - properties: - channels: - type: array - items: { $ref: "#/components/schemas/NotificationChannel" } - preferences: - type: array - items: { $ref: "#/components/schemas/NotificationPreference" } - eventTypes: - type: array - items: { type: string } - - UpdateNotificationPreferenceInput: - type: object - properties: - channelId: { type: string } - eventType: { type: string } - enabled: { type: boolean } - required: [channelId, eventType, enabled] - - WebPushSubscribeInput: - type: object - properties: - endpoint: { type: string, format: uri } - keys: - type: object - properties: - p256dh: { type: string } - auth: { type: string } - required: [p256dh, auth] - required: [endpoint, keys] - - # ─── User config ─────────────────────────────────────────── - UserAiProvider: - type: object - properties: - provider: - oneOf: - - $ref: "#/components/schemas/AiProviderType" - - type: "null" - model: { type: [string, "null"] } - baseUrl: { type: [string, "null"] } - hasAnthropicKey: { type: boolean } - anthropicKeyPreview: { type: [string, "null"] } - hasLocalKey: { type: boolean } - - UpdateUserAiProviderInput: - type: object - properties: - provider: - oneOf: - - $ref: "#/components/schemas/AiProviderType" - - type: "null" - model: { type: [string, "null"] } - baseUrl: { type: [string, "null"] } - anthropicKey: { type: [string, "null"] } - localKey: { type: [string, "null"] } - - ThresholdRange: - type: object - properties: - greenMin: { type: number } - greenMax: { type: number } - orangeMin: { type: number } - orangeMax: { type: number } - redMin: { type: number } - redMax: { type: number } - - EffectiveRange: - type: object - properties: - range: - oneOf: - - $ref: "#/components/schemas/ThresholdRange" - - type: "null" - isOverride: { type: boolean } - defaultRange: - oneOf: - - $ref: "#/components/schemas/ThresholdRange" - - type: "null" - - ThresholdOverrides: - type: object - description: "Map of metric name → `{ min, max }` override." - additionalProperties: - type: object - properties: - min: { type: number } - max: { type: number } - - ThresholdsResponse: - type: object - properties: - effective: - type: object - additionalProperties: { $ref: "#/components/schemas/EffectiveRange" } - overrides: { $ref: "#/components/schemas/ThresholdOverrides" } - - # ─── Analytics ───────────────────────────────────────────── - AnalyticsResponse: - type: object - properties: - summaries: - type: object - additionalProperties: { $ref: "#/components/schemas/SummaryStats" } - bmi: { type: [number, "null"] } - bpInTargetPct: { type: [integer, "null"] } - glucoseByContext: - type: object - additionalProperties: { $ref: "#/components/schemas/SummaryStats" } - - AiTestResult: - type: object - properties: - ok: { type: boolean } - providerType: { $ref: "#/components/schemas/AiProviderType" } - model: { type: string } - tokensUsed: - type: object - properties: - input: { type: integer } - output: { type: integer } - sample: { type: string } - - # ─── Export / Import ─────────────────────────────────────── - ImportInput: - type: object - properties: - measurements: - type: array - maxItems: 10000 - items: - type: object - properties: - type: { $ref: "#/components/schemas/MeasurementType" } - value: { type: number } - unit: { type: string } - measuredAt: { type: string, format: date-time } - source: { type: string } - notes: { type: string } - required: [type, value, unit, measuredAt] - moodEntries: - type: array - maxItems: 10000 - items: - type: object - properties: - date: { type: string } - mood: { $ref: "#/components/schemas/MoodLevel" } - score: { type: integer, minimum: 1, maximum: 5 } - tags: { type: string } - loggedAt: { type: string, format: date-time } - required: [date, mood, score] - - ImportResult: - type: object - properties: - measurements: { type: integer } - moodEntries: { type: integer } - skipped: { type: integer } - - # ─── Audit log ───────────────────────────────────────────── - AuditLogEntry: - type: object - properties: - id: { type: string } - action: { type: string } - ipAddress: { type: [string, "null"] } - location: { type: [string, "null"] } - details: { type: [string, "null"], description: "JSON string" } - createdAt: { type: string, format: date-time } - - AuditLogResponse: - type: object - properties: - entries: - type: array - items: { $ref: "#/components/schemas/AuditLogEntry" } - meta: { $ref: "#/components/schemas/PageMeta" } - - # ─── Feedback ────────────────────────────────────────────── - FeedbackCategory: - type: string - enum: [BUG, FEATURE_REQUEST, QUESTION, OTHER] - - FeedbackStatus: - type: string - enum: [OPEN, ACKNOWLEDGED, RESOLVED, ARCHIVED] - - FeedbackSummary: - type: object - properties: - id: { type: string } - category: { $ref: "#/components/schemas/FeedbackCategory" } - subject: { type: string } - status: { $ref: "#/components/schemas/FeedbackStatus" } - adminNote: { type: [string, "null"] } - gitHubIssueUrl: { type: [string, "null"], format: uri } - createdAt: { type: string, format: date-time } - updatedAt: { type: string, format: date-time } - - CreateFeedbackInput: - type: object - properties: - category: { $ref: "#/components/schemas/FeedbackCategory" } - subject: { type: string } - description: { type: string } - screenshot: - type: string - description: "Base64-encoded PNG/JPG (≤5 MB)." - metadata: - type: object - additionalProperties: true - required: [category, subject, description] - - BugReportInput: - type: object - properties: - description: { type: string, minLength: 10, maxLength: 5000 } - screenshot: - type: string - description: "`data:image/(png|jpeg|webp|gif);base64,...` data URL." - required: [description] - - # ─── Settings ────────────────────────────────────────────── - GlobalServicesFlags: - type: object - properties: - telegram: { type: boolean } - ntfy: { type: boolean } - webPush: { type: boolean } - api: { type: boolean } - moodLog: { type: boolean } - - MoodLogSettingsInput: - type: object - properties: - url: { type: [string, "null"], format: uri } - apiKey: { type: [string, "null"] } - enabled: { type: boolean } - webhookSecret: { type: [string, "null"] } - - NtfySettings: - type: object - properties: - configured: { type: boolean } - enabled: { type: boolean } - topic: { type: [string, "null"] } - serverUrl: { type: [string, "null"] } - - NtfySettingsInput: - type: object - properties: - topic: { type: [string, "null"] } - serverUrl: { type: [string, "null"], format: uri } - enabled: { type: boolean } - accessToken: { type: [string, "null"] } - - TelegramSettings: - type: object - properties: - configured: { type: boolean } - enabled: { type: boolean } - chatId: { type: [string, "null"] } - - TelegramSettingsInput: - type: object - properties: - botToken: { type: [string, "null"] } - chatId: { type: [string, "null"] } - enabled: { type: boolean } - - ReminderThresholds: - type: object - properties: - reminderLateMinutes: { type: integer } - reminderMissedMinutes: { type: integer } - - # ─── Withings + moodLog status ───────────────────────────── - WithingsCredentialsInput: - type: object - properties: - clientId: { type: string } - clientSecret: { type: string } - required: [clientId, clientSecret] - - WithingsStatus: - type: object - properties: - connected: { type: boolean } - configured: { type: boolean } - lastSyncedAt: { type: [string, "null"], format: date-time } - connectedAt: { type: string, format: date-time } - tokenExpired: { type: boolean } - tokenRefreshFailed: { type: boolean } - tokenExpiresAt: { type: string, format: date-time } - - MoodLogStatus: - type: object - properties: - configured: { type: boolean } - enabled: { type: boolean } - lastSyncedAt: { type: [string, "null"], format: date-time } - entryCount: { type: integer } - webhookSecret: { type: [string, "null"] } - - PublicMonitoringSettings: - type: object - properties: - umamiEnabled: { type: boolean } - umamiScriptUrl: { type: [string, "null"] } - umamiWebsiteId: { type: [string, "null"] } - glitchtipEnabled: { type: boolean } - glitchtipDsn: { type: [string, "null"] } - glitchtipEnvironment: { type: [string, "null"] } - - # ─── iOS adapter DTOs (v1.3) ────────────────────────────── - # Used by /api/dashboard/summary (`MetricCard.kind`) and the - # /api/measurements/series adapter. Extended in v1.3.3 to cover the - # body-composition + SpO2 measurements that the server already emits. - # iOS clients SHOULD decode unknown values defensively; we still - # publish the canonical list here so generated codecs stay accurate. - MetricKind: - type: string - enum: - - weight - - bloodPressure - - pulse - - bodyFat - - glucose - - sleep - - steps - - totalBodyWater - - boneMass - - oxygenSaturation - - InsightSeverityIos: - type: string - enum: [alert, caution, info, good] - - DashboardSummaryGreeting: - type: object - properties: - salutation: { type: string } - date: { type: string, format: date-time } - required: [salutation, date] - - DashboardSummaryStreak: - type: object - properties: - currentDays: { type: integer } - longest: { type: integer } - label: { type: string } - required: [currentDays, longest, label] - - DashboardSummaryCompliance: - type: object - properties: - scheduledToday: { type: integer } - takenToday: { type: integer } - required: [scheduledToday, takenToday] - - DashboardMetricCard: - type: object - properties: - id: { type: string } - kind: { $ref: "#/components/schemas/MetricKind" } - title: { type: string } - latestValue: { type: [number, "null"] } - secondaryValue: { type: [number, "null"] } - unit: { type: string } - trend: - type: string - enum: [up, down, flat, unknown] - sparkline: - type: array - items: { type: number } - updatedAt: { type: [string, "null"], format: date-time } - required: [id, kind, title, unit, trend, sparkline] - - DashboardSummary: - type: object - properties: - greeting: { $ref: "#/components/schemas/DashboardSummaryGreeting" } - streak: { $ref: "#/components/schemas/DashboardSummaryStreak" } - compliance: { $ref: "#/components/schemas/DashboardSummaryCompliance" } - highlightInsight: - oneOf: - - { type: "null" } - - { $ref: "#/components/schemas/InsightCard" } - metrics: - type: array - items: { $ref: "#/components/schemas/DashboardMetricCard" } - lastUpdated: { type: string, format: date-time } - required: [greeting, streak, compliance, metrics, lastUpdated] - - MeasurementSeriesPoint: - type: object - properties: - id: { type: string } - at: { type: string, format: date-time } - value: { type: number } - secondary: { type: [number, "null"] } - required: [id, at, value] - - MeasurementSeriesStats: - type: object - properties: - mean: { type: number } - min: { type: number } - max: { type: number } - stdDev: { type: number } - count: { type: integer } - required: [mean, min, max, stdDev, count] - - MeasurementSeries: - type: object - properties: - kind: { $ref: "#/components/schemas/MetricKind" } - points: - type: array - items: { $ref: "#/components/schemas/MeasurementSeriesPoint" } - stats: { $ref: "#/components/schemas/MeasurementSeriesStats" } - required: [kind, points, stats] - - HealthKitDirection: - type: string - enum: [bidirectional, readOnly, writeOnly, disabled] - - HealthKitEntry: - type: object - properties: - id: { type: string } - kind: { type: string } - direction: { $ref: "#/components/schemas/HealthKitDirection" } - enabled: { type: boolean } - required: [id, kind, direction, enabled] - - HealthKitConfig: - type: object - properties: - entries: - type: array - items: { $ref: "#/components/schemas/HealthKitEntry" } - lastSyncedAt: { type: [string, "null"], format: date-time } - required: [entries] - - HealthKitConfigPatch: - type: object - properties: - entries: - type: array - maxItems: 50 - items: - type: object - properties: - id: { type: string } - kind: { type: string } - direction: { $ref: "#/components/schemas/HealthKitDirection" } - enabled: { type: boolean } - required: [id, direction] - required: [entries] - - IosUserProfile: - type: object - properties: - username: { type: string } - displayName: { type: [string, "null"] } - email: { type: [string, "null"], format: email } - dateOfBirth: { type: [string, "null"], format: date-time } - gender: { $ref: "#/components/schemas/Gender" } - heightCm: { type: [number, "null"] } - locale: { type: [string, "null"] } - timezone: { type: string } - required: [username, timezone] - - IosUserProfilePatch: - type: object - properties: - displayName: { type: [string, "null"], minLength: 1, maxLength: 80 } - email: { type: [string, "null"], format: email } - dateOfBirth: { type: [string, "null"] } - gender: - oneOf: - - { type: "null" } - - { $ref: "#/components/schemas/Gender" } - heightCm: { type: [number, "null"], minimum: 50, maximum: 300 } - locale: - type: [string, "null"] - enum: [de, en, null] - timezone: { type: string, minLength: 1, maxLength: 64 } - - DeviceRegisterInput: - type: object - properties: - token: { type: string, minLength: 8, maxLength: 512 } - bundleId: { type: string, minLength: 1, maxLength: 128 } - locale: { type: string } - appVersion: { type: string } - model: { type: string } - required: [token, bundleId] - - IntakeUpdateInput: - type: object - properties: - intakeId: { type: string } - status: - type: string - enum: [taken, skipped, snoozed] - takenAt: { type: string, format: date-time } - snoozedUntil: { type: string, format: date-time } - required: [intakeId, status] - - InsightRecommendation: - type: object - properties: - id: { type: string } - label: { type: string } - actionURL: { type: [string, "null"] } - required: [id, label] - - InsightCard: - type: object - properties: - id: { type: string } - title: { type: string } - summary: { type: string } - body: { type: [string, "null"] } - severity: { $ref: "#/components/schemas/InsightSeverityIos" } - recommendations: - type: array - items: { $ref: "#/components/schemas/InsightRecommendation" } - generatedAt: { type: string, format: date-time } - provider: { type: string } - required: - [id, title, summary, severity, recommendations, generatedAt, provider] - - InsightCorrelation: - type: object - properties: - id: { type: string } - leftMetric: { type: string } - rightMetric: { type: string } - coefficient: { type: number } - sampleSize: { type: integer } - required: [id, leftMetric, rightMetric, coefficient, sampleSize] - - # ─── Admin DTOs ──────────────────────────────────────────── - AdminUpdateUserInput: - type: object - properties: - username: { type: string, minLength: 3, maxLength: 30 } - email: { type: [string, "null"], format: email } - role: { $ref: "#/components/schemas/UserRole" } diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3304d1d49..8fbc7206a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.4.23 + version: 1.5.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/docs/audit/2026-04-26-PLAN.md b/docs/audit/2026-04-26-PLAN.md deleted file mode 100644 index 634ec03e8..000000000 --- a/docs/audit/2026-04-26-PLAN.md +++ /dev/null @@ -1,246 +0,0 @@ -# HealthLog Audit-Plan — Phasen-Übersicht - -**Datum:** 2026-04-26 -**Audit-Bericht:** `~/infra/reports/healthlog-audit-2026-04-26.md` -**Status:** Geplant, noch nicht in Execution - -## Strategische Decisions - -1. **Onboarding-Redesign:** 4-Step-Wizard → 2-Step + Empty-State-Dashboard + Getting-Started-Card. -2. **Custom-Measurement-Types:** Pragmatic Hardcoding (jetzt #89), Cronometer-Pattern als v1.5+-Roadmap. -3. **`apiClient`-Refactor vor Onboarding-Redesign**, damit neues Onboarding nicht denselben Silent-Failure-Bug reproduziert. - ---- - -## Phasen-Sequenz (10 Phasen) - -| # | Phase | Skill | Effort | Issue-Bezug | -| ------- | ------------------------------------------------------ | --------------------------------------- | ------ | ------------------- | -| **1** | Setup-Notfall + Versions-Sync | `gsd:quick` | ≤4 h | #87 (Pflaster), #88 | -| **2** | Docker-Image-Pipeline (GHCR-Push + Doc-Konsolidierung) | `gsd:plan-phase` | ~1 d | #88 | -| **3** | Issue #89 — TBW + Bone Mass hardcoded | `gsd:plan-phase` | ~1 d | #89 | -| **4** | `apiClient`-Wrapper + geteilte Zod-Schemas | `gsd:research-phase` → `gsd:plan-phase` | 2-3 d | #87 (strukturell) | -| **3.5** | **Onboarding-Redesign** (2 Steps, TTFV <60s) | `gsd:research-phase` → `gsd:plan-phase` | ~2 d | #87 (strukturell) | -| **4.5** | Empty-State-Dashboard-Tiles (eine pro Type) | `gsd:plan-phase` | ~1 d | #87, #89 | -| **4.6** | „Getting Started"-Checklist auf Dashboard | `gsd:plan-phase` | ~4 h | #87 | -| **5** | Security + Reliability Hardening | `gsd:plan-phase` | 1-2 d | — | -| **6** | Test-Coverage-Boost (Auth/Passkey/Withings/pg-boss) | `gsd:plan-phase` | 1-2 d | — | -| **7** | UX/i18n/a11y Polish (Residuals aus #73) | `gsd:quick` | ≤4 h | #73 (Residuals) | -| **8** | Settings-Page-Splitting (3143 → Sub-Components) | `gsd:research-phase` → `gsd:plan-phase` | 2-3 d | — | - -**Reihenfolge:** 1 → 2 → 3 → **4 (apiClient)** → **3.5 (Onboarding nutzt apiClient)** → 4.5 → 4.6 → 5 → 6 → 7 → 8. - ---- - -## Phase 1 — Setup-Notfall (≤4 h) - -**Goal:** Self-Hoster kann mit `git clone && docker compose up -d` tatsächlich starten + Issue #87 stoppt zu blocken. - -- `docker-compose.yml`: `expose:3000` → `ports: "3000:3000"` (DX-002) -- `docker-compose.yml`: `POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?required}"` (SEC-003) -- `.env.example`: `localhost:5432` → `db:5432` (DX-003) -- `package.json`: `"version": "1.2.0"` (DOC-007) -- `CLAUDE.md` + `AGENTS.md`: 23 Models statt 21/22 (DOC-001) -- `src/app/onboarding/page.tsx`: `await fetch(...)` mit `.ok`-Check + `toast.error()` (DX-005 Pflaster) -- README „minutes"-Claim entschärfen (DX-006) - -**Verification:** Frischer Clone + neue VM → `cp .env.example .env && set secrets && docker compose up -d` → App auf `http://localhost:3000` erreichbar in <5 min. - ---- - -## Phase 2 — Docker-Image-Pipeline (~1 d) - -**Goal:** Issue #88 vollständig schließen. Pre-built Image auf GHCR. Docs konsolidiert. - -- `.github/workflows/docker-publish.yml`: Multi-Arch (amd64+arm64), Trigger auf Tag-Push + main, GHCR-Push mit GITHUB_TOKEN -- `healthlog-docs/getting-started/installation.mdx` → Verweis auf `self-hosting/docker.mdx` statt Duplikat (DX-004) -- Repo-`docker-compose.yml` vs Docs-`docker-compose.yml`: eine Variante als Single-Source-of-Truth (Compose verwendet `image:` aus GHCR; Doc-Snippet wird daraus generiert) -- `SECURITY.md` + Image-Pull-Instructions -- `healthlog-landing` prüfen ob „Quick Start" aktualisiert werden muss - ---- - -## Phase 3 — Issue #89: TBW + Bone Mass (~1 d) - -**Goal:** User-Anfrage von Manschk3rl beantworten. Blueprint = v1.2-Glucose-Migration. - -**Touchpoints:** - -1. `prisma/schema.prisma`: `MeasurementType` Enum + 2 neue Werte -2. `prisma/migrations/0019_*` (additive `ALTER TYPE ... ADD VALUE`) -3. `src/lib/validations/measurement.ts`: `unitMap`, `VALUE_RANGES` -4. `src/lib/format.ts` + i18n-Keys (4-6 neue Keys) -5. `src/lib/doctor-report-pdf.ts`: `TYPE_LABEL_KEYS`, `TYPE_UNIT_KEYS`, `vitalTypes` -6. `src/components/measurements/measurement-form.tsx`: `MEASUREMENT_TYPES` Array -7. `src/lib/withings/client.ts` + `sync.ts`: Mapping aus Withings `body_composition` -8. `src/app/api/dashboard/widgets/route.ts` + `DEFAULT_DASHBOARD_LAYOUT`: 2 neue Widget-IDs -9. healthlog-docs `health-metrics.mdx` aktualisieren -10. Optional: `/api/ingest/measurements` Bearer-Endpoint für n8n (analog `/api/ingest/medication`) - ---- - -## Phase 4 — `apiClient`-Wrapper (2-3 d) - -**Goal:** Silent-Failure-Pattern strukturell eliminieren. Voraussetzung für 3.5. - -- `src/lib/api-client.ts`: `apiClient.{get,post,put,delete}(path, body?, schema?) → Result` -- Auto-`.ok`-Check + Toast bei Fehler (mit i18n-Mapping) -- Geteilte Zod-Schemas: Validations-Module in `src/lib/validations/` werden client-importable -- Migration: alle ~50 `fetch(...)`-Aufrufe in `src/app/` und `src/components/` → `apiClient.*` -- Tests für `apiClient` (Mock-Fetch, Error-Mapping, Schema-Validation) - ---- - -## Phase 3.5 — Onboarding-Redesign (~2 d) - -**Goal:** TTFV <60s, 90% Completion (statt aktuell ~8% global). - -**Neuer Flow:** - -- Step 1: Welcome + Anzeigename (optional) + Einheiten + Größe (optional). Sekundärlink „Skip everything → Dashboard". -- Step 2: Multi-Select-Chips „Was willst du tracken?" (Weight/BP/Pulse/Mood/Glucose/Sleep/Steps/BodyFat/TBW/BoneMass/Medications). Pre-checked Defaults. Speichert in `User.dashboardWidgetsJson`. -- Routing nach Onboarding: direkt zum Dashboard mit „Getting Started"-Card (s. Phase 4.6). - -**Entfällt ersatzlos:** Targets-Step, Notifications-Teaser-Step, Front-loaded-Med-Wizard. - -**Permissions deferred:** Notifications nur bei „Add medication → Reminder ein", PWA-Install nur bei Day-2-Return-Visit oder nach 3. Eintrag. - ---- - -## Phase 4.5 — Empty-State-Dashboard-Tiles (~1 d) - -**Goal:** Mealie/Tandoor/Linear-Pattern. Jede Metric-Tile hat Empty-State mit „Add your first X"-CTA. - -- `src/components/dashboard/EmptyStateTile.tsx` als wiederverwendbare Komponente -- Pro `MeasurementType`: eigene Empty-State-Card (Icon + Headline + CTA-Button → opens MeasurementForm prefilled) -- `Medications` analog: Empty-State „Add your first medication" → opens MedicationForm mit Default-Schedule (08:00–09:00 daily) -- Dashboard-Routing: User mit `dashboardWidgetsJson` ohne Daten sieht Empty-States, nicht leeres Grid - ---- - -## Phase 4.6 — „Getting Started"-Checklist (~4 h) - -**Goal:** Linear-Pattern. Persistent dismissible Card auf Dashboard. - -- `User.gettingStartedDismissedAt: DateTime?` (nullable, NULL = noch sichtbar) -- 4 Items mit Live-Status: - - ☐ Log your first measurement (✓ wenn ≥1 Measurement existiert) - - ☐ Add a medication (✓ wenn ≥1 Medication existiert) - - ☐ Set a target value (✓ wenn `thresholdsJson` non-empty) - - ☐ Install HealthLog as App (✓ wenn `beforeinstallprompt` accepted, sonst nur sichtbar bei PWA-fähigen Browsern) -- „Dismiss"-Button → setzt Timestamp, Card verschwindet -- Vollständig durchgeklickt → Card verschwindet automatisch - ---- - -## Phase 5 — Security + Reliability Hardening (1-2 d) - -- SEC-002: SSRF-IPv4-Parser-Härtung (führende Nullen) -- SEC-004: GitHub-PAT-Logging-Sanitization (Sensitive-Field-Filter) -- SEC-005: AES-256-GCM Key-Rotation-Doc + optional `keyVersion` -- SEC-006: Custom-Thresholds-Rate-Limit -- PERF-001: N+1 in `insights/comprehensive/route.ts:219` per `groupBy` auflösen -- REL-002: Onboarding-Error-Handling über Pflaster hinaus (via apiClient aus Phase 4) -- REL-003: pg-boss `process.on('SIGTERM', ...)` für graceful shutdown -- LOG-001: 6× `console.log()` in `src/lib/jobs/reminder-worker.ts` → Wide-Event-Annotations -- CI: ESLint+Typecheck `continue-on-error: true` → blocking - ---- - -## Phase 6 — Test-Coverage-Boost (1-2 d) - -Coverage 35% → 60% auf kritischen Pfaden. - -**Top-5-Test-Suites:** - -1. Session-Validation Roundtrip (`src/lib/auth/session.ts`) -2. Passkey-Register/Verify-Integration (Attestation-Flow) -3. Notification-Dispatcher Multi-Channel (TELEGRAM/NTFY/WEB_PUSH) -4. pg-boss Reminder-Worker (Queue-Init, Job-Execution, Cron, Retry) -5. Withings-OAuth + Sync (`exchangeCode` → `refresh` → `fetchMeasurements`) - -**Plus:** TEST-006 Custom-Thresholds-Resolution, TEST-007 AI-Provider-SSRF. - ---- - -## Phase 7 — UX/i18n/a11y Polish (≤4 h) - -**Residuals aus Issue #73 + neue Findings:** - -- I18N-002: Admin-Page Hardcoded „Web Push" / „Bug Report" → `t(...)` -- I18N-003: `medication-card.tsx` `Intl.DateTimeFormat("en-CA")` und `toLocaleString("en-US")` → `useFormatters()` -- A11Y-001: `phase-config-dialog.tsx` Text-Indicator neben Farb-Punkt (WCAG 1.4.1) -- A11Y-002: Admin StatusItem `aria-label` verifizieren -- UX-002: Admin-Save-Error-UX (Field-Highlight bei Fehler) -- UX-003: Bottom-Nav-Touch-Targets `p-2.5` → `p-3` (≥44×44 px) - ---- - -## Phase 8 — Settings-Page-Splitting (2-3 d) - -**Goal:** 3143 Zeilen → Sub-Components mit klarer Boundary. - -- Research-Phase: Sub-Routes (`/settings/profile`, `/settings/notifications`, …) vs Sub-Components in einer Page? -- Plan-Phase: Migrationsstrategie ohne URL-Bruch -- Insights-Page (1750 Zeilen) folgt analogem Pattern - ---- - -## Issue-Vorschlags-Abdeckung - -### Issue #87 (Onboarding-Meds verschwinden) - -- **Pflaster:** Phase 1 (`.ok`-Check + Toast) -- **Strukturelle Lösung:** Phase 4 (`apiClient`) + Phase 3.5 (neuer Flow ohne Med-Wizard) + Phase 4.5 (Empty-State-Tiles, Meds direkt sichtbar) + Phase 4.6 (Checklist) - -### Issue #88 (Setup holprig — 4 Hauptpunkte + 3 Verbesserungsvorschläge) - -| #88 Punkt | Phase | Status | -| --------------------------------------------------------------------------- | ----------------------------- | --------- | -| (a) Bundled compose ≠ docs version | Phase 2 (DOC-005) | abgedeckt | -| (b) ghcr.io 404 (kein Image gepusht) | Phase 2 (DX-001 GHA-Workflow) | abgedeckt | -| (c) `.env.example` `localhost:5432` vs Compose `db:5432` | Phase 1 (DX-003) | abgedeckt | -| (d) `expose:3000` statt `ports:3000:3000` | Phase 1 (DX-002) | abgedeckt | -| Vorschlag: GHA für Image-Push (GHCR/Docker Hub) | Phase 2 | abgedeckt | -| Vorschlag: Merge „Getting started > Installation" + „Self-Hosting > Docker" | Phase 2 (DX-004) | abgedeckt | -| Vorschlag: Same presets across compose+docs+.env.example | Phase 1 + Phase 2 | abgedeckt | - -### Issue #89 (TBW + Bone Mass) - -- Phase 3 hardcoded mit Withings-Mapping. Optional `/api/ingest/measurements` für n8n in Phase 3. - -### Issue #73 (UX-Liste mit 8 Punkten — CLOSED in v1.2) - -| #73 Punkt | v1.2-Status | Residual? | -| ----------------------------------------- | ------------------------------------------------- | ------------------------------------------- | -| 1. Passkey-Errors unklar | Resolved (Package C, typed errors) | TEST-002 (keine Tests) → Phase 6 | -| 2. Number-Formatting (DE 70,5 vs US 70.5) | Resolved (Package B, locale-aware formatters) | I18N-003 in `medication-card.tsx` → Phase 7 | -| 3. Chart-Labels „7P/30P/90P/All" | Resolved (Package A, „7 Pkt"/„7 pts") | — | -| 4. Dashboard nicht real-time | Resolved (`invalidateKeys` durchgängig) | — | -| 5. GitHub-Setup-Confusion | Resolved (Built-in Feedback in v1.2) | — | -| 6. Built-in Feedback | Done in v1.2 | — | -| 7. Admin-Save ohne Error-UX | Partial (Success-Msg ja, Error-Highlight schwach) | UX-002 → Phase 7 | -| 8. Blood-Sugar | Done in v1.2 | — | - -### Issue #74 (raw locale keys in UI) - -Resolved in v1.2. - ---- - -## Time-to-First-Value (TTFV) Ziel - -Nach Onboarding-Redesign + Empty-State-Pattern: - -- Login → Dashboard mit Empty-States: 2 Klicks -- Klick auf „Add your first weight" → Form prefilled „now" → Submit: 30 Sek -- **TTFV-Ziel: <60 Sekunden** (Branchenvergleich: führende Health-Apps 30-90s) - -## Anti-Patterns explizit vermieden - -- Permissions-Cold-Prompt (Notifications/PWA) auf Day 1 -- Front-loaded Targets vor erster Messung -- Multi-Step-Wizard mit identischem Skip-Button -- `fetch()` ohne `.ok`-Check (apiClient löst strukturell) -- Mega-Page-Pattern (Phase 8 löst Settings 3143 Z.) -- Custom-Measurement-Types vorzeitig (40-60h Wartung/Jahr für 50% Feature-Loss) diff --git a/docs/audit/v1414-performance.md b/docs/audit/v1414-performance.md deleted file mode 100644 index da6491542..000000000 --- a/docs/audit/v1414-performance.md +++ /dev/null @@ -1,233 +0,0 @@ -# v1.4.14 Performance Audit — Production - -**Status**: stage-4 of the v1.4.14 release cycle -**Measured against**: `https://healthlog.bombeck.io` (`/api/version` → `1.4.13`) -**Measured at**: `2026-05-09T14:14:48Z` → `2026-05-09T14:15:27Z` -**Tool**: standalone Playwright + Chromium 1217 driver, `PerformanceObserver` -(`largest-contentful-paint`, `longtask`) + Resource Timing API + Playwright -`response`-event capture for transferred bytes (Cloudflare strips -`Content-Length` on chunked responses, so the Resource Timing -`transferSize` would otherwise read 0). Script lives at -`/tmp/v15-perf-measure.mjs` (one-shot, not committed — re-run with -`SESSION= node ./.v15-perf-measure.mjs > /tmp/v15-perf-results.json` -inside the repo so the script picks up `node_modules/@playwright/test`). -Two viewports per page: **desktop** (`1920×1080`, default Chromium UA) and -**mobile** (Playwright's `Pixel 5` profile). - -## Method - -For each `(page × viewport)` cell: - -1. Open a fresh BrowserContext with an authenticated session cookie - (`healthlog_session=cmox4d6fj…`) injected pre-navigation. -2. Inject a `PerformanceObserver` via `addInitScript` BEFORE navigation so LCP - and longtask events from the very first paint are captured. -3. `page.goto(url, { waitUntil: "load", timeout: 45_000 })` and then idle for - 3 s so deferred chart code, lazy queries and below-the-fold content settle. -4. Read `performance.getEntriesByType("navigation")[0]`, - `performance.getEntriesByType("resource")`, plus the longtask / LCP - captures we accumulated in `window.__perfState`. -5. Total Blocking Time is the longtask-derived proxy: - `Σ (longtask.duration − 50ms)` over all longtasks ≥ 50 ms. - -The "TTI" column in the original brief is omitted because Chromium's -canonical TTI requires a 5 s quiet-window heuristic that isn't worth -synthesising here — `Load` + `TBT` are the actionable signals. - -## Per-page metrics - -| page | viewport | TTFB ms | DCL ms | Load ms | LCP ms | TBT ms | Longtasks | Total KiB | JS KiB | CSS KiB | -| ------------------------ | -------- | ------: | -----: | ------: | -------: | ------: | --------: | --------: | -----: | ------: | -| `/` | desktop | 396 | 1080 | 2497 | **2828** | **624** | 1 | 604 | 425 | 16 | -| `/` | mobile | 384 | 752 | 1560 | 2184 | 0 | 0 | 604 | 425 | 16 | -| `/settings/integrations` | desktop | 500 | 738 | 1498 | 2396 | 0 | 0 | 394 | 297 | 16 | -| `/settings/integrations` | mobile | 495 | 771 | 1545 | 1900 | 0 | 0 | 386 | 297 | 16 | -| `/admin` | desktop | 584 | 761 | 1611 | 1932 | 0 | 0 | 382 | 291 | 16 | -| `/admin` | mobile | 872 | 1112 | 1920 | 2268 | 0 | 0 | 378 | 291 | 16 | -| `/insights` | desktop | 519 | 681 | 1609 | 2264 | 27 | 1 | 608 | 419 | 16 | -| `/insights` | mobile | 502 | 667 | 1647 | 2380 | 28 | 1 | 604 | 419 | 16 | - -**Read in plain English**: every page comes in well under 2.5 s LCP except -`/` desktop (2.83 s) and `/insights` (~2.3 s). Mobile is faster than desktop -in absolute LCP terms because the Pixel 5 profile uses a smaller viewport -(less LCP candidate area) — the work is the same. - -`/` desktop racks up 624 ms TBT from a single longtask: that's the dashboard -hydration cost for the `useQuery` × 9 + `TrendCard` × 8 + `HealthChart` -dynamic-imports firing in parallel. - -## Top-5 JS bundles per page - -> All sizes are compressed Brotli from CF. The `0vl43uc.17vx1.js` chunk -> appears on every page — that's the framework + Radix + TanStack Query -> bedrock. The `0m4t~kff9equt.js` chunk (52.2 KiB everywhere) is the next.js -> app-router shell + `radix-ui/dialog/dropdown`. The notable per-page chunks -> are flagged with **bold** below. - -### `/` (desktop + mobile, identical) - -- `0503y5tur7j1f.js` — **108.6 KiB** ← Recharts (loaded via `next/dynamic` from `health-chart` / `mood-chart`, but still on the critical path because the dashboard mounts ≥ 1 chart for any user with measurements) -- `0vl43uc.17vx1.js` — 71.3 KiB ← framework + TanStack Query (every page) -- `0m4t~kff9equt.js` — 52.2 KiB ← Next router shell + Radix overlay primitives -- `0~1ef0ki4ccp~.js` — 36.7 KiB ← shared app code (i18n + auth-shell) -- `0uc70mahb3xof.js` — 15.3 KiB ← `dashboard/page.tsx` per-page chunk - -### `/settings/integrations` (desktop + mobile, identical) - -- `0vl43uc.17vx1.js` — 71.3 KiB -- `0m4t~kff9equt.js` — 52.2 KiB -- `0~1ef0ki4ccp~.js` — 36.7 KiB -- `01q.1mkz27ebz.js` — 19.8 KiB ← settings/integrations page -- `0d3x9g6k_smzl.js` — 15.0 KiB - -### `/admin` (desktop + mobile, identical) - -- `0vl43uc.17vx1.js` — 71.3 KiB -- `0m4t~kff9equt.js` — 52.2 KiB -- `0~1ef0ki4ccp~.js` — 36.7 KiB -- `0d3x9g6k_smzl.js` — 15.0 KiB -- `0of675ww0w6~y.js` — 13.6 KiB ← admin status-card-grid - -### `/insights` (desktop + mobile, identical) - -- `18b.35leqj.iy.js` — **108.5 KiB** ← Recharts, eagerly imported because `insights/page.tsx` uses `ScatterChart`/`Scatter`/`XAxis`/`YAxis`/`CartesianGrid`/`Tooltip`/`ResponsiveContainer` symbols at module top-level (line 42–50) -- `0vl43uc.17vx1.js` — 71.3 KiB -- `0m4t~kff9equt.js` — 52.2 KiB -- `0~1ef0ki4ccp~.js` — 36.7 KiB -- `16c~twjymta_z.js` — 15.6 KiB - -## Treemap-flavoured grouping - -| group | KiB (uncompressed est ×3 or measured) | notes | -| -------------------------------------------- | ------------------------------------: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Recharts (per page that uses it) | ~108 KiB | by far the heaviest single dep; v1.4.14 candidate for replacement. v1.4.6 already moved `HealthChart`/`MoodChart` to `next/dynamic`, but `/insights` still imports the Scatter primitives eagerly. | -| Framework (Next.js + React + TanStack Query) | ~71 KiB | unavoidable baseline | -| Radix primitives + app-router shell | ~52 KiB | unavoidable baseline | -| Shared app code (i18n, auth-shell, layout) | ~37 KiB | unavoidable baseline | -| Per-page app code | 13–20 KiB | within budget | - -Pages without charts (`/admin`, `/settings/integrations`) are about 132 KiB -lighter than the chart pages, confirming Recharts is the dominant -single-dependency cost. - -## Slowest API calls observed - -Measured for `/` desktop (worst case). Numbers are the per-request -`PerformanceResourceTiming.duration` (queue + DNS + connect + TTFB + body): - -| endpoint | ms | -| -------------------------------------------------- | --: | -| `/api/measurements?type=WEIGHT&limit=500&offset=0` | 812 | -| `/api/notifications/preferences` | 654 | -| `/api/gamification/achievements` | 651 | -| `/api/measurements?type=PULSE` | 506 | -| `/api/measurements?type=BLOOD_PRESSURE_SYS` | 404 | -| `/api/measurements?type=BLOOD_PRESSURE_DIA` | 399 | -| `/api/mood/analytics` | 351 | -| `/api/analytics` | 342 | -| `/api/medications` | 340 | -| `/api/withings/status` | 303 | - -Two surprises: - -- `notifications/preferences` and `withings/status` are fired by the - `GettingStartedChecklist` even when the user is well past onboarding - (the live tenant has `onboardingCompletedAt` set since early 2026 and - 1000+ measurements). The checklist short-circuits with `null` after - fetching, so the network is wasted. -- `gamification/achievements` is hot-polled every 2 minutes by - `AchievementUnlockNotifier`. Acceptable cadence; not on the win list. - -## Top 3 wins - -### 1. Defer Recharts imports on `/insights` (S, **implemented**) - -- **File**: `src/app/insights/page.tsx` -- **What**: Replace the eager `import { ScatterChart, … } from "recharts"` - with per-symbol `next/dynamic` imports (`ssr: false`). Each scatter card - is below the fold — it sits inside the correlation cards that gate on - `data.bpMedicationScatterData.length >= 5` etc. -- **Expected impact**: Moves the 108.5 KiB Recharts chunk off the initial - bundle. LCP-critical content no longer waits for Recharts to parse on - the main thread. Same Recharts that the dashboard already pays for is - shared at runtime, so no double-fetch on cache-warm navigation. -- **Effort**: ~10 LOC (the seven recharts symbols become seven - `dynamic(...)` consts). -- **Risk**: each first paint of a scatter section will Suspense-fallback - for ~1 frame while the chunk downloads. That matches the existing - `` UX on the same page. - -### 2. Skip `GettingStartedChecklist` data fetches for post-onboarding users (S, **implemented**) - -- **File**: `src/components/onboarding/getting-started-checklist.tsx` -- **What**: Gate `withings/status` and `notifications/preferences` on - `!!user && user.onboardingCompletedAt == null`. The two queries fire - unconditionally today, then the checklist self-hides because the user - has already completed onboarding. -- **Expected impact**: 2 fewer API calls per dashboard load for every - established user. Saves ~950 ms of network on `/` desktop. -- **Effort**: 2 LOC (one extra clause in each `enabled:` field). -- **Risk**: the corner case of a returning user with - `onboardingCompletedAt != null` but `< 5` measurements (e.g. they - deleted readings post-onboarding) will see a checklist with stale - `withingsConnected` / `notificationsConfigured` flags. Acceptable — - the checklist already gracefully renders unknown defaults. - -### 3. Replace Recharts on the dashboard (L, **deferred to v1.4.15**) - -- **What**: Drop Recharts in favour of a smaller library (Visx, Chart.js - 4, or hand-rolled SVG). Recharts is ~108 KiB Brotli for a use case that - needs ~3 chart variants (line, area, scatter). -- **Expected impact**: −80 to −100 KiB initial JS on `/` and `/insights`; - TBT win on `/` desktop because Recharts hydration is the dominant - longtask source. -- **Effort**: L. New dep + every chart in `src/components/charts/` needs - rewrite. Out of scope for v1.4.14 — track in v1.4.15 backlog. -- **Why deferred**: hard rule "no new dependencies in v1.4.14". - -### Bonus / honourable mentions - -- `Achievements` polling cadence on the dashboard is 2 min, staleTime 60 s. - Reasonable, no win. -- `staleTime: 5 * 60 * 1000` is already the global default in - `providers.tsx` — no global tuning needed. -- Image lazy-load: only the `` in `sidebar-nav.tsx` and one - `` in `admin/feedback-inbox-section.tsx` exist. The avatar is - in-viewport; the feedback `` is admin (Stage 4b territory). Skip. - -## Verification - -Local `pnpm build` cannot complete on this machine due to a Node-25 -upstream regression (`Cannot read private member #state from an object -whose class did not declare it` in turbopack's prerender path; this is -the same bug noted in the repo doc's pnpm-build entry). Wins #1 and #2 -therefore can't be re-measured against prod until v1.4.14 ships and -Coolify force-pulls the new bundle. Both wins are nevertheless verified -by static code review: - -- **Win #1 — defer Recharts**: every changed import follows the exact - same `next/dynamic(... ssr: false)` shape that - `src/app/insights/page.tsx` already uses for `HealthChart` and - `MoodChart`. Next 16 / Turbopack splits each `dynamic(...)` callsite - into its own async chunk. Recharts internally re-uses one shared - module so the seven dynamic imports collapse to a single chunk request - at runtime, identical to the existing dashboard behaviour. -- **Win #2 — skip checklist fetches**: `onboardingPending` is read once - per render from `user.onboardingCompletedAt`. When false, both - `useQuery({ enabled: onboardingPending })` calls stay idle. The - downstream `shouldShowChecklist()` already hides the component for - every post-onboarding user with ≥ 5 measurements, so no UI regresses. - `pnpm test` (95 files / 733 tests) is green after the change. - -A re-measure after v1.4.14 ships should show: - -- `/insights` initial JS dropping from ~419 KiB to ~310 KiB (the 108 KiB - Recharts chunk moves out of the critical path and is only fetched if a - scatter section actually mounts). -- `/` dashboard total request count dropping from 9 to 7 for any user - with `onboardingCompletedAt != null`, eliminating the - `notifications/preferences` (~654 ms) and `withings/status` (~303 ms) - calls. - -If post-deploy measurement contradicts either expectation, revert the -relevant commit and update this document. diff --git a/docs/audit/v1414-summary.md b/docs/audit/v1414-summary.md deleted file mode 100644 index 652651e46..000000000 --- a/docs/audit/v1414-summary.md +++ /dev/null @@ -1,337 +0,0 @@ -# HealthLog v1.4.14 — Release Summary - -## Release brief (DE, ≤ 200 Wörter) - -✅ **v1.4.14 ist live** auf `https://healthlog.bombeck.io` — Image-Digest -`sha256:0ced46004a54…` (vorher v1.4.12: `791c2cd2…`), `/api/version` -meldet `1.4.14`. - -🆕 **Was ist neu** - -- **Codex/ChatGPT-Modell-Slug korrigiert** auf `gpt-5.3-codex` — KI- - Insights laufen jetzt direkt mit deinem ChatGPT-Login durch, kein - API-Key nötig. Operator-Override per `CODEX_MODEL`-Env-Var möglich. -- **Admin-Bereich auf Unterseiten aufgeteilt** (`/admin/users`, - `/admin/backups`, `/admin/system-status`, `/admin/integrations`, …) — - bessere Übersicht, schnelleres Laden, Status-Cards verlinken direkt. -- **Neue Backups-Sektion** mit Tabelle aller bisherigen Sicherungen und - „Backup jetzt erstellen“-Knopf. -- **Neue User-Verwaltung** mit Rollen-Filter und „Force-Logout"-Aktion - (eigene Session ist gesperrt). -- **„Gespeicherten KI-Schlüssel entfernen"** als eigener Knopf in - Settings → KI. -- **Trend-Pfeile sind jetzt metrik-bewusst** — Blutdruck ↑ orange, - Stimmung ↑ grün, Puls ↑ neutral. - -🔧 **Was ist gefixt** - -- Notification-Channels + Push-Subscriptions werden jetzt vom „Alle - Daten löschen“ tatsächlich entfernt. -- DST-Wechsel verschiebt keine Tageswerte mehr in Cross-Metric-Joins - (war zwei Tage/Jahr falsch). -- KI-Provider-Fehler werden korrekt klassifiziert (422/503/429 statt - pauschal 500). -- Logging-Redactor verschluckt keine harmlosen Wörter wie `task-force` - oder `risk-management` mehr. - -⚡ **Performance** - -- `/insights` initiales JS −108 KiB (Recharts deferred geladen). -- Dashboard-Checklist Netzwerk −950 ms für Bestandsnutzer. - -⚠️ **Hard-Reload** (`Cmd+Shift+R`) für den einmaligen Service-Worker- -Reset einplanen. - -📚 **Docs** auf `docs.healthlog.dev` und Landing aktualisiert. -🎯 **v1.4.15 Backlog**: `.planning/v1415-backlog.md`. - ---- - -## Was gelandet ist - -53 Commits seit `v1.4.13` (`5df74f7..e5fae9b`). Severity-geordnet. - -### Release - -| SHA | Summary | -| --------- | --------------------------------------------------------------- | -| `e5fae9b` | chore(release): v1.4.14 | -| `f0d53fb` | chore(release): rebrand artifacts as v1.4.14 (patch, not minor) | - -### Codex-OAuth verification (Stage 1) - -| SHA | Summary | -| --------- | ------------------------------------------------------------------------ | -| `5df74f7` | fix(ai): default Codex model to `gpt-5.3-codex` for ChatGPT-account auth | - -### v1.4.6 deferred bugfix backlog (Stage 2) - -| SHA | Summary | -| --------- | -------------------------------------------------------------------------------------------- | -| `512a6a6` | fix(admin): wipe-all-data clears notification channels, push subs, telegram deletions (T2.1) | -| `cb6a59a` | fix(insights): correct DST handling in cross-metric daily bucket pairing (T2.2) | -| `5403821` | fix(insights): map AI provider errors to 422/503 instead of 500 (T2.3) | -| `d6696cf` | fix(logging): redactSecrets regex requires non-alphanumeric prefix (T2.5) | - -### Admin Panel refactor (Stage 4b — folds T2.6 + T2.7) - -| SHA | Summary | -| --------- | ----------------------------------------------------------------------------------- | -| `d8b71b3` | feat(admin): scaffold per-section dynamic routes (admin-shell + [section]/page.tsx) | -| `12b280b` | refactor(admin): move sections to dynamic-route pattern + reduce overview | -| `69c5225` | feat(admin): backups view + manual backup trigger (T2.6 deferred) | -| `5957e18` | feat(admin): users management with filters + force-logout action (T2.7 deferred) | -| `a34cd8c` | refactor(i18n): namespace admin keys under `admin.section..*` | -| `73965cd` | feat(admin): expandable sidebar nav for admin sections | -| `961e2a9` | fix(admin): status-card CTAs use real routes (not `#anchor`) | -| `7a47532` | feat(admin): redirect legacy section-anchor paths to dynamic routes | -| `1a7e3d6` | test(admin): tighten status-card-grid + add per-section render smoke | - -### UX polish (Stage 5) - -| SHA | Summary | -| --------- | ------------------------------------------------------------------------ | -| `987ce0d` | fix(a11y): clear axe-core violations on `/admin` and key sub-routes | -| `38f12df` | feat(ui): trend arrow color reflects metric-specific direction sentiment | -| `788c8ad` | feat(settings): allow removing saved AI provider key | -| `569300e` | style(ui): use semantic tokens for mood and feedback indicators | -| `e3a6899` | fix(ui): disambiguate dashboard quick-add menu items | -| `65cfb27` | fix(a11y): label `/admin` overview quick-jump as a navigation landmark | - -### Multi-pass QA reconcile (Stage 6) - -| SHA | Summary | -| --------- | ---------------------------------------------------------------------------------- | -| `88c8db1` | refactor: apply simplify-review safe-to-apply suggestions (11 of 11) | -| `b3f282a` | fix(security): block force-logout from targeting the admin's own session | -| `8e17f22` | fix(admin): rate-limit `/api/admin/backups/run` to 3/min per admin | -| `f0cfd26` | fix(security): redact standalone `hlk_` / `hlr_` native API tokens in logs | -| `bd24f13` | fix(a11y): drop duplicate h2 inside single-section `/admin/` routes | -| `499cfad` | fix(a11y): bump `/admin/users` + `/admin/login-overview` tap targets to WCAG 2.5.5 | -| `ef96e87` | fix(admin): default api-tokens + login-overview routes to expanded | - -### End-to-end test coverage (Stage 3) - -| SHA | Summary | -| --------- | --------------------------------------------------------------------- | -| `535465c` | test(e2e): seeded test user + auth storageState fixture | -| `3999ea7` | test(e2e): authenticated dashboard + add-measurement flow | -| `2b6f959` | test(e2e): mocked Codex device-flow + insights regenerate specs | -| `a106547` | test(e2e): doctor-report PDF + mobile-viewport smoke | -| `ca3c599` | test(e2e): extend axe-core to authenticated surfaces; fix login regex | -| `7af1f59` | fix(a11y): close violations the new e2e axe-core sweep surfaced | - -### Performance (Stage 4) - -| SHA | Summary | -| --------- | ----------------------------------------------------------------------- | -| `bb2b1de` | perf(insights): defer Recharts ScatterChart imports via `next/dynamic` | -| `519e36e` | perf(dashboard): skip checklist API fetches once onboarding is complete | -| `1a85da0` | fix(insights): preserve chart visual parity in deferred bundle | -| `41fa203` | docs(audit): v1.4.14 performance audit report | - -### CI / housekeeping - -| SHA | Summary | -| --------- | ----------------------------------------------------------------- | -| `3cacdf2` | fix(tests): share next/headers cookieJar across integration suite | -| `c847b80` | chore(format): trend-card test file prettier sweep | -| `badd380` | style(admin): apply prettier to stage-6 reconcile changes | -| `6b88e56` | chore(format): prettier sweep over stage-2 touched files | - ---- - -## Was deferred wurde + Warum - -### v1.4.14 release-cycle "Apply automatically? no" simplify findings (8) - -Aus `phase-6-simplify-findings.md`: - -- **F5** Defensive `notFound()` hinter `dynamicParams = false` — - Entscheidung pending zwischen (a) Kommentar trimmen vs. (b) Guard - droppen + casten. -- **F9** `` in `renderer.tsx` switch — die Repetition ist - echt, aber das Tabellen-Refactor wäre schlechter (asymmetrische - `currentUserId`-Prop). Recommend: keep as-is. -- **F10** `if (!user || user.role !== "ADMIN") return null` doppelt — - zwei Sites, unter dem 4-uses-Threshold. Recommend: keep. -- **F12** Historische Anker-Kommentare (`v1.4.6 T2.6`, `P15`, - `v1.4.7.1`, …) — judgement-call sweep; ein Teil enthält echtes - WHY-Wissen, ein Teil ist Archäologie. -- **F13** ~270 LOC duplication in den drei Status-Generatoren - (mood/weight/blood-pressure) — `_status-helpers.ts`-Extraktion ist - medium-risk, braucht fokussierten Pass + Tests. -- **F14** `formatBytes(fmt: ReturnType)` — - cosmetic. -- **F18** `getApiErrorMessage` doppelter Fallback-Branch — cosmetic. -- **F19** `eslint-disable-next-line react-hooks/exhaustive-deps` auf - Device-Code-Polling — verifizieren, ob `t`/`queryClient`-deps eine - Re-poll-Loop auslösen. - -### v1.4.6 P4+ Items (nicht aufgenommen) - -`useAdminSettings` / `useSystemStatus` Test-Coverage für die -`isError`-UI aus P19 ist weiter manuell-only — nicht in v1.4.14 -gepflegt, kein Regressionsrisiko. Trägt sich in den v1.4.15-Backlog. - -### 4 HIGH findings → v1.4.15 - -Aus `phase-6-reconcile-report.md`: - -- **H4d/H5d design — StatusCardGrid i18n** (Englisch-only - Severity-Labels, Card-Titel/-CTAs, `fmtRelative`/`fmtUptime`, - StatusBadge-aria-label). ~15 Keys × 2 Locales + Test-Rewrite — zu - breit für sicheren Overnight-Apply. -- **H2 code-review — Admin-Page server-side `requireAdmin()`**. Heute - ist die Rolle nur client-seitig geprüft; das Shell flackert kurz. - Braucht eine server-rendered `requireAdmin()`-HOC, präzisiert für - den HealthLog-Auth-Stack. -- **H-1 security — "Wipe all data"-Scope-Drift**. v1.4.14 hat - Notification-Channels, Push-Subs und Telegram-Scheduled-Deletions - ergänzt. Was weiter überlebt: `MoodEntry`, `RefreshToken`, - `DataBackup`, `Device`, `UserAchievement`, `IdempotencyKey`, plus - einige verschlüsselte Spalten. Entscheidung pending: (a) Scope - nochmal weiten oder (b) Operation umbenennen ("Reset health data"). - ---- - -## CI / Test status - -- `pnpm typecheck` — clean -- `pnpm lint` — 0 errors / 12 pre-existing warnings (none introduced) -- `pnpm format:check` — clean nach `c847b80` (trend-card test file - sweep) -- `pnpm test` — **97 files / 754 tests** green -- `pnpm test:integration` — 5 files / 11 tests green; mock-leak flake - zwischen `admin-data-wipe.test.ts` und `idempotency-replay.test.ts` - via `tests/integration/mock-next-headers.ts` shared cookieJar - behoben (10/10 consecutive runs grün) -- `pnpm build` — fails lokal auf Node 25 (`Reflect.get` private-member - bug); CI Docker (Node 22) ist canonical -- `pnpm e2e` — gleicher Node-25-Bug betrifft auch `next dev`; CI runs - green auf Node 22 (Phase-3 Report: 41/41 specs green) - ---- - -## Production state - -- **URL**: `https://healthlog.bombeck.io` -- **`/api/version`**: `1.4.14` ✓ -- **Image digest**: `sha256:0ced46004a544a311627b7036f2f2aed75861b0c62576214a1303a17d20c3d22` - (vorher v1.4.12: `sha256:791c2cd2a8eae5ec0a6fd666074c9f646c17414e3521d526267131c2a3b7edab`) -- **Smoke** (curl mit Session-Cookie): `/`, `/auth/login`, - `/settings/integrations`, `/admin` — alle 200 -- **Deploy-Methode**: `:1.4.14` von GHCR auf den Host gepullt, dort als - `:latest` retagged (kein Compose-Edit), dann `docker compose up -d -app` in `/data/coolify/applications/pg8wggwogo8c4gc4ks0kk4ss/`. Der - parallele `main`-Branch-GHCR-Run war auf demselben SHA hängengeblieben - (>45 min), wurde gecancelt — harmlos, weil der Tag-Build `:latest` - bereits gepusht hatte. Retag-on-host war defensiv aber sicher. -- **GitHub Release**: https://github.com/MBombeck/HealthLog/releases/tag/v1.4.14 -- **Tag-Naming**: GHCR-OCI-Tag ist `:1.4.14` (kein `v`-Prefix); - git-Tag ist `v1.4.14`. Hard-Rule's `:v1.4.14`-Slug needs a docs fix. - ---- - -## Codex-OAuth Status (das v1.4.7–v1.4.13 Saga-Ende) - -- **Resolution**: ChatGPT-Account-Auth verlangt das Modell-Slug - `gpt-5.3-codex`. Das ist canonical, gepflegt aus - `openai/codex` → `codex-rs/models-manager/models.json`. Sowohl - `gpt-5-codex` als auch das v1.4.13-Fallback `gpt-5` werden vom - Codex-Backend mit ChatGPT-Subscription explizit abgelehnt - (`The 'gpt-5' model is not supported when using Codex with a ChatGPT -account.`). -- **Spec extension**: `docs/codex-protocol-spec.md` §7a dokumentiert - jetzt die ChatGPT-Account-Auth-Allow-List + die Lehre, dass die Slugs - in `model_migration.rs` Migration-_Prompts_ sind, keine Wire-Slugs. -- **Operator-Override**: `CODEX_MODEL`-Env-Var auf apps-01 lässt - alternative Slugs ohne Rebuild testen — als Sicherheitsnetz für - andere Plan-Tarife oder zukünftige OpenAI-Slug-Wechsel sinnvoll. -- **Live-Probe verifiziert**: `/api/ai/test` und - `/api/insights/generate` laufen beide gegen das ChatGPT-Plus-Abo - des Live-Tenants durch. `/tmp/v15-codex-working.png` zeigt das - grüne "ChatGPT connected"-Badge in `/settings/ai`. - ---- - -## Docs / Landing - -- **healthlog-docs** (Starlight) — 7 Files aktualisiert: AI-Insights - (Codex device-code-Pfad neu), Admin-Settings (Backups + Users - Sektionen), Environment-Variables (`CODEX_MODEL`), Updates (Backups- - View), Introduction (One-Click Codex-Flow), Troubleshooting - (Codex-Failure-Modes), Dashboard-Customization (Perf-Note). - Commit `06bc616`. Coolify auto-redeploys docs.healthlog.dev. -- **healthlog-landing** (Next.js) — `softwareVersion 1.4.6 → 1.4.14` - in JSON-LD; AI-Providers-Copy auf "one-click device-code Codex - flow, no API plan needed" angepasst. Commit `92c6588`. - ---- - -## GitHub releases - -`v1.4.7` … `v1.4.13` waren bereits real-time während des -Codex-OAuth-Iterationstags veröffentlicht; nur `v1.4.13` war pending -und wurde nachgezogen. `v1.4.14` selbst ist jetzt als Latest geflagged. - ---- - -## Empfohlener v1.4.15 Backlog - -Aus `.planning/v1415-backlog.md` (severity-geordnet, Datei:Zeile, ein -Satz Recommendation pro Item). Top-Hits: - -### HIGH - -- **H4d+H5d** — `StatusCardGrid` i18n: Severity-Labels, Card-Titel, - CTAs, `fmtRelative`, `fmtUptime`, StatusBadge-aria-label nach - `admin.statusGrid.*` migrieren (EN+DE Parität, ~15 Keys × 2 Locales). -- **H2** — `requireAdmin()` server-side auf `/admin/[section]/page.tsx` - und `/admin/page.tsx` ziehen, damit Non-Admins 403 sehen, bevor das - Shell paint-flickert. -- **H-1** — "Wipe all data" Entscheidung: (a) Scope auf `MoodEntry`, - `RefreshToken`, `DataBackup`, `Device`, `UserAchievement`, - `IdempotencyKey` + verschlüsselte Codex/AI/moodLog-Spalten weiten, - oder (b) Operation umbenennen ("Reset health data") und "preserved"- - Liste explizit zeigen. Recommend (a) für GDPR-Art-17-Alignment. - -### MEDIUM (Highlights) - -- **M-2 security** `/api/admin/users/[id]/force-logout` ohne Rate-Limit - und ohne Last-Admin-Guard. -- **M-3 security** Wide-Event-`meta`-Strings nicht durch - `redactSecrets()` gezogen — `addMeta()` braucht den Filter. -- **M-5 security** Wipe-Rate-Limit BEFORE auth — unauth'd Traffic - mutiert `rate_limits`. Reorder: `requireAdmin()` zuerst, dann key by - `user.id`. -- **M1 code-review** `/insights` TrendCard-Tile-Strip noch ohne - `directionSentiment` verdrahtet (war v1.4.14 nur Dashboard). -- **M2 code-review** Legacy-Admin-Anchor-301s leaken Slug-Existenz an - unauth'd Hits — Redirect-Block muss NACH dem Session-Check. -- **M3 code-review** `BackupsSection` 2-Sek-Timeout für Refetch ist ein - Guess — auf 5–8s mit Double-Invalidate, oder Job-State pollen. - -### LOW - -- **L-1 security** `/api/auth/codex/callback` als PUBLIC_PATH gelistet, - aber kein Route-File existiert — Eintrag aus `proxy.ts` löschen. -- **L-2 security** Codex `codex_device`-Cookie ohne Per-User-Binding - — `userId` in den encrypted Blob einbetten, Mismatch rejecten. -- **L7 design** `/insights` Sticky-`