diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 24de24513..5229123be 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -39,7 +39,6 @@ body: - Notifications - AI Insights - Withings integration - - moodLog integration - Self-hosting / Docker - Other validations: diff --git a/AGENTS.md b/AGENTS.md index b0c70bf97..ceaec3adc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Instructions for AI coding agents (OpenAI Codex, Claude Code, Cursor, etc.) work ## Project -**HealthLog** — a personal health-tracking web app (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first PWA design. +**HealthLog** — a personal health-tracking web app (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, Dracula-themed UI, mobile-first PWA design. **Status**: v1.4.0 (released) → v1.4.1 in progress. v1.4.0 shipped the UI guidelines + Skeleton/EmptyState primitives, medical-citations @@ -102,7 +102,6 @@ src/ │ ├── import/ # JSON data import │ ├── doctor-report/ # Doctor report data aggregation │ ├── gamification/ # Achievements (persistent) -│ ├── integrations/moodlog/ # moodLog.app webhook + sync + status │ └── ... ├── components/ │ ├── ui/ # shadcn/ui primitives @@ -135,7 +134,6 @@ src/ │ ├── gamification/ # Achievement definitions + progress │ ├── insights/ # Insight pipeline + prompts │ ├── logging/ # Wide Events: builder, context, sampler, transports, background -│ ├── moodlog/ # moodLog.app webhook + sync │ ├── monitoring/ # Umami / GlitchTip server-side hooks │ └── withings/ # Withings OAuth client + sync service ├── hooks/ diff --git a/CLAUDE.md b/CLAUDE.md index 8481b2002..e529b0e80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,7 +140,7 @@ The v1.5.2 audit produced `.planning/security-audit-v1.5.2.md` and a stack of pe - **SSRF: input-time `isPublicUrl` is the floor.** The helper `isPublicUrl()` in `src/lib/validations/notifications.ts` catches every IPv4 / IPv6 alt-notation class (octal / hex / decimal IPv4, IPv4-mapped IPv6, IPv6 ULA, CGNAT, link-local, loopback, metadata). Use it for every outbound URL that comes from a user / admin input. DNS rebinding and the redirect-follow gap remain as architectural follow-ups (issues #217 + #218). Until those land, every NEW outbound `fetch()` that touches a user-controlled host must pin `redirect: "manual"` and an `AbortSignal.timeout`. - **Outbound egress goes through `safeFetch`.** `src/lib/safe-fetch.ts` is the one documented egress entry (`redirect: "manual"` + `AbortSignal.timeout(15_000)` defaults; opt into the connect-time DNS-rebinding pin with `requirePublicHost: true` for any operator- or user-supplied host). The in-repo ESLint rule `healthlog/safe-fetch-required` (set to `error`) bans raw `fetch(` under `src/lib/` + `src/app/` outside the wrapper internals and test files; same-origin relative-path (`/api/…`) client fetches are exempt by construction. - **Rate limits live in Postgres.** `src/lib/rate-limit.ts` uses a single atomic SQL upsert returning `{count, reset_at}`. Multi-instance correctness is structural. Bucket-key convention: `:${identifier}` where identifier is `userId` for authenticated routes and the trusted-proxy IP for anonymous. Anonymous auth surfaces use `checkAuthSurfaceRateLimit(...)` so a trust-violation collapses every anonymous caller into a single tight bucket rather than a free-for-all under the unknown-IP bucket. -- **Webhooks: shared-secret + `timingSafeEqual` + per-source rate limit BEFORE secret verification.** Withings, Telegram, moodLog, the Coolify deploy webhook, and the CSP report endpoint all follow this exact pattern. The Withings path-segment secret is scrubbed from `http.path` / `http.route` by `redactSecrets()` via the `PATH_SECRET_PATHS` registry in `src/lib/logging/redact.ts:40`. +- **Webhooks: shared-secret + `timingSafeEqual` + per-source rate limit BEFORE secret verification.** Withings, Telegram, the Coolify deploy webhook, and the CSP report endpoint all follow this exact pattern. The Withings path-segment secret is scrubbed from `http.path` / `http.route` by `redactSecrets()` via the `PATH_SECRET_PATHS` registry in `src/lib/logging/redact.ts:40`. - **Idempotency keys are user-scoped, length-bounded, and refuse to cache secret-shaped responses.** `src/lib/idempotency.ts`. A client cannot replay another user's idempotency key (composite unique index by `userId_key_method_path`). The cache helper refuses to persist a response body containing `hlk_` / `hlr_` / `sk-` patterns even if a future caller wraps an auth endpoint by mistake. - **CSP: strict, per-request nonce, surgical `connect-src`.** Production CSP gates third-party hosts to the routes that need them — AI hosts only on `/settings/ai/*`, `wbsapi.withings.net` only on `/settings/integrations/withings/*` + `/api/withings/*`. Exactly one `dangerouslySetInnerHTML` in the tree (the theme bootstrap in `src/app/layout.tsx`, nonce-bound, file-literal content). No markdown library installed anywhere; Coach / briefing / insights render as plain React text children. Don't add one without a security review of the renderer's HTML-allow defaults. - **No CORS headers anywhere.** Same-origin-only by construction. Adding `Access-Control-Allow-Origin: *` anywhere would silently open every authenticated endpoint to cross-origin POST; webhook routes accept cross-origin POSTs but they authenticate by header secret, not cookie. diff --git a/CONTRIBUTING-AI.md b/CONTRIBUTING-AI.md index b5822d1fb..9c99d4a22 100644 --- a/CONTRIBUTING-AI.md +++ b/CONTRIBUTING-AI.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project -HealthLog — a personal health-tracking PWA (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first design, offline caching, and doctor report PDF export. +HealthLog — a personal health-tracking PWA (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, Dracula-themed UI, mobile-first design, offline caching, and doctor report PDF export. ## Commands diff --git a/docs/diagrams/01-data-flow.svg b/docs/diagrams/01-data-flow.svg index 388d3d883..9bb7a272b 100644 --- a/docs/diagrams/01-data-flow.svg +++ b/docs/diagrams/01-data-flow.svg @@ -1,6 +1,6 @@ HealthLog data flow — device to insight - Five-stage pipeline: sources (Withings, Apple Health export, iPhone HealthKit, manual entry, moodLog.app) feed ingest endpoints, which write to Postgres Measurement and MeasurementRollup tables, then surface on Dashboard tiles, Insights cards, the AI Coach, and the Doctor PDF. + Five-stage pipeline: sources (Withings, Apple Health export, iPhone HealthKit, manual entry, Nightscout) feed ingest endpoints, which write to Postgres Measurement and MeasurementRollup tables, then surface on Dashboard tiles, Insights cards, the AI Coach, and the Doctor PDF. @@ -45,8 +45,8 @@ PWA · iOS Shortcuts - moodLog.app - webhook + Nightscout + pull sync diff --git a/docs/diagrams/03-self-hosting-topology.svg b/docs/diagrams/03-self-hosting-topology.svg index 4b2a227c4..658a87b7b 100644 --- a/docs/diagrams/03-self-hosting-topology.svg +++ b/docs/diagrams/03-self-hosting-topology.svg @@ -81,7 +81,7 @@ Withings Apple Health - moodLog.app + Nightscout AI provider (per-user) diff --git a/docs/diagrams/README.md b/docs/diagrams/README.md index 101cabfed..c462567c7 100644 --- a/docs/diagrams/README.md +++ b/docs/diagrams/README.md @@ -8,7 +8,7 @@ copy. | File | Story it tells | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `01-data-flow.svg` | Sources (Withings, Apple Health export.zip, iPhone HealthKit, manual, moodLog) → ingest endpoints → Measurement + MeasurementRollup → reads (rollup probe / live SQL fallback / cache) → surfaces (dashboard, insights, Coach, doctor PDF). | +| `01-data-flow.svg` | Sources (Withings, Apple Health export.zip, iPhone HealthKit, manual, Nightscout) → ingest endpoints → Measurement + MeasurementRollup → reads (rollup probe / live SQL fallback / cache) → surfaces (dashboard, insights, Coach, doctor PDF). | | `02-coach-pipeline.svg` | User question → snapshot builder → prompt assembler → provider chain (ChatGPT OAuth / OpenAI / Anthropic / local / admin-shared) → response parser → cited reply with mini-charts. | | `03-self-hosting-topology.svg` | Internet → reverse proxy (TLS) → Next.js app (port 3000) ↔ pg-boss worker ↔ Postgres 16, plus GHCR image pull, optional Coolify autodeploy, optional S3 off-host backup. | | `04-source-priority.svg` | Three sources for the same metric on the same day → resolver picks one canonical row per (cumulative vs point) ladder. Losing rows stay in the audit trail. | diff --git a/docs/ui-guidelines.md b/docs/ui-guidelines.md index 48b78efd7..0f2874ec7 100644 --- a/docs/ui-guidelines.md +++ b/docs/ui-guidelines.md @@ -324,7 +324,7 @@ The 1.4 release splits the legacy 3150-LOC settings monolith into one route per top-level section: - `/settings/account` — profile, password, passkeys, account deletion. -- `/settings/integrations` — Withings, moodLog, Telegram, ntfy, Web Push. +- `/settings/integrations` — Withings, Nightscout, Telegram, ntfy, Web Push. - `/settings/notifications` — channel matrix + quiet hours + reminders. - `/settings/dashboard` — tile layout editor, default ranges, threshold overrides. @@ -426,7 +426,7 @@ control. Behavior: - **History**: each test writes a row to a tiny per-integration log visible in the section. Last 5 tests, timestamp + result. - **Coverage**: required for Telegram, ntfy, Web Push, AI provider, - Withings, moodLog, GlitchTip, Umami, email (when wired), backup + Withings, Nightscout, GlitchTip, Umami, email (when wired), backup target. Headless E2E asserts presence + working call for each. ### 4.6 Server actions and mutations diff --git a/e2e/settings-integrations-mobile.spec.ts b/e2e/settings-integrations-mobile.spec.ts index 926a78a66..55ab67f28 100644 --- a/e2e/settings-integrations-mobile.spec.ts +++ b/e2e/settings-integrations-mobile.spec.ts @@ -45,7 +45,6 @@ test.describe("/settings/integrations Pixel-5 layout", () => { ntfyGlobal: true, webPushGlobal: true, apiGlobal: true, - moodLogGlobal: true, }, error: null, }), @@ -75,7 +74,7 @@ test.describe("/settings/integrations Pixel-5 layout", () => { // v1.12.1 — the cards read connection state from this // consolidated envelope (the per-provider routes were // dropped from the section). The Withings pill keys on - // `connected`; the moodLog pill keys on `configured`. + // `connected`; a card-less pill keys on `configured`. configured: true, connected: true, connectedAt: "2026-04-01T12:00:00Z", @@ -90,19 +89,18 @@ test.describe("/settings/integrations Pixel-5 layout", () => { ], }, { - integration: "moodlog", + // Configured, card-less, and no longer attempting: the case + // the fallback row exists for. Synthetic since v1.32.33 — + // every shipped provider has a card again, and the row must + // stay proven before the next one is dropped. + integration: "example-provider", state: "connected", lastSuccessAt: recent, lastAttemptAt: recent, lastError: null, consecutiveFailures: 0, configured: true, - enabled: true, legacyLastSyncedAt: monthAgo, - webhookSecret: "ml_xxxxxxxxxxxxxxxxxxxxxxxxxxxx", - entryCount: 42, - // Configured, card-less, and no longer attempting: the case the - // fallback row exists for. syncHealth: { verdict: "stalled", since: monthAgo }, metricFreshness: [], }, @@ -161,23 +159,6 @@ test.describe("/settings/integrations Pixel-5 layout", () => { }), ); - await page.route("**/api/integrations/moodlog/status", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - data: { - configured: true, - enabled: true, - lastSyncedAt: recent, - entryCount: 42, - webhookSecret: "ml_xxxxxxxxxxxxxxxxxxxxxxxxxxxx", - }, - error: null, - }), - }), - ); - await page.goto("/settings/integrations", { waitUntil: "domcontentloaded", }); @@ -186,7 +167,7 @@ test.describe("/settings/integrations Pixel-5 layout", () => { // Wait for the pills to mount (queries resolve after first paint). // One pill per card: Withings, WHOOP, Fitbit, Google Health, Polar, // Oura, Strava, Nightscout — plus the fallback row for the configured, - // card-less moodLog entry. (The Garmin info note is not an OAuth card and + // card-less entry. (The Garmin info note is not an OAuth card and // carries no pill; the Apple Health card's pill keeps its own testid.) const pills = page.locator('[data-testid="integration-status-pill"]'); await expect(pills).toHaveCount(9, { timeout: 10_000 }); @@ -194,7 +175,7 @@ test.describe("/settings/integrations Pixel-5 layout", () => { // The acceptance case: a configured integration with no card of its own, // which has stopped even attempting, is visible on the page. const fallback = page.locator( - '[data-testid="integration-fallback-row"][data-integration="moodlog"]', + '[data-testid="integration-fallback-row"][data-integration="example-provider"]', ); await expect(fallback).toHaveCount(1); await expect( diff --git a/messages/de.json b/messages/de.json index 7bde66b09..554c832be 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5360,27 +5360,6 @@ "deleteAccountFinal": "Endgültig löschen", "saved": "Gespeichert", "savingError": "Fehler beim Speichern", - "moodLogTitle": "moodLog", - "moodLogDescription": "Stimmungseinträge übernehmen.", - "moodLogDeprecated": "Veraltet: Stimmung wird jetzt nativ in HealthLog mit Tags und Faktoren erfasst. Diese Integration funktioniert weiterhin, wird aber in einer künftigen Version entfernt.", - "moodLogUrl": "moodLog URL", - "moodLogUrlPlaceholder": "https://mood.example.com", - "moodLogApiKey": "API-Key", - "moodLogApiKeyPlaceholder": "ml_...", - "moodLogWebhookSecret": "Webhook-Secret", - "moodLogWebhookSecretHelp": "Dieses Secret in moodLog als Webhook-Secret eintragen", - "moodLogSync": "Jetzt synchronisieren", - "moodLogFullSync": "Alle Daten synchronisieren", - "moodLogFullSyncConfirm": "Synchronisieren", - "moodLogFullSyncTitle": "Vollsynchronisierung?", - "moodLogFullSyncDescription": "Alle verfügbaren Stimmungsdaten werden vollständig synchronisiert.", - "moodLogDisconnect": "Trennen", - "moodLogDisconnectTitle": "moodLog trennen?", - "moodLogDisconnectDescription": "Die Verbindung wird getrennt und alle importierten Stimmungsdaten werden gelöscht.", - "moodLogEntries": "Einträge", - "moodLogSyncResult": "{count} Einträge synchronisiert", - "moodLogSyncFailed": "Sync fehlgeschlagen", - "moodLogDisconnected": "moodLog getrennt", "about": { "version": "App-Version", "gitSha": "Build", @@ -5984,7 +5963,6 @@ "telegramGlobal": "Telegram-Benachrichtigungen erlauben.", "ntfyGlobal": "ntfy-Benachrichtigungen erlauben.", "webPushGlobal": "Browser-Push-Benachrichtigungen erlauben.", - "moodLogGlobal": "moodLog-Erinnerungen erlauben.", "integrationWebPush": "Web Push", "offlineGeoLabel": "Geodaten", "offlineGeoEnabled": "Offline (GeoLite2)", diff --git a/messages/en.json b/messages/en.json index e6b4d44d8..fcf3e7d0a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5350,27 +5350,6 @@ "deleteAccountFinal": "Delete permanently", "saved": "Saved", "savingError": "Error saving", - "moodLogTitle": "moodLog", - "moodLogDescription": "Import mood entries.", - "moodLogDeprecated": "Deprecated: mood is now tracked natively in HealthLog with tags and factors. This integration still works but will be removed in a future release.", - "moodLogUrl": "moodLog URL", - "moodLogUrlPlaceholder": "https://mood.example.com", - "moodLogApiKey": "API Key", - "moodLogApiKeyPlaceholder": "ml_...", - "moodLogWebhookSecret": "Webhook Secret", - "moodLogWebhookSecretHelp": "Enter this secret in moodLog as webhook secret", - "moodLogSync": "Sync now", - "moodLogFullSync": "Sync all data", - "moodLogFullSyncConfirm": "Synchronize", - "moodLogFullSyncTitle": "Full sync?", - "moodLogFullSyncDescription": "All available mood data will be fully synchronized.", - "moodLogDisconnect": "Disconnect", - "moodLogDisconnectTitle": "Disconnect moodLog?", - "moodLogDisconnectDescription": "The connection will be removed and all imported mood data will be deleted.", - "moodLogEntries": "Entries", - "moodLogSyncResult": "{count} entries synchronized", - "moodLogSyncFailed": "Sync failed", - "moodLogDisconnected": "moodLog disconnected", "about": { "version": "App version", "gitSha": "Build", @@ -5974,7 +5953,6 @@ "telegramGlobal": "Allow Telegram notifications.", "ntfyGlobal": "Allow ntfy notifications.", "webPushGlobal": "Allow browser push notifications.", - "moodLogGlobal": "Allow mood-log reminders.", "integrationWebPush": "Web Push", "offlineGeoLabel": "Geo data", "offlineGeoEnabled": "Offline (GeoLite2)", diff --git a/messages/es.json b/messages/es.json index 75b5da539..9867f5444 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5362,27 +5362,6 @@ "deleteAccountFinal": "Eliminar permanentemente", "saved": "Guardado", "savingError": "Error al guardar", - "moodLogTitle": "moodLog", - "moodLogDescription": "Import mood data from moodLog", - "moodLogDeprecated": "Obsoleto: el estado de ánimo ahora se registra de forma nativa en HealthLog con etiquetas y factores. Esta integración sigue funcionando, pero se eliminará en una versión futura.", - "moodLogUrl": "moodLog URL", - "moodLogUrlPlaceholder": "https://mood.example.com", - "moodLogApiKey": "Clave de API", - "moodLogApiKeyPlaceholder": "ml_...", - "moodLogWebhookSecret": "Secreto del webhook", - "moodLogWebhookSecretHelp": "Introduce este secreto en moodLog como secreto del webhook", - "moodLogSync": "Sincronizar ahora", - "moodLogFullSync": "Sincronizar todos los datos", - "moodLogFullSyncConfirm": "Sincronizar", - "moodLogFullSyncTitle": "¿Sincronización completa?", - "moodLogFullSyncDescription": "Se sincronizarán por completo todos los datos de estado de ánimo disponibles.", - "moodLogDisconnect": "Desconectar", - "moodLogDisconnectTitle": "¿Desconectar moodLog?", - "moodLogDisconnectDescription": "Se eliminará la conexión y se borrarán todos los datos de estado de ánimo importados.", - "moodLogEntries": "Entradas", - "moodLogSyncResult": "{count} entradas sincronizadas", - "moodLogSyncFailed": "Falló la sincronización", - "moodLogDisconnected": "moodLog desconectado", "about": { "version": "Versión de la app", "gitSha": "Build", @@ -5986,7 +5965,6 @@ "telegramGlobal": "Permitir notificaciones de Telegram.", "ntfyGlobal": "Permitir notificaciones de ntfy.", "webPushGlobal": "Permitir notificaciones push del navegador.", - "moodLogGlobal": "Permitir recordatorios del registro de ánimo.", "integrationWebPush": "Web Push", "offlineGeoLabel": "Datos geográficos", "offlineGeoEnabled": "Sin conexión (GeoLite2)", diff --git a/messages/fr.json b/messages/fr.json index a0a638b3d..fca3bdb2c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5364,27 +5364,6 @@ "deleteAccountFinal": "Supprimer définitivement", "saved": "Enregistré", "savingError": "Erreur lors de l’enregistrement", - "moodLogTitle": "moodLog", - "moodLogDescription": "Import mood data from moodLog", - "moodLogDeprecated": "Obsolète : l’humeur est désormais suivie nativement dans HealthLog avec tags et facteurs. Cette intégration fonctionne encore mais sera supprimée dans une version future.", - "moodLogUrl": "moodLog URL", - "moodLogUrlPlaceholder": "https://mood.example.com", - "moodLogApiKey": "Clé API", - "moodLogApiKeyPlaceholder": "ml_...", - "moodLogWebhookSecret": "Secret du webhook", - "moodLogWebhookSecretHelp": "Saisissez ce secret dans moodLog comme secret de webhook", - "moodLogSync": "Synchroniser maintenant", - "moodLogFullSync": "Synchroniser toutes les données", - "moodLogFullSyncConfirm": "Synchroniser", - "moodLogFullSyncTitle": "Synchronisation complète ?", - "moodLogFullSyncDescription": "Toutes les données d’humeur disponibles seront entièrement synchronisées.", - "moodLogDisconnect": "Déconnecter", - "moodLogDisconnectTitle": "Déconnecter moodLog ?", - "moodLogDisconnectDescription": "La connexion sera supprimée et toutes les données d’humeur importées seront supprimées.", - "moodLogEntries": "Entrées", - "moodLogSyncResult": "{count} entrées synchronisées", - "moodLogSyncFailed": "Échec de la synchronisation", - "moodLogDisconnected": "moodLog déconnecté", "about": { "version": "Version de l’app", "gitSha": "Build", @@ -5988,7 +5967,6 @@ "telegramGlobal": "Autoriser les notifications Telegram.", "ntfyGlobal": "Autoriser les notifications ntfy.", "webPushGlobal": "Autoriser les notifications push du navigateur.", - "moodLogGlobal": "Autoriser les rappels du journal d'humeur.", "integrationWebPush": "Web Push", "offlineGeoLabel": "Données géographiques", "offlineGeoEnabled": "Hors ligne (GeoLite2)", diff --git a/messages/it.json b/messages/it.json index 114b1fc07..1b15c4507 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5354,27 +5354,6 @@ "deleteAccountFinal": "Elimina definitivamente", "saved": "Salvato", "savingError": "Errore durante il salvataggio", - "moodLogTitle": "moodLog", - "moodLogDescription": "Import mood data from moodLog", - "moodLogDeprecated": "Obsoleto: l’umore ora è registrato nativamente in HealthLog con tag e fattori. Questa integrazione funziona ancora ma verrà rimossa in una versione futura.", - "moodLogUrl": "moodLog URL", - "moodLogUrlPlaceholder": "https://mood.example.com", - "moodLogApiKey": "Chiave API", - "moodLogApiKeyPlaceholder": "ml_...", - "moodLogWebhookSecret": "Secret del webhook", - "moodLogWebhookSecretHelp": "Inserisci questo secret in moodLog come secret del webhook", - "moodLogSync": "Sincronizza ora", - "moodLogFullSync": "Sincronizza tutti i dati", - "moodLogFullSyncConfirm": "Sincronizza", - "moodLogFullSyncTitle": "Sincronizzazione completa?", - "moodLogFullSyncDescription": "Tutti i dati sull’umore disponibili verranno sincronizzati completamente.", - "moodLogDisconnect": "Disconnetti", - "moodLogDisconnectTitle": "Disconnettere moodLog?", - "moodLogDisconnectDescription": "La connessione verrà rimossa e tutti i dati sull’umore importati verranno eliminati.", - "moodLogEntries": "Voci", - "moodLogSyncResult": "{count} voci sincronizzate", - "moodLogSyncFailed": "Sincronizzazione non riuscita", - "moodLogDisconnected": "moodLog disconnesso", "about": { "version": "Versione dell’app", "gitSha": "Build", @@ -5978,7 +5957,6 @@ "telegramGlobal": "Consentire le notifiche Telegram.", "ntfyGlobal": "Consentire le notifiche ntfy.", "webPushGlobal": "Consentire le notifiche push del browser.", - "moodLogGlobal": "Consentire i promemoria del diario dell'umore.", "integrationWebPush": "Web Push", "offlineGeoLabel": "Dati geografici", "offlineGeoEnabled": "Offline (GeoLite2)", diff --git a/messages/pl.json b/messages/pl.json index 85bcb9a2f..8c59d3a63 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5348,27 +5348,6 @@ "deleteAccountFinal": "Usuń trwale", "saved": "Zapisano", "savingError": "Błąd podczas zapisywania", - "moodLogTitle": "moodLog", - "moodLogDescription": "Import mood data from moodLog", - "moodLogDeprecated": "Wycofane: nastrój jest teraz śledzony natywnie w HealthLog z tagami i czynnikami. Ta integracja nadal działa, ale zostanie usunięta w przyszłej wersji.", - "moodLogUrl": "moodLog URL", - "moodLogUrlPlaceholder": "https://mood.example.com", - "moodLogApiKey": "Klucz API", - "moodLogApiKeyPlaceholder": "ml_...", - "moodLogWebhookSecret": "Sekret webhooka", - "moodLogWebhookSecretHelp": "Wprowadź ten sekret w moodLog jako sekret webhooka", - "moodLogSync": "Synchronizuj teraz", - "moodLogFullSync": "Synchronizuj wszystkie dane", - "moodLogFullSyncConfirm": "Synchronizuj", - "moodLogFullSyncTitle": "Pełna synchronizacja?", - "moodLogFullSyncDescription": "Wszystkie dostępne dane o nastroju zostaną w pełni zsynchronizowane.", - "moodLogDisconnect": "Rozłącz", - "moodLogDisconnectTitle": "Rozłączyć moodLog?", - "moodLogDisconnectDescription": "Połączenie zostanie usunięte, a wszystkie zaimportowane dane o nastroju zostaną skasowane.", - "moodLogEntries": "Wpisy", - "moodLogSyncResult": "{count} wpisów zsynchronizowano", - "moodLogSyncFailed": "Synchronizacja nie powiodła się", - "moodLogDisconnected": "moodLog rozłączony", "about": { "version": "Wersja aplikacji", "gitSha": "Build", @@ -5972,7 +5951,6 @@ "telegramGlobal": "Zezwól na powiadomienia Telegram.", "ntfyGlobal": "Zezwól na powiadomienia ntfy.", "webPushGlobal": "Zezwól na powiadomienia push przeglądarki.", - "moodLogGlobal": "Zezwól na przypomnienia dziennika nastroju.", "integrationWebPush": "Web Push", "offlineGeoLabel": "Dane geograficzne", "offlineGeoEnabled": "Offline (GeoLite2)", diff --git a/prisma/migrations/0271_drop_moodlog_integration/migration.sql b/prisma/migrations/0271_drop_moodlog_integration/migration.sql new file mode 100644 index 000000000..1ebe995ab --- /dev/null +++ b/prisma/migrations/0271_drop_moodlog_integration/migration.sql @@ -0,0 +1,64 @@ +-- v1.32.33 — retire the moodLog integration. +-- +-- The bridge to the external moodLog service is gone: no pull cron, no +-- inbound webhook, no reverse push, no settings or admin surface. What this +-- migration removes is the integration's own state and nothing else. +-- +-- DATA BOUNDARY — read before editing. +-- +-- `mood_entries` is NOT touched. Mood is a first-class HealthLog module and +-- every row in that table is the user's own health record, including the rows +-- the bridge imported (they carry `source = 'MOODLOG'` and keep it, so the +-- mood list still labels them honestly). Nothing below names `mood_entries`, +-- and nothing below can reach it: +-- +-- * The six dropped columns are plain scalars on `users` / `app_settings`. +-- None carries an index, a unique, a check or a foreign key (see +-- migration 0014, which added all six), so `DROP COLUMN` rewrites those +-- two tables' tuples and stops there. `DROP COLUMN` never cascades into +-- another table on its own, and there is no CASCADE keyword here. +-- * `mood_entries.user_id` references `users(id)`, not any dropped column. +-- Dropping a non-referenced column leaves that foreign key intact and +-- leaves every mood row addressable. +-- * The `integration_statuses` delete is scoped by `integration = 'moodlog'` +-- — sync-ledger bookkeeping for a provider that no longer exists. That +-- table holds no health values and nothing references it. +-- +-- The pg-boss cleanup is guarded on the schema existing: the worker owns that +-- schema and creates it at boot, so a first-install database runs this +-- migration before `pgboss` exists. Without it, a live instance would keep an +-- hourly `moodlog-sync` schedule enqueuing work no handler drains. + +-- Stop the retired cron and drain anything it already queued. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'pgboss' AND table_name = 'schedule' + ) THEN + DELETE FROM pgboss.schedule WHERE name = 'moodlog-sync'; + END IF; + + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'pgboss' AND table_name = 'job' + ) THEN + DELETE FROM pgboss.job WHERE name = 'moodlog-sync'; + END IF; +END +$$; + +-- Sync-ledger row for the retired provider. +DELETE FROM "integration_statuses" WHERE "integration" = 'moodlog'; + +-- Per-user credentials and the enable flag. +ALTER TABLE "users" + DROP COLUMN IF EXISTS "mood_log_url_encrypted", + DROP COLUMN IF EXISTS "mood_log_api_key_encrypted", + DROP COLUMN IF EXISTS "mood_log_enabled", + DROP COLUMN IF EXISTS "mood_log_last_synced_at", + DROP COLUMN IF EXISTS "mood_log_webhook_secret"; + +-- Instance-wide kill switch for the retired integration. +ALTER TABLE "app_settings" + DROP COLUMN IF EXISTS "mood_log_global"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b503eb526..2a4920eca 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -233,13 +233,6 @@ model User { // predicate `strava_backfill_completed_at IS NULL`), mirroring WHOOP/Fitbit. stravaBackfillCompletedAt DateTime? @map("strava_backfill_completed_at") - // moodLog integration (per-user, encrypted) - moodLogUrlEncrypted String? @map("mood_log_url_encrypted") - moodLogApiKeyEncrypted String? @map("mood_log_api_key_encrypted") - moodLogEnabled Boolean @default(false) @map("mood_log_enabled") - moodLogLastSyncedAt DateTime? @map("mood_log_last_synced_at") - moodLogWebhookSecret String? @map("mood_log_webhook_secret") - // Locale (null = use system/admin default) locale String? @map("locale") // "de" | "en" | null @@ -3755,7 +3748,7 @@ model GoogleHealthOAuthState { @@map("google_health_oauth_states") } -// ─── Mood Entries (moodLog) ─────────────────────────────────── +// ─── Mood Entries ───────────────────────────────────────────── model MoodEntry { id String @id @default(cuid()) @@ -3781,11 +3774,15 @@ model MoodEntry { /// IllnessDayLog / CycleDayLog / LabResult note convention). Registered in /// ENCRYPTED_COLUMNS + the rotation script. noteEncrypted Bytes? @map("note_encrypted") - source String @default("MOODLOG") // WEB, TELEGRAM, DAYLIO + // Provenance. MANUAL / WEB / TELEGRAM / DAYLIO / MCP, plus the legacy + // MOODLOG value carried by rows the retired moodLog bridge imported — + // those rows are user history and keep their label. The column default + // is legacy too; every writer sets `source` explicitly. + source String @default("MOODLOG") // v1.12.1 — stable per-source dedup id. NULL for native / MANUAL // entries (those dedup on the legacy `(userId, date, moodLoggedAt)` - // key). When an importer receives a source-stable id (the moodLog - // upstream entry id, a Daylio row id) it writes it here and upserts on + // key). When an importer receives a source-stable id (an iOS row + // UUID, a Daylio row id) it writes it here and upserts on // `@@unique([userId, source, externalId])`, so a re-import is // idempotent even when `moodLoggedAt` re-rounds / re-zones. Postgres // treats NULLs as distinct in that unique, so MANUAL rows never @@ -3859,7 +3856,7 @@ model MoodEntry { // // Backward-compat: this is purely ADDITIVE. The legacy flat // `MoodEntry.tags` (a JSON string array of free-text keys) keeps working -// unchanged — the GLP-1 quick-tag chips, the Telegram/MoodLog sync, and +// unchanged — the GLP-1 quick-tag chips, the Telegram capture, and // every existing row stay on the flat axis. The structured taxonomy is a // second, richer capture surface that the tag-frequency / lift breakdown // folds in next to the flat tags (`mood-aggregates.ts`). @@ -4095,12 +4092,12 @@ model MedicationComplianceRollup { // (the sync entry-points consult this row before doing any external // work). // -// `integration` values are bare strings ("withings", "moodlog") rather +// `integration` values are bare strings ("withings", "whoop") rather // than an enum so future integrations don't require a migration. model IntegrationStatus { id String @id @default(cuid()) userId String @map("user_id") - integration String // "withings" | "moodlog" + integration String // "withings" | "whoop" | "fitbit" | … state String @default("connected") // "connected" | "error_reauth" | "error_transient" | "disconnected" | "parked" lastSuccessAt DateTime? @map("last_success_at") lastAttemptAt DateTime? @map("last_attempt_at") @@ -4143,7 +4140,6 @@ model AppSettings { webPushVapidPrivateKeyEncrypted String? @map("web_push_vapid_private_key_encrypted") webPushVapidSubject String? @map("web_push_vapid_subject") apiGlobal Boolean @default(true) @map("api_global") // global toggle - moodLogGlobal Boolean @default(true) @map("mood_log_global") umamiEnabled Boolean @default(false) @map("umami_enabled") umamiScriptUrl String? @map("umami_script_url") umamiWebsiteId String? @map("umami_website_id") diff --git a/public/sw.js b/public/sw.js index 0c44f3498..385e4bc2b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.32.31"; + /* @sw-version-fallback */ "v1.32.33"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/scripts/migrate-demo-edge01.README.md b/scripts/migrate-demo-edge01.README.md index 4eb35bdc4..80eac968c 100644 --- a/scripts/migrate-demo-edge01.README.md +++ b/scripts/migrate-demo-edge01.README.md @@ -62,11 +62,10 @@ reset to `'disconnected'`), `ai_anthropic_key_encrypted`, `withings_client_id_encrypted`, `withings_client_secret_encrypted`, `whoop_client_id_encrypted`, `whoop_client_secret_encrypted`, `fitbit_client_id_encrypted`, `fitbit_client_secret_encrypted`, -`telegram_bot_token`, `telegram_chat_id`, `mood_log_url_encrypted`, -`mood_log_api_key_encrypted`, `mood_log_webhook_secret`, +`telegram_bot_token`, `telegram_chat_id`, `insurance_number_encrypted`, `insurer_name`, `insurer_ik_number`. -Forced flags: `telegram_enabled=false`, `mood_log_enabled=false`, +Forced flags: `telegram_enabled=false`, `role='ADMIN'`. **Kept:** `password_hash` (demo login works), `avatar_bytes` / diff --git a/scripts/migrate-demo-edge01.sh b/scripts/migrate-demo-edge01.sh index 2fb430e30..76dc3e1c0 100755 --- a/scripts/migrate-demo-edge01.sh +++ b/scripts/migrate-demo-edge01.sh @@ -302,7 +302,7 @@ fi # # users — explicit ordered list. Secret/credential columns are NULLed in the # SELECT (literal NULL in the right position). role forced to 'ADMIN'. -# telegram_enabled / mood_log_enabled forced false. Kept: password_hash, +# telegram_enabled forced false. Kept: password_hash, # avatar_*, all display/prefs/layout JSON, unit prefs, profile fields. # # The SELECT projects literals for the NULLed/forced columns and the real @@ -318,8 +318,6 @@ readonly USERS_COLS="id, username, email, password_hash, role, created_at, updat withings_client_id_encrypted, withings_client_secret_encrypted, whoop_client_id_encrypted, whoop_client_secret_encrypted, fitbit_client_id_encrypted, fitbit_client_secret_encrypted, - mood_log_url_encrypted, mood_log_api_key_encrypted, mood_log_enabled, - mood_log_last_synced_at, mood_log_webhook_secret, locale, thresholds_json, dashboard_widgets_json, insights_layout_json, glucose_unit, unit_preference, ai_provider, ai_model, ai_base_url, ai_anthropic_key_encrypted, ai_local_key_encrypted, @@ -358,11 +356,6 @@ readonly USERS_SELECT="id, username, email, password_hash, NULL::text AS whoop_client_secret_encrypted, NULL::text AS fitbit_client_id_encrypted, NULL::text AS fitbit_client_secret_encrypted, - NULL::text AS mood_log_url_encrypted, - NULL::text AS mood_log_api_key_encrypted, - false AS mood_log_enabled, - NULL::timestamptz AS mood_log_last_synced_at, - NULL::text AS mood_log_webhook_secret, locale, thresholds_json, dashboard_widgets_json, insights_layout_json, glucose_unit, unit_preference, ai_provider, ai_model, ai_base_url, @@ -651,7 +644,7 @@ fi echo echo "HARD assertion 3: the demo user exists on edge01 with role=ADMIN and the apps01 id." -psql_dst "-c \"SELECT id, username, role, telegram_enabled, mood_log_enabled FROM users WHERE id = '$DEMO_USER_ID';\"" +psql_dst "-c \"SELECT id, username, role, telegram_enabled FROM users WHERE id = '$DEMO_USER_ID';\"" echo echo "==============================================================" diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 7b8e2016d..8fefe6ba2 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -159,8 +159,7 @@ async function main() { // ───── User — integration credentials + AI keys + KVNR (String) ───── // Columns: "codexAccessTokenEncrypted" "codexRefreshTokenEncrypted" - // "telegramBotToken" "moodLogWebhookSecret" "moodLogUrlEncrypted" - // "moodLogApiKeyEncrypted" "withingsClientIdEncrypted" + // "telegramBotToken" "withingsClientIdEncrypted" // "withingsClientSecretEncrypted" "whoopClientIdEncrypted" // "whoopClientSecretEncrypted" "fitbitClientIdEncrypted" // "fitbitClientSecretEncrypted" "nightscoutUrlEncrypted" @@ -176,9 +175,6 @@ async function main() { "codexAccessTokenEncrypted", "codexRefreshTokenEncrypted", "telegramBotToken", - "moodLogWebhookSecret", - "moodLogUrlEncrypted", - "moodLogApiKeyEncrypted", "withingsClientIdEncrypted", "withingsClientSecretEncrypted", "whoopClientIdEncrypted", diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index 36104a535..297ff8aa0 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -109,7 +109,6 @@ const LEGIT_IDENTICAL = new Set([ "settings.ai.providerChain.types.anthropic", "settings.ai.providerChain.types.codex", "settings.ai.providerSelect.anthropic", - "settings.moodLogUrl", "settings.sections.export.import.appleHealth.title", "settings.sections.sources.sourceLabels.APPLE_HEALTH", "settings.appleHealth.title", diff --git a/src/__tests__/moodlog-removal-guard.test.ts b/src/__tests__/moodlog-removal-guard.test.ts new file mode 100644 index 000000000..d734121c5 --- /dev/null +++ b/src/__tests__/moodlog-removal-guard.test.ts @@ -0,0 +1,105 @@ +/** + * The moodLog bridge stays removed. + * + * It was retired once before, in pieces: the Settings card went in June, the + * cron, webhook, credentials, ledger entry and roughly twenty i18n keys stayed + * behind, and for a month the code ran hourly against an upstream that had + * stopped answering while nothing on any screen said so. Half-removed was + * worse than either finished state. + * + * This test pins the finished state. It is a structural guard, not a + * behavioural one: it fails the moment a moodLog surface reappears, so a + * reintroduction has to be a decision someone writes down rather than a merge + * that slips through. + * + * What it deliberately does NOT forbid: the `MOODLOG` mood-entry source value + * and its label. Rows the bridge imported are the user's own history, they + * keep their provenance, and every read path must go on resolving it. + */ +import { readFileSync, existsSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { INTEGRATION_CADENCE } from "@/lib/integrations/sync-verdict"; +import { INTEGRATION_DISPLAY_NAMES } from "@/components/settings/integrations/integration-fallback-row"; + +const ROOT = path.resolve(__dirname, "../.."); + +const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const; + +/** Every path the retired bridge occupied. */ +const REMOVED_PATHS = [ + "src/lib/moodlog", + "src/lib/moodlog-secret.ts", + "src/lib/jobs/reminder/moodlog-sync.ts", + "src/app/api/integrations/moodlog", + "src/app/api/settings/moodlog", + "src/lib/validations/moodlog.ts", +]; + +/** Columns migration 0271 dropped. */ +const REMOVED_SCHEMA_FIELDS = [ + "moodLogUrlEncrypted", + "moodLogApiKeyEncrypted", + "moodLogEnabled", + "moodLogLastSyncedAt", + "moodLogWebhookSecret", + "moodLogGlobal", +]; + +describe("moodLog integration stays removed", () => { + it("has no moodLog module, route or validation file", () => { + for (const rel of REMOVED_PATHS) { + expect(existsSync(path.join(ROOT, rel)), `${rel} is back`).toBe(false); + } + }); + + it("declares no moodLog column on the Prisma schema", () => { + const schema = readFileSync( + path.join(ROOT, "prisma/schema.prisma"), + "utf8", + ); + for (const field of REMOVED_SCHEMA_FIELDS) { + expect(schema, `${field} is back on the schema`).not.toContain(field); + } + }); + + it("registers no moodlog queue, cron or handler binding", () => { + const registrar = readFileSync( + path.join(ROOT, "src/lib/jobs/reminder/register-integration-sync.ts"), + "utf8", + ); + expect(registrar).not.toContain("moodlog"); + expect(registrar).not.toContain("MoodLog"); + }); + + it("carries no moodlog integration key on the status envelope", () => { + expect(Object.keys(INTEGRATION_CADENCE)).not.toContain("moodlog"); + expect(Object.keys(INTEGRATION_DISPLAY_NAMES)).not.toContain("moodlog"); + }); + + it("keeps no orphaned moodLog message key in any locale", () => { + for (const locale of LOCALES) { + const bundle = JSON.parse( + readFileSync(path.join(ROOT, `messages/${locale}.json`), "utf8"), + ) as { + settings: Record; + admin: Record; + }; + const orphans = [ + ...Object.keys(bundle.settings), + ...Object.keys(bundle.admin), + ].filter((key) => key.startsWith("moodLog")); + expect(orphans, `${locale} still carries moodLog keys`).toEqual([]); + } + }); + + it("still resolves the MOODLOG provenance of imported mood rows", () => { + // The other half of the contract: removing the bridge must not erase the + // history it wrote. The source badge label has to survive the removal. + const en = JSON.parse( + readFileSync(path.join(ROOT, "messages/en.json"), "utf8"), + ) as { mood: Record }; + expect(en.mood.sourceMoodlog).toBeTruthy(); + }); +}); diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index c7aeac8c7..87a44dfed 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -150,7 +150,7 @@ const EXEMPT_ROUTES: ReadonlyArray = [ // ── DATA LAYER (mood) ───────────────────────────────────────────── // Raw MoodEntry CRUD. The module gate governs whether mood SURFACES // (nav, dashboard tile, analysis, doctor report), not whether the row - // store accepts writes. An importer / the moodLog sync / the user's + // store accepts writes. An importer / the user's // ability to delete or restore entries must keep working while the // module is off, and re-enabling must find the rows intact. "src/app/api/mood-entries/route.ts", diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 341577473..d417d0568 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -343,7 +343,6 @@ const handler = apiHandler( settings.webPushVapidPrivateKeyEncrypted, webPushVapidSubject: settings.webPushVapidSubject, apiGlobal: settings.apiGlobal, - moodLogGlobal: settings.moodLogGlobal, umamiEnabled: settings.umamiEnabled, umamiScriptUrl: settings.umamiScriptUrl, umamiWebsiteId: settings.umamiWebsiteId, diff --git a/src/app/api/admin/settings/__tests__/route.test.ts b/src/app/api/admin/settings/__tests__/route.test.ts index 60a550032..deaae0f41 100644 --- a/src/app/api/admin/settings/__tests__/route.test.ts +++ b/src/app/api/admin/settings/__tests__/route.test.ts @@ -122,7 +122,6 @@ describe("GET /api/admin/settings", () => { glitchtipEnvironment: "production", reminderLateMinutes: 120, reminderMissedMinutes: 240, - moodLogGlobal: true, } as never); const res = await GET(); @@ -182,7 +181,6 @@ describe("PUT /api/admin/settings", () => { glitchtipEnvironment: "production", reminderLateMinutes: 120, reminderMissedMinutes: 240, - moodLogGlobal: true, } as never); const res = await PUT(jsonReq({ webPushVapidPrivateKey: "plain-private" })); @@ -222,7 +220,6 @@ describe("PUT /api/admin/settings", () => { glitchtipEnvironment: null, reminderLateMinutes: 120, reminderMissedMinutes: 240, - moodLogGlobal: true, } as never); const res = await PUT(jsonReq({ clearWebPushVapidPrivateKey: true })); expect(res.status).toBe(200); @@ -250,7 +247,6 @@ describe("PUT /api/admin/settings", () => { glitchtipEnvironment: null, reminderLateMinutes: 120, reminderMissedMinutes: 240, - moodLogGlobal: true, } as never); const res = await PUT( @@ -283,7 +279,6 @@ describe("PUT /api/admin/settings", () => { glitchtipEnvironment: "staging", reminderLateMinutes: 30, reminderMissedMinutes: 90, - moodLogGlobal: false, } as never); const res = await PUT( @@ -295,7 +290,6 @@ describe("PUT /api/admin/settings", () => { webPushGlobal: false, apiGlobal: false, umamiEnabled: true, - moodLogGlobal: false, glitchtipEnabled: true, webPushVapidPublicKey: "vapid-pub", webPushVapidSubject: "mailto:ops@healthlog.dev", @@ -350,7 +344,6 @@ describe("PUT /api/admin/settings", () => { glitchtipEnvironment: null, reminderLateMinutes: 120, reminderMissedMinutes: 240, - moodLogGlobal: true, } as never); await PUT( @@ -405,7 +398,6 @@ describe("PUT /api/admin/settings", () => { glitchtipEnvironment: null, reminderLateMinutes: 120, reminderMissedMinutes: 240, - moodLogGlobal: true, defaultUserTimezone: "Pacific/Auckland", } as never); const res = await PUT(jsonReq({ defaultUserTimezone: "Pacific/Auckland" })); @@ -434,7 +426,6 @@ describe("PUT /api/admin/settings", () => { glitchtipEnvironment: null, reminderLateMinutes: 120, reminderMissedMinutes: 240, - moodLogGlobal: true, defaultUserTimezone: null, } as never); const res = await PUT(jsonReq({ defaultUserTimezone: "" })); diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index 7cbb95b6c..cfb4d787b 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -50,7 +50,6 @@ export const GET = apiHandler(async () => { glitchtipEnvironment: settings?.glitchtipEnvironment ?? "production", reminderLateMinutes: settings?.reminderLateMinutes ?? 120, reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240, - moodLogGlobal: settings?.moodLogGlobal ?? true, // Document vault limits (per-file cap + per-user quota default). BigInt // column → Number for the JSON envelope (values are far below 2^53). documentMaxFileBytes: settings?.documentMaxFileBytes ?? 26_214_400, @@ -94,7 +93,6 @@ export const PUT = apiHandler(async (request: NextRequest) => { "apiGlobal", "umamiEnabled", "glitchtipEnabled", - "moodLogGlobal", ] as const; for (const field of booleanFields) { if (data[field] !== undefined) { @@ -257,7 +255,6 @@ export const PUT = apiHandler(async (request: NextRequest) => { glitchtipEnvironment: settings.glitchtipEnvironment ?? "production", reminderLateMinutes: settings.reminderLateMinutes, reminderMissedMinutes: settings.reminderMissedMinutes, - moodLogGlobal: settings.moodLogGlobal, documentMaxFileBytes: settings.documentMaxFileBytes, documentQuotaBytes: Number(settings.documentQuotaBytes), defaultUserTimezone: settings.defaultUserTimezone, diff --git a/src/app/api/import/__tests__/route.test.ts b/src/app/api/import/__tests__/route.test.ts index 09e8d95b3..f616d3c12 100644 --- a/src/app/api/import/__tests__/route.test.ts +++ b/src/app/api/import/__tests__/route.test.ts @@ -49,7 +49,7 @@ vi.mock("@/lib/daily/morning-refresh-trigger", () => ({ // v1.4.39.1 — surface the rollup hook so the new regression test can // assert that the import path now folds the persistent rollup tier // for each touched (type, day). Mood rollup mock stays no-op because -// the existing v1.4.39 W-MOOD hook already covers the moodlog branch. +// the existing v1.4.39 W-MOOD hook already covers the import branch. vi.mock("@/lib/rollups/measurement-rollups", async () => { const actual = await vi.importActual< typeof import("@/lib/rollups/measurement-rollups") diff --git a/src/app/api/insights/targets/__tests__/route.test.ts b/src/app/api/insights/targets/__tests__/route.test.ts index e522c2e7e..d3be63c57 100644 --- a/src/app/api/insights/targets/__tests__/route.test.ts +++ b/src/app/api/insights/targets/__tests__/route.test.ts @@ -645,7 +645,7 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { category: "Good", color: "var(--success)", }, - source: "moodLog", + source: "Mood entries", ...fiveDaysInRange, }, { @@ -660,7 +660,7 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { category: "Very stable", color: "var(--success)", }, - source: "moodLog", + source: "Mood entries", ...fiveDaysInRange, }, { diff --git a/src/app/api/integrations/moodlog/status/route.ts b/src/app/api/integrations/moodlog/status/route.ts deleted file mode 100644 index fda320e93..000000000 --- a/src/app/api/integrations/moodlog/status/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; -import { annotate } from "@/lib/logging/context"; -import { readMoodLogSecret } from "@/lib/moodlog-secret"; - -export const dynamic = "force-dynamic"; - -export const GET = apiHandler(async () => { - const { user } = await requireAuth(); - annotate({ action: { name: "moodlog.status" } }); - - const dbUser = await prisma.user.findUnique({ - where: { id: user.id }, - select: { - moodLogUrlEncrypted: true, - moodLogEnabled: true, - moodLogLastSyncedAt: true, - moodLogWebhookSecret: true, - }, - }); - - const entryCount = await prisma.moodEntry.count({ - // v1.7.0 sync — exclude tombstoned rows from the entry count. - where: { userId: user.id, deletedAt: null }, - }); - - // V3 audit STILL-V2-C-2: stored secret is now AES-GCM encrypted at rest. - // Decrypt for the user's settings page; legacy plaintext is also handled. - const webhookSecret = readMoodLogSecret(dbUser?.moodLogWebhookSecret ?? null); - - return apiSuccess({ - configured: Boolean(dbUser?.moodLogUrlEncrypted), - enabled: dbUser?.moodLogEnabled ?? false, - lastSyncedAt: dbUser?.moodLogLastSyncedAt ?? null, - entryCount, - webhookSecret, - }); -}); diff --git a/src/app/api/integrations/moodlog/sync/route.ts b/src/app/api/integrations/moodlog/sync/route.ts deleted file mode 100644 index d2dea2455..000000000 --- a/src/app/api/integrations/moodlog/sync/route.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { NextRequest } from "next/server"; -import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, apiError } from "@/lib/api-response"; -import { annotate } from "@/lib/logging/context"; -import { syncMoodLogEntries } from "@/lib/moodlog/sync"; - -export const dynamic = "force-dynamic"; - -export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); - annotate({ action: { name: "moodlog.sync" } }); - - // Check global toggle - const appSettings = await prisma.appSettings.findUnique({ - where: { id: "singleton" }, - select: { moodLogGlobal: true }, - }); - if (appSettings && !appSettings.moodLogGlobal) { - return apiError("moodLog integration is disabled", 403); - } - - let fullSync = false; - try { - const raw = await request.text(); - // Flag-only payload — cap the parse cost (mirrors safeJson maxBytes). - if (raw.length > 64 * 1024) { - return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); - } - const body = JSON.parse(raw); - fullSync = body?.fullSync === true; - } catch { - // No body or invalid JSON — use defaults - } - - const imported = await syncMoodLogEntries(user.id, { - fullSync, - }); - return apiSuccess({ imported }); -}); diff --git a/src/app/api/integrations/moodlog/test/__tests__/route.test.ts b/src/app/api/integrations/moodlog/test/__tests__/route.test.ts deleted file mode 100644 index 33ac0c7e5..000000000 --- a/src/app/api/integrations/moodlog/test/__tests__/route.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; - -// safeFetch's requirePublicHost path runs through undici's own `fetch` -// (version-locked with its dispatcher). Delegate it to the global `fetch` -// stub these tests install so the existing interception still applies. -vi.mock("undici", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - fetch: (input: unknown, init?: unknown) => - (globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)( - input, - init, - ), - }; -}); - -vi.mock("@/lib/api-handler", () => ({ - apiHandler: unknown>(fn: T) => fn, - requireAuth: vi.fn(async () => ({ - user: { id: "u-1" }, - session: { id: "s-1" }, - })), -})); - -vi.mock("@/lib/db", () => ({ - prisma: { - user: { - findUnique: vi.fn(), - }, - }, -})); - -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn(async () => ({ allowed: true })), -})); - -vi.mock("@/lib/logging/context", () => ({ - annotate: vi.fn(), -})); - -vi.mock("@/lib/crypto", () => ({ - decrypt: vi.fn((x: string) => x), -})); - -import { POST } from "../route"; -import { prisma } from "@/lib/db"; -import { checkRateLimit } from "@/lib/rate-limit"; - -interface ApiErrorEnvelope { - data: null; - error: string; -} -interface ApiSuccessEnvelope { - data: T; - error: null; -} - -const originalFetch = global.fetch; - -beforeEach(() => { - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - remaining: 5, - resetAt: Date.now() + 60_000, - } as never); - vi.mocked(prisma.user.findUnique).mockReset(); - global.fetch = vi.fn() as never; -}); - -afterEach(() => { - global.fetch = originalFetch; -}); - -function emptyRequest(): Request { - return new Request("http://localhost/api/integrations/moodlog/test", { - method: "POST", - headers: { "content-length": "0" }, - }); -} - -describe("POST /api/integrations/moodlog/test", () => { - it("happy path returns ok with shape", async () => { - const lastSyncedAt = new Date("2026-05-01T10:00:00.000Z"); - vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ - moodLogUrlEncrypted: "https://example.com", - moodLogApiKeyEncrypted: "ml-key-123", - moodLogLastSyncedAt: lastSyncedAt, - } as never); - (global.fetch as unknown as ReturnType).mockResolvedValueOnce( - new Response(JSON.stringify({ entries: [] }), { status: 200 }), - ); - - const response = await POST(emptyRequest() as never); - expect(response.status).toBe(200); - const body = (await response.json()) as ApiSuccessEnvelope<{ - ok: boolean; - statusCode: number; - latencyMs: number; - lastSyncedAt: string; - }>; - expect(body.data.ok).toBe(true); - expect(body.data.statusCode).toBe(200); - expect(typeof body.data.latencyMs).toBe("number"); - expect(body.data.lastSyncedAt).toBe(lastSyncedAt.toISOString()); - - // Probe must hit the actual sync endpoint with Bearer auth, not a bare HEAD - // against the configured URL. - const fetchCall = (global.fetch as unknown as ReturnType).mock - .calls[0]; - const init = fetchCall[1] as RequestInit; - expect(init.method).toBe("GET"); - const headers = init.headers as Record; - expect(headers.Authorization).toBe("Bearer ml-key-123"); - expect(init.redirect).toBe("manual"); - }); - - it("rate-limit denial returns 429 with no upstream call", async () => { - vi.mocked(checkRateLimit).mockResolvedValueOnce({ - allowed: false, - remaining: 0, - resetAt: Date.now() + 60_000, - } as never); - const response = await POST(emptyRequest() as never); - expect(response.status).toBe(429); - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it("returns 422 when no moodLog URL configured", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ - moodLogUrlEncrypted: null, - moodLogApiKeyEncrypted: null, - moodLogLastSyncedAt: null, - } as never); - const response = await POST(emptyRequest() as never); - expect(response.status).toBe(422); - const body = (await response.json()) as ApiErrorEnvelope; - expect(body.error).toMatch(/not configured/i); - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it("rejects SSRF (private IP) with 422", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ - moodLogUrlEncrypted: "http://127.0.0.1/moodlog", - moodLogApiKeyEncrypted: "ml-key-123", - moodLogLastSyncedAt: null, - } as never); - const response = await POST(emptyRequest() as never); - expect(response.status).toBe(422); - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it("does not leak upstream URL or Bearer token on upstream rejection", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ - moodLogUrlEncrypted: "https://example.com", - moodLogApiKeyEncrypted: "sk-secret", - moodLogLastSyncedAt: null, - } as never); - (global.fetch as unknown as ReturnType).mockRejectedValueOnce( - new Error( - "Authorization: Bearer sk-secret echoed from https://example.com/api/integrations/health-log/mood", - ), - ); - const response = await POST(emptyRequest() as never); - const text = await response.text(); - expect(response.status).toBe(502); - expect(text).not.toMatch(/sk-/); - expect(text).not.toMatch(/example\.com/); - }); -}); diff --git a/src/app/api/integrations/moodlog/test/route.ts b/src/app/api/integrations/moodlog/test/route.ts deleted file mode 100644 index 36b20a09e..000000000 --- a/src/app/api/integrations/moodlog/test/route.ts +++ /dev/null @@ -1,178 +0,0 @@ -import type { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, apiError } from "@/lib/api-response"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { annotate } from "@/lib/logging/context"; -import { prisma } from "@/lib/db"; -import { decrypt } from "@/lib/crypto"; -import { isPublicUrl } from "@/lib/validations/notifications"; -import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; - -export const dynamic = "force-dynamic"; - -const TIMEOUT_MS = 5_000; - -function categoriseHttpStatus(status: number): { - code: string; - message: string; -} { - if (status === 401 || status === 403) { - return { - code: "credentials_rejected", - message: "moodLog rejected the API key", - }; - } - if (status === 404) { - return { - code: "endpoint_not_found", - message: "moodLog endpoint not found at the configured URL", - }; - } - if (status === 429) { - return { - code: "rate_limited", - message: "moodLog rate-limited the request", - }; - } - if (status >= 500) { - return { - code: "upstream_error", - message: "moodLog returned a server error", - }; - } - return { code: "connection_failed", message: "moodLog connection failed" }; -} - -function redact(text: string): string { - return text - .replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]") - .replace(/https?:\/\/\S+/gi, "[url]"); -} - -export const POST = apiHandler(async (request: NextRequest) => { - void request; - const { user } = await requireAuth(); - annotate({ action: { name: "integrations.moodlog.test" } }); - - const rl = await checkRateLimit(`moodlog-test:${user.id}`, 5, 60_000); - if (!rl.allowed) { - return apiError("Too many test requests", 429, { - errorCode: "rate_limited_self", - }); - } - - const dbUser = await prisma.user.findUnique({ - where: { id: user.id }, - select: { - moodLogUrlEncrypted: true, - moodLogApiKeyEncrypted: true, - moodLogLastSyncedAt: true, - }, - }); - - if (!dbUser?.moodLogUrlEncrypted || !dbUser.moodLogApiKeyEncrypted) { - return apiError("moodLog not configured", 422, { - errorCode: "not_configured", - }); - } - - let baseUrl: string; - let apiKey: string; - try { - baseUrl = decrypt(dbUser.moodLogUrlEncrypted); - apiKey = decrypt(dbUser.moodLogApiKeyEncrypted); - } catch { - return apiError("moodLog credentials unreadable", 422, { - errorCode: "credentials_unreadable", - }); - } - - if (!isPublicUrl(baseUrl)) { - return apiError("moodLog URL must be a public HTTP(S) endpoint", 422, { - errorCode: "url_not_public", - }); - } - - // Probe the actual sync endpoint with the actual auth header — a HEAD on the - // bare URL would return 200 from the static landing page even if the API key - // is invalid. Use a 1-day window to keep the upstream payload small. - let probeUrl: URL; - try { - probeUrl = new URL("/api/integrations/health-log/mood", baseUrl); - } catch { - return apiError("moodLog URL is invalid", 422, { - errorCode: "url_invalid", - }); - } - const yesterday = new Date(Date.now() - 24 * 3600 * 1000) - .toISOString() - .slice(0, 10); - const today = new Date().toISOString().slice(0, 10); - probeUrl.searchParams.set("from", yesterday); - probeUrl.searchParams.set("to", today); - - const start = performance.now(); - - try { - // safeFetch keeps the manual-redirect guard (the apiKey would - // otherwise leak on a 302 to 169.254.169.254 / RFC1918) and adds - // the AbortSignal.timeout convention. requirePublicHost layers in - // the connect-time IP pin so a DNS rebinding cannot flip the host - // between accept and dispatch (issue #217). - const res = await safeFetch( - probeUrl.toString(), - { - method: "GET", - headers: { Authorization: `Bearer ${apiKey}` }, - cache: "no-store", - }, - { timeoutMs: TIMEOUT_MS, requirePublicHost: true }, - ); - const latencyMs = Math.round(performance.now() - start); - - // 3xx in `manual` redirect mode → we never followed; treat as success - // failure rather than chase the Location header. - if (res.status >= 300 && res.status < 400) { - annotate({ meta: { moodlog_test_status: res.status } }); - return apiError("moodLog redirected — check the configured URL", 502, { - errorCode: "redirected", - }); - } - - if (!res.ok) { - const cat = categoriseHttpStatus(res.status); - annotate({ - meta: { - moodlog_test_status: res.status, - moodlog_test_code: cat.code, - moodlog_test_latency_ms: latencyMs, - }, - }); - return apiError(cat.message, 502, { errorCode: cat.code }); - } - - return apiSuccess({ - ok: true, - statusCode: res.status, - latencyMs, - lastSyncedAt: dbUser.moodLogLastSyncedAt, - }); - } catch (e) { - const err = e as Error; - const isAbort = - (e instanceof SafeFetchError && e.kind === "timeout") || - err.name === "AbortError"; - const code = isAbort ? "timeout" : "connection_failed"; - annotate({ - meta: { - moodlog_test_code: code, - moodlog_test_error: redact(err.message).slice(0, 300), - }, - }); - return apiError( - isAbort ? "moodLog request timed out" : "moodLog connection failed", - 502, - { errorCode: code }, - ); - } -}); diff --git a/src/app/api/integrations/moodlog/webhook/__tests__/route.test.ts b/src/app/api/integrations/moodlog/webhook/__tests__/route.test.ts deleted file mode 100644 index 1cf3a6519..000000000 --- a/src/app/api/integrations/moodlog/webhook/__tests__/route.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; - -// --- Module-boundary mocks must come before importing the route. --- - -vi.mock("@/lib/api-handler", () => ({ - apiHandler: unknown>(fn: T) => fn, -})); - -vi.mock("@/lib/db", () => ({ - prisma: { - appSettings: { findUnique: vi.fn() }, - user: { findMany: vi.fn() }, - moodEntry: { - upsert: vi.fn(), - deleteMany: vi.fn(), - }, - }, -})); - -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn(), -})); - -vi.mock("@/lib/moodlog-secret", () => ({ - readMoodLogSecret: vi.fn(), -})); - -vi.mock("@/lib/logging/context", () => ({ - annotate: vi.fn(), - getEvent: vi.fn(() => ({ setAuth: vi.fn(), addWarning: vi.fn() })), -})); - -import { POST } from "../route"; -import { prisma } from "@/lib/db"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { readMoodLogSecret } from "@/lib/moodlog-secret"; - -const VALID_PAYLOAD = { - event: "mood.created" as const, - timestamp: "2026-05-08T12:00:00.000Z", - entry: { - date: "2026-05-08", - time: "2026-05-08T12:00:00.000Z", - mood: "GUT" as const, - score: 4, - tags: ["walk", "sun"], - loggedVia: "WEB" as const, - }, -}; - -beforeEach(() => { - vi.resetAllMocks(); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - remaining: 30, - resetAt: Date.now() + 60_000, - } as never); - vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ - moodLogGlobal: true, - } as never); - vi.mocked(prisma.user.findMany).mockResolvedValue([ - { id: "user-1", moodLogWebhookSecret: "encrypted-blob-1" }, - ] as never); - vi.mocked(readMoodLogSecret).mockReturnValue("plaintext-secret"); - vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({} as never); - vi.mocked(prisma.moodEntry.deleteMany).mockResolvedValue({ - count: 0, - } as never); -}); - -function jsonRequest( - body: unknown, - headers: Record = {}, -): NextRequest { - return new NextRequest("http://localhost/api/integrations/moodlog/webhook", { - method: "POST", - headers: { "content-type": "application/json", ...headers }, - body: JSON.stringify(body), - }); -} - -describe("POST /api/integrations/moodlog/webhook", () => { - it("returns 401 when X-Webhook-Secret header is missing", async () => { - const res = await POST(jsonRequest(VALID_PAYLOAD)); - expect(res.status).toBe(401); - const body = (await res.json()) as { error: string }; - expect(body.error).toMatch(/missing/i); - expect(prisma.moodEntry.upsert).not.toHaveBeenCalled(); - }); - - it("returns 401 when no enabled user matches the supplied secret", async () => { - // The single candidate decrypts to a different plaintext. - vi.mocked(readMoodLogSecret).mockReturnValue("different-plaintext"); - - const res = await POST( - jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }), - ); - expect(res.status).toBe(401); - expect(readMoodLogSecret).toHaveBeenCalledWith("encrypted-blob-1"); - expect(prisma.moodEntry.upsert).not.toHaveBeenCalled(); - }); - - it("decrypts each candidate via readMoodLogSecret and matches timing-safely", async () => { - vi.mocked(prisma.user.findMany).mockResolvedValueOnce([ - { id: "user-A", moodLogWebhookSecret: "blob-A" }, - { id: "user-B", moodLogWebhookSecret: "blob-B" }, - ] as never); - - // First candidate decrypts to a wrong value, second decrypts to the - // submitted secret. The route must walk the list and pick user-B. - vi.mocked(readMoodLogSecret) - .mockReturnValueOnce("nope") - .mockReturnValueOnce("plaintext-secret"); - - const res = await POST( - jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }), - ); - - expect(res.status).toBe(200); - expect(readMoodLogSecret).toHaveBeenNthCalledWith(1, "blob-A"); - expect(readMoodLogSecret).toHaveBeenNthCalledWith(2, "blob-B"); - const upsertArgs = vi.mocked(prisma.moodEntry.upsert).mock.calls[0][0]; - // The route must use the matched user's id, not the first candidate's. - expect( - (upsertArgs.where as { userId_date_moodLoggedAt: { userId: string } }) - .userId_date_moodLoggedAt.userId, - ).toBe("user-B"); - }); - - it("happy path: valid secret + mood.created upserts a mood entry tagged MOODLOG", async () => { - const res = await POST( - jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }), - ); - expect(res.status).toBe(200); - - expect(prisma.moodEntry.upsert).toHaveBeenCalledTimes(1); - const args = vi.mocked(prisma.moodEntry.upsert).mock.calls[0][0]; - expect(args).toMatchObject({ - where: { - userId_date_moodLoggedAt: { - userId: "user-1", - date: "2026-05-08", - moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"), - }, - }, - update: { - mood: "GUT", - score: 4, - tags: JSON.stringify(["walk", "sun"]), - source: "MOODLOG", - }, - create: { - userId: "user-1", - date: "2026-05-08", - mood: "GUT", - score: 4, - tags: JSON.stringify(["walk", "sun"]), - source: "MOODLOG", - moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"), - }, - }); - }); - - it("idempotency: a duplicate mood.updated event hits upsert with the same composite key, so the DB no-ops", async () => { - const updated = { ...VALID_PAYLOAD, event: "mood.updated" as const }; - - const r1 = await POST( - jsonRequest(updated, { "x-webhook-secret": "plaintext-secret" }), - ); - const r2 = await POST( - jsonRequest(updated, { "x-webhook-secret": "plaintext-secret" }), - ); - - expect(r1.status).toBe(200); - expect(r2.status).toBe(200); - expect(prisma.moodEntry.upsert).toHaveBeenCalledTimes(2); - const wheres = vi.mocked(prisma.moodEntry.upsert).mock.calls.map( - (c) => - ( - c[0].where as { - userId_date_moodLoggedAt: { - userId: string; - date: string; - moodLoggedAt: Date; - }; - } - ).userId_date_moodLoggedAt, - ); - // Both calls target the same composite key — Postgres ON CONFLICT - // makes the second upsert a no-op write. - expect(wheres[0]).toEqual(wheres[1]); - }); - - it("returns retryable failure when the source record cannot be persisted", async () => { - vi.mocked(prisma.moodEntry.upsert).mockRejectedValueOnce( - new Error("database unavailable"), - ); - - const res = await POST( - jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }), - ); - - expect(res.status).toBe(503); - expect(await res.json()).toMatchObject({ - data: null, - error: expect.stringMatching(/persist/i), - }); - expect(prisma.moodEntry.upsert).toHaveBeenCalledOnce(); - }); - - it("mood.deleted removes any matching entry rather than upserting", async () => { - const deletedPayload = { - ...VALID_PAYLOAD, - event: "mood.deleted" as const, - }; - const res = await POST( - jsonRequest(deletedPayload, { - "x-webhook-secret": "plaintext-secret", - }), - ); - expect(res.status).toBe(200); - expect(prisma.moodEntry.upsert).not.toHaveBeenCalled(); - expect(prisma.moodEntry.deleteMany).toHaveBeenCalledWith({ - where: { - userId: "user-1", - date: "2026-05-08", - moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"), - }, - }); - }); - - it("webhook.test ping short-circuits to 200 with no DB writes", async () => { - const res = await POST( - jsonRequest( - { event: "webhook.test" }, - { "x-webhook-secret": "plaintext-secret" }, - ), - ); - expect(res.status).toBe(200); - expect(prisma.moodEntry.upsert).not.toHaveBeenCalled(); - expect(prisma.moodEntry.deleteMany).not.toHaveBeenCalled(); - }); - - it("rejects payloads that don't match the schema with 400", async () => { - const bad = { - event: "mood.created", - timestamp: "2026-05-08T12:00:00.000Z", - // entry missing required fields - entry: { date: "not-a-date", time: "x", mood: "BAD", score: 0 }, - }; - const res = await POST( - jsonRequest(bad, { "x-webhook-secret": "plaintext-secret" }), - ); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toMatch(/invalid payload/i); - expect(prisma.moodEntry.upsert).not.toHaveBeenCalled(); - }); - - it("returns 403 when the global moodLog toggle is disabled", async () => { - vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ - moodLogGlobal: false, - } as never); - const res = await POST( - jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }), - ); - expect(res.status).toBe(403); - expect(prisma.user.findMany).not.toHaveBeenCalled(); - }); - - it("returns 429 when rate limit is exceeded — no secret lookup performed", async () => { - vi.mocked(checkRateLimit).mockResolvedValueOnce({ - allowed: false, - remaining: 0, - resetAt: Date.now() + 60_000, - } as never); - const res = await POST( - jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }), - ); - expect(res.status).toBe(429); - expect(prisma.user.findMany).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/api/integrations/moodlog/webhook/route.ts b/src/app/api/integrations/moodlog/webhook/route.ts deleted file mode 100644 index 6dc89846e..000000000 --- a/src/app/api/integrations/moodlog/webhook/route.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { NextRequest } from "next/server"; -import { timingSafeEqual } from "node:crypto"; -import { prisma } from "@/lib/db"; -import { apiError } from "@/lib/api-response"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { getClientIp } from "@/lib/api-response"; -import { moodLogWebhookPayloadSchema } from "@/lib/validations/moodlog"; -import { readMoodLogSecret } from "@/lib/moodlog-secret"; -import { persistMoodLogSourceEntry } from "@/lib/moodlog/persistence"; -import { apiHandler } from "@/lib/api-handler"; -import { annotate, getEvent } from "@/lib/logging/context"; -import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; - -export const dynamic = "force-dynamic"; - -/** - * @deprecated The standalone moodLog webhook is superseded by native mood - * entries plus structured tags and rated factors — mood is now tracked - * fully inside HealthLog, so the external moodLog bridge no longer adds - * anything. Kept functional for existing integrations; slated for removal - * in a future major release. Do not build new callers against it. - */ -export const POST = apiHandler(async (request: NextRequest) => { - annotate({ action: { name: "moodlog.webhook" } }); - - // 1. Rate limit (30/min per IP) - const ip = getClientIp(request) ?? "unknown"; - const rl = await checkRateLimit(`moodlog-webhook:${ip}`, 30, 60_000); - if (!rl.allowed) { - return apiError("Rate limit exceeded", 429); - } - - // 2. Check global toggle - const appSettings = await prisma.appSettings.findUnique({ - where: { id: "singleton" }, - select: { moodLogGlobal: true }, - }); - if (appSettings && !appSettings.moodLogGlobal) { - return apiError("moodLog integration is disabled", 403); - } - - // 3. Get webhook secret from header - const webhookSecret = request.headers.get("x-webhook-secret"); - if (!webhookSecret) { - return apiError("Missing X-Webhook-Secret header", 401); - } - - // 4. Find all enabled users with webhook secrets and compare timing-safe - const candidates = await prisma.user.findMany({ - where: { - moodLogEnabled: true, - moodLogWebhookSecret: { not: null }, - }, - select: { id: true, moodLogWebhookSecret: true }, - }); - - // V3 audit STILL-V2-C-2: stored secret is now encrypted at rest. Decrypt - // each candidate before the timing-safe compare. `readMoodLogSecret` - // also tolerates legacy plaintext rows during the transition window. - const receivedBuf = Buffer.from(webhookSecret, "utf8"); - const user = candidates.find((c) => { - const expected = readMoodLogSecret(c.moodLogWebhookSecret); - if (!expected) return false; - const expectedBuf = Buffer.from(expected, "utf8"); - if (expectedBuf.length !== receivedBuf.length) return false; - return timingSafeEqual(expectedBuf, receivedBuf); - }); - - if (!user) { - return apiError("Invalid webhook secret", 401); - } - - getEvent()?.setAuth({ user_id: user.id, auth_method: "webhook_secret" }); - - // 5. Parse body - let body: Record; - try { - const raw = await request.text(); - if (raw.length > 256 * 1024) { - return apiError(`Request body exceeds ${256 * 1024} bytes`, 413); - } - body = JSON.parse(raw); - } catch { - return apiError("Invalid JSON", 400); - } - - // Handle test pings from moodLog's "Test senden" button - if (body.event === "webhook.test") { - annotate({ meta: { webhook_event: "webhook.test" } }); - return new Response(null, { status: 200 }); - } - - const parsed = moodLogWebhookPayloadSchema.safeParse(body); - if (!parsed.success) { - return apiError("Invalid payload", 400); - } - - const { event, entry } = parsed.data; - annotate({ meta: { webhook_event: event } }); - - // 5. Process event and acknowledge only after the source write commits. - const moodLoggedAt = new Date(entry.time); - // v1.12.1 — when the upstream supplies a stable entry id, dedup on - // `(userId, source, externalId)` so a re-emit with a re-rounded / - // re-zoned `time` updates the same row instead of minting a duplicate. - // Absent → the legacy `(userId, date, moodLoggedAt)` key. MOODLOG is - // the fixed source here, matching the create/update writes below. - const externalId = entry.id ?? null; - - try { - if (event === "mood.deleted") { - await prisma.moodEntry.deleteMany({ - where: { - userId: user.id, - ...(externalId - ? { source: "MOODLOG", externalId } - : { date: entry.date, moodLoggedAt }), - }, - }); - } else { - await persistMoodLogSourceEntry(user.id, { - externalId, - date: entry.date, - moodLoggedAt, - mood: entry.mood, - score: entry.score, - tags: entry.tags, - }); - } - - // v1.4.39 W-MOOD — refresh the persisted rollup for the entry's - // bucket. Inline best-effort: a failure here must not block the - // webhook 200 response (the rollup is a cache tier, the source-of- - // truth is the mood_entries write that already committed). - try { - await recomputeMoodBucketsForEntry(user.id, entry.date); - } catch (rollupErr) { - getEvent()?.addWarning( - "Mood rollup recompute failed: " + - (rollupErr instanceof Error ? rollupErr.message : String(rollupErr)), - ); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - getEvent()?.addWarning(`MoodLog source persistence failed: ${message}`); - return apiError("Failed to persist moodLog event", 503); - } - - // 6. The source record is durable; the provider can stop retrying. - return new Response(null, { status: 200 }); -}); diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts index 468a2adb4..edba6a7c8 100644 --- a/src/app/api/integrations/status/__tests__/route.test.ts +++ b/src/app/api/integrations/status/__tests__/route.test.ts @@ -40,7 +40,6 @@ vi.mock("@/lib/integrations/status", () => ({ const perIntegrationLedger: Record> = {}; vi.mock("@/lib/withings/client", () => ({ hasActivityScope: () => false })); -vi.mock("@/lib/moodlog-secret", () => ({ readMoodLogSecret: () => null })); const polarAvailable = vi.fn(async () => true); const ouraAvailable = vi.fn(async () => false); @@ -178,13 +177,14 @@ describe("/api/integrations/status — sync-health verdict on every entry", () = } }); - it("reports a configured, month-silent card-less provider as stalled", async () => { - // The live incident, on the wire: moodLog has no Settings card, so this - // entry is the only thing that can tell anyone the sync has stopped. + it("reports a configured, month-silent provider as stalled", async () => { + // The class the verdict exists for: the ledger state has been frozen + // since the last write, so only the age of `lastAttemptAt` can say the + // sync has stopped attempting. const lastAttemptAt = new Date( Date.now() - 28 * 24 * 60 * 60 * 1000, ).toISOString(); - perIntegrationLedger.moodlog = { + perIntegrationLedger.nightscout = { state: "error_transient", lastSuccessAt: new Date( Date.now() - 55 * 24 * 60 * 60 * 1000, @@ -192,23 +192,20 @@ describe("/api/integrations/status — sync-health verdict on every entry", () = lastAttemptAt, lastError: null, }; - userFind.mockResolvedValue({ - moodLogUrlEncrypted: "enc", - moodLogEnabled: true, - }); + userFind.mockResolvedValue({ nightscoutUrlEncrypted: "enc" }); - const moodlog = (await fetchEntries()).find( - (entry) => entry.integration === "moodlog", + const nightscout = (await fetchEntries()).find( + (entry) => entry.integration === "nightscout", )!; - expect(moodlog.configured).toBe(true); - expect(moodlog.syncHealth).toEqual({ + expect(nightscout.configured).toBe(true); + expect(nightscout.syncHealth).toEqual({ verdict: "stalled", since: lastAttemptAt, }); }); it("keeps an integration that attempted within the window off the stalled arm", async () => { - perIntegrationLedger.moodlog = { + perIntegrationLedger.nightscout = { state: "error_transient", lastSuccessAt: null, lastAttemptAt: new Date( @@ -216,12 +213,12 @@ describe("/api/integrations/status — sync-health verdict on every entry", () = ).toISOString(), lastError: null, }; - userFind.mockResolvedValue({ moodLogUrlEncrypted: "enc" }); + userFind.mockResolvedValue({ nightscoutUrlEncrypted: "enc" }); - const moodlog = (await fetchEntries()).find( - (entry) => entry.integration === "moodlog", + const nightscout = (await fetchEntries()).find( + (entry) => entry.integration === "nightscout", )!; - expect(moodlog.syncHealth!.verdict).toBe("failing"); + expect(nightscout.syncHealth!.verdict).toBe("failing"); }); it("includes a nightscout entry with the fields the card's form reads", async () => { diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index cd17301b5..e74ad777f 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -5,13 +5,12 @@ * - the IntegrationStatus row (state, last-success, last-attempt, * decrypted last-error, per-kind failure buckets, threshold) * - integration-specific extras the UI already shows (Withings: - * credentials configured, token expiry, OAuth-connected; moodLog: - * credentials configured, enabled flag) + * credentials configured, token expiry, OAuth-connected) * * This is the single fetch the Settings → Integrations cards read off - * — it carries every field the four cards render (Withings activity - * scope, WHOOP/Fitbit backfill state, moodLog webhook secret + entry - * count) so the per-card /api//status round-trips are gone + * — it carries every field the cards render (Withings activity scope, + * WHOOP/Fitbit backfill state) so the per-card /api//status + * round-trips are gone * from the web. The legacy per-provider routes stay for the iOS/test * callers; the web cards no longer hit them. */ @@ -39,7 +38,6 @@ import { getOuraClientCredentials } from "@/lib/oura/credentials"; import { getPolarClientCredentials } from "@/lib/polar/credentials"; import { getStravaClientCredentials } from "@/lib/strava/credentials"; import { hasActivityScope } from "@/lib/withings/client"; -import { readMoodLogSecret } from "@/lib/moodlog-secret"; export const dynamic = "force-dynamic"; @@ -49,7 +47,6 @@ export const GET = apiHandler(async () => { const [ withingsStatus, - moodLogStatus, whoopStatus, fitbitStatus, googleHealthStatus, @@ -62,7 +59,6 @@ export const GET = apiHandler(async () => { whoopConn, fitbitConn, googleHealthConn, - moodLogEntryCount, // v1.17.1 — `available` reports whether usable OAuth credentials resolve // (per-user BYO first, then the shared env app), mirroring the per-card // /api//status the consolidated envelope now replaces. @@ -71,7 +67,6 @@ export const GET = apiHandler(async () => { stravaAvailable, ] = await Promise.all([ getIntegrationStatus(user.id, "withings"), - getIntegrationStatus(user.id, "moodlog"), getIntegrationStatus(user.id, "whoop"), getIntegrationStatus(user.id, "fitbit"), getIntegrationStatus(user.id, "google-health"), @@ -99,11 +94,6 @@ export const GET = apiHandler(async () => { stravaAccessTokenEncrypted: true, stravaClientIdEncrypted: true, stravaClientSecretEncrypted: true, - moodLogUrlEncrypted: true, - moodLogApiKeyEncrypted: true, - moodLogEnabled: true, - moodLogLastSyncedAt: true, - moodLogWebhookSecret: true, // Nightscout folds onto the envelope; the card reads these three off // it instead of its own /api/nightscout/status round-trip. nightscoutUrlEncrypted: true, @@ -149,10 +139,6 @@ export const GET = apiHandler(async () => { needsReauth: true, }, }), - // v1.7.0 sync — exclude tombstoned rows from the entry count. - prisma.moodEntry.count({ - where: { userId: user.id, deletedAt: null }, - }), getPolarClientCredentials(user.id).then((c) => !!c), getOuraClientCredentials(user.id).then((c) => !!c), getStravaClientCredentials(user.id).then((c) => !!c), @@ -214,9 +200,6 @@ export const GET = apiHandler(async () => { !!dbUser?.withingsClientSecretEncrypted; const withingsLegacyLastSyncedAt = withingsConn?.lastSyncedAt?.toISOString() ?? null; - const moodLogConfigured = !!dbUser?.moodLogUrlEncrypted; - const moodLogLegacyLastSyncedAt = - dbUser?.moodLogLastSyncedAt?.toISOString() ?? null; const whoopConfigured = !!dbUser?.whoopClientIdEncrypted && !!dbUser?.whoopClientSecretEncrypted; const whoopLegacyLastSyncedAt = whoopConnected @@ -265,25 +248,6 @@ export const GET = apiHandler(async () => { legacyLastSyncedAt: withingsLegacyLastSyncedAt, }), } satisfies IntegrationViewModel & WithingsExtras, - { - ...moodLogStatus, - // moodLog "configured" tracks the URL alone (the API key is - // optional for the webhook-only path), matching the legacy - // /api/integrations/moodlog/status contract the card relied on. - configured: moodLogConfigured, - enabled: dbUser?.moodLogEnabled ?? false, - legacyLastSyncedAt: moodLogLegacyLastSyncedAt, - // V3 audit STILL-V2-C-2: stored secret is AES-GCM encrypted at rest; - // decrypt for the settings page (legacy plaintext is also handled). - webhookSecret: readMoodLogSecret(dbUser?.moodLogWebhookSecret ?? null), - entryCount: moodLogEntryCount, - // moodLog has no connection row — `configured` alone decides whether it - // is meant to be running, so the verdict gets no `connected` hint. - ...resolve("moodlog", moodLogStatus, { - configured: moodLogConfigured, - legacyLastSyncedAt: moodLogLegacyLastSyncedAt, - }), - } satisfies IntegrationViewModel & MoodLogExtras, { ...whoopStatus, configured: whoopConfigured, @@ -425,8 +389,7 @@ interface IntegrationViewModel { syncHealth: SyncHealth; /** * Per-metric-type last-value timestamps for the integration's synced data, - * each with the server-computed `stale` flag. Empty for moodLog (which - * writes MoodEntry rows, not Measurements). Lets the card flag a silently + * each with the server-computed `stale` flag. Lets the card flag a silently * dead metric the green integration state hides. */ metricFreshness: MetricFreshnessEntry[]; @@ -443,14 +406,6 @@ interface WithingsExtras { hasActivityScope: boolean; } -interface MoodLogExtras { - configured: boolean; - enabled: boolean; - legacyLastSyncedAt: string | null; - webhookSecret: string | null; - entryCount: number; -} - interface WhoopExtras { configured: boolean; connected: boolean; diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index 48269ba1a..ce9843d91 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -8,10 +8,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; -import { - updateMoodEntrySchema, - getScoreForMood, -} from "@/lib/validations/moodlog"; +import { updateMoodEntrySchema, getScoreForMood } from "@/lib/validations/mood"; import { NextRequest } from "next/server"; import { Prisma } from "@/generated/prisma/client"; import { apiHandler, requireAuth } from "@/lib/api-handler"; diff --git a/src/app/api/mood-entries/__tests__/route.test.ts b/src/app/api/mood-entries/__tests__/route.test.ts index 4fba3311c..2ddf87a90 100644 --- a/src/app/api/mood-entries/__tests__/route.test.ts +++ b/src/app/api/mood-entries/__tests__/route.test.ts @@ -66,9 +66,6 @@ vi.mock("@/lib/cache/invalidate", () => ({ vi.mock("@/lib/rollups/mood-rollups", () => ({ recomputeMoodBucketsForEntry: vi.fn().mockResolvedValue(undefined), })); -vi.mock("@/lib/moodlog/push", () => ({ - pushMoodEntriesToMoodLog: vi.fn().mockResolvedValue(undefined), -})); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), @@ -87,7 +84,6 @@ import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { createTagLinks } from "@/lib/mood/tag-links"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; -import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push"; import { Prisma } from "@/generated/prisma/client"; const SESSION_OK = { @@ -117,14 +113,9 @@ beforeEach(() => { ); txClient.moodEntryTagLink.findMany.mockResolvedValue([]); vi.mocked(createTagLinks).mockResolvedValue(undefined); - // `reset` blanks the post-commit best-effort mocks; restore the promise - // returns so `recompute(...)` awaits and `push(...).catch()` is callable. + // `reset` blanks the post-commit best-effort mock; restore the promise + // return so `recompute(...)` awaits. vi.mocked(recomputeMoodBucketsForEntry).mockResolvedValue(undefined); - vi.mocked(pushMoodEntriesToMoodLog).mockResolvedValue({ - pushed: 0, - skipped: 0, - status: "ok", - }); }); describe("GET /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => { diff --git a/src/app/api/mood-entries/bulk/__tests__/route.test.ts b/src/app/api/mood-entries/bulk/__tests__/route.test.ts index e54a65a86..bfbe3f9b6 100644 --- a/src/app/api/mood-entries/bulk/__tests__/route.test.ts +++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts @@ -7,7 +7,7 @@ * entry still resolves its own "inserted" / "duplicate" state through the * exact key its dedup contract uses (externalId when carried, else the * legacy date+moodLoggedAt key). The per-entry `upsert` + tombstone check + - * tag links + reverse push are unchanged. Tests below drive `findMany` + * tag links are unchanged. Tests below drive `findMany` * instead of `findUnique`. */ import { describe, it, expect, vi, beforeEach } from "vitest"; @@ -52,10 +52,6 @@ vi.mock("@/lib/mood/tag-links", async () => { }; }); -vi.mock("@/lib/moodlog/push", () => ({ - pushMoodEntriesToMoodLog: vi.fn().mockResolvedValue(undefined), -})); - vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), @@ -94,7 +90,6 @@ import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; import { createTagLinks } from "@/lib/mood/tag-links"; -import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -138,14 +133,6 @@ beforeEach(() => { vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); vi.mocked(createTagLinks).mockResolvedValue(undefined); - // `vi.resetAllMocks()` above wipes the factory default, so the - // best-effort reverse-sync push must be re-stubbed to a resolved - // promise — the route calls `.catch()` on its return value. - vi.mocked(pushMoodEntriesToMoodLog).mockResolvedValue({ - pushed: 0, - skipped: 0, - status: "skipped", - }); }); describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => { diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index 9076ab551..b1b1d5940 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -42,12 +42,11 @@ import { getScoreForMood, moodLevelEnum, moodSourceEnum, -} from "@/lib/validations/moodlog"; +} from "@/lib/validations/mood"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; import { encryptNote } from "@/lib/crypto/note-cipher"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; -import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push"; import { createTagLinks } from "@/lib/mood/tag-links"; const MAX_ENTRIES_PER_BATCH = 500; @@ -459,40 +458,6 @@ async function postBulk(request: NextRequest): Promise { } } - // v1.4.50 — reverse-sync the freshly-inserted batch to MoodLog so - // an iOS one-shot backfill surfaces in both apps. Duplicates and - // skips do NOT push (MoodLog already has them — or we'd just be - // retrying a known failure). The helper filters MOODLOG-sourced - // rows internally so a re-ingest of a MoodLog pull doesn't echo. - if (inserted > 0) { - const pushBatch: Array<{ - date: string; - moodLoggedAt: Date; - mood: string; - note: string | null; - tags: string | null; - source: string; - }> = []; - for (let i = 0; i < entries.length; i++) { - if (results[i]?.status !== "inserted") continue; - const e = entries[i]; - const tz = user.timezone ?? DEFAULT_TIMEZONE; - pushBatch.push({ - date: moodDateKey(e.moodLoggedAt, tz), - moodLoggedAt: e.moodLoggedAt, - mood: e.mood, - note: e.note ?? null, - tags: e.tags ? JSON.stringify(e.tags) : null, - source: e.source, - }); - } - if (pushBatch.length > 0) { - void pushMoodEntriesToMoodLog(user.id, pushBatch).catch(() => { - /* helper wraps errors in wide-event warnings; defence-in-depth */ - }); - } - } - return apiSuccess({ processed: entries.length, inserted, diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 11820d872..8af6bbb0b 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -12,17 +12,16 @@ import { createMoodEntrySchema, listMoodEntriesSchema, getScoreForMood, -} from "@/lib/validations/moodlog"; +} from "@/lib/validations/mood"; import { NextRequest } from "next/server"; import { Prisma } from "@/generated/prisma/client"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { withIdempotency } from "@/lib/idempotency"; -import { encryptNote, readNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; +import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; -import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push"; import { createTagLinks, RatedFactorOutOfRangeError, @@ -345,30 +344,6 @@ async function postMoodEntry(request: NextRequest) { }); } - // v1.4.50 — reverse-sync push to MoodLog. Fire-and-forget; the - // helper itself is best-effort and never throws, so a 502 from - // MoodLog or a transient network blip can never bubble back to - // the user's create. The pull side (15-min cron) backfills any - // entry that fails the push window. Entries with `source === - // "MOODLOG"` skip inside the helper to avoid an echo loop. - void pushMoodEntriesToMoodLog(user.id, [ - { - date: entry.date, - moodLoggedAt: entry.moodLoggedAt, - mood: entry.mood, - note: readNote(entry.noteEncrypted, entry.note), - tags: entry.tags, - source: entry.source, - }, - ]).catch(() => { - // The helper already wraps its own errors in wide-event - // warnings. The void + .catch is defence-in-depth so a - // synchronous throw inside the helper's promise chain (e.g. a - // future refactor that adds an await on a rejected promise - // before the first try/catch) can't surface as an unhandled - // rejection in the Next.js runtime. - }); - return apiSuccess( { // v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext. diff --git a/src/app/api/settings/data/route.ts b/src/app/api/settings/data/route.ts index e938a843b..beb67d1fc 100644 --- a/src/app/api/settings/data/route.ts +++ b/src/app/api/settings/data/route.ts @@ -95,11 +95,6 @@ export const DELETE = apiHandler(async (request: NextRequest) => { telegramEnabled: false, withingsClientIdEncrypted: null, withingsClientSecretEncrypted: null, - moodLogUrlEncrypted: null, - moodLogApiKeyEncrypted: null, - moodLogEnabled: false, - moodLogLastSyncedAt: null, - moodLogWebhookSecret: null, onboardingCompletedAt: null, }, }); diff --git a/src/app/api/settings/moodlog/route.ts b/src/app/api/settings/moodlog/route.ts deleted file mode 100644 index 274a757cc..000000000 --- a/src/app/api/settings/moodlog/route.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { NextRequest } from "next/server"; -import { randomBytes } from "node:crypto"; -import { prisma } from "@/lib/db"; -import { apiSuccess, apiError } from "@/lib/api-response"; -import { encrypt } from "@/lib/crypto"; -import { encryptMoodLogSecret, readMoodLogSecret } from "@/lib/moodlog-secret"; -import { moodLogCredentialsSchema } from "@/lib/validations/moodlog"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; -import { markDisconnected, markReconnected } from "@/lib/integrations/status"; -import { invalidateUserMood } from "@/lib/cache/invalidate"; - -export const dynamic = "force-dynamic"; - -export const PUT = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); - - let body: unknown; - try { - const raw = await request.text(); - if (raw.length > 64 * 1024) { - return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); - } - body = JSON.parse(raw); - } catch { - return apiError("Invalid JSON", 400); - } - - const parsed = moodLogCredentialsSchema.safeParse(body); - if (!parsed.success) { - return apiError("Invalid input", 422); - } - - const { url, apiKey } = parsed.data; - - // Generate webhook secret if not yet set; otherwise reuse the existing - // one (decrypted from at-rest storage; legacy plaintext is also handled). - const existing = await prisma.user.findUnique({ - where: { id: user.id }, - select: { moodLogWebhookSecret: true }, - }); - - const webhookSecret = - readMoodLogSecret(existing?.moodLogWebhookSecret ?? null) ?? - `mb_${randomBytes(32).toString("hex")}`; - - await prisma.user.update({ - where: { id: user.id }, - data: { - moodLogUrlEncrypted: encrypt(url), - moodLogApiKeyEncrypted: encrypt(apiKey), - moodLogEnabled: true, - // V3 audit STILL-V2-C-2: encrypt at rest with AES-256-GCM. A legacy - // plaintext value (rare) is rotated to the encrypted form on this - // write transparently. - moodLogWebhookSecret: encryptMoodLogSecret(webhookSecret), - }, - }); - - annotate({ action: { name: "settings.moodlog.update" } }); - - // Re-saving credentials clears any prior `error_reauth` state so the - // next scheduled sync can run again. We don't write a fresh - // success-time — that happens on the first real sync. - await markReconnected(user.id, "moodlog"); - - return apiSuccess({ - configured: true, - enabled: true, - webhookSecret, - }); -}); - -export const DELETE = apiHandler(async () => { - const { user } = await requireAuth(); - - // Delete all mood entries for this user - await prisma.moodEntry.deleteMany({ - where: { userId: user.id }, - }); - - // v1.4.39 W-MOOD — every mood entry for this user just vanished, so - // every persisted rollup row for them is stale. Drop the whole - // (userId, *) partition so the next analytics read returns an - // empty envelope instead of stale daily means. - await prisma.moodEntryRollup.deleteMany({ where: { userId: user.id } }); - invalidateUserMood(user.id); - - // Clear credentials - await prisma.user.update({ - where: { id: user.id }, - data: { - moodLogUrlEncrypted: null, - moodLogApiKeyEncrypted: null, - moodLogEnabled: false, - moodLogLastSyncedAt: null, - moodLogWebhookSecret: null, - }, - }); - - annotate({ action: { name: "settings.moodlog.disconnect" } }); - - await markDisconnected(user.id, "moodlog"); - - return apiSuccess({ disconnected: true }); -}); diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 38d907201..e7d143c75 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -90,7 +90,6 @@ export interface AdminSettings { reminderMissedMinutes: number; // v1.4.25 W7 — null means "fall back to Europe/Berlin in the resolver". defaultUserTimezone: string | null; - moodLogGlobal?: boolean; // Document vault — the two admin-tunable limits (bytes on the wire; the // UI edits them as MB / GB). The per-file cap clamps to the hard 100 MiB // ceiling server-side. diff --git a/src/components/admin/services-section.tsx b/src/components/admin/services-section.tsx index 27ff8e02b..3137e16c3 100644 --- a/src/components/admin/services-section.tsx +++ b/src/components/admin/services-section.tsx @@ -1,6 +1,6 @@ "use client"; -import { Bell, Globe, Key, MessageCircle, Smile } from "lucide-react"; +import { Bell, Globe, Key, MessageCircle } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; @@ -49,16 +49,6 @@ export function ServicesSection() { } disabled={updateSettings.isPending} /> - - updateSettings.mutate({ moodLogGlobal: checked }) - } - disabled={updateSettings.isPending} - /> ({ queryKey: queryKeys.integrationsStatus(), queryFn: async () => { diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx index cdea7f1bd..ec5cf418c 100644 --- a/src/components/settings/__tests__/integrations-section.test.tsx +++ b/src/components/settings/__tests__/integrations-section.test.tsx @@ -62,7 +62,6 @@ vi.mock("@tanstack/react-query", () => ({ ntfyGlobal: true, webPushGlobal: true, apiGlobal: true, - moodLogGlobal: true, }, isLoading: false, }; @@ -116,18 +115,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { hasActivityScope: true, syncHealth: { verdict: "fresh", since: null }, }, - { - // Not configured here, so this entry claims no surface. The - // configured, card-less case has its own coverage test — this one is - // about one status display per CARD. - integration: "moodlog", - state: "disconnected", - lastSuccessAt: null, - lastAttemptAt: null, - lastError: null, - configured: false, - syncHealth: { verdict: "disconnected", since: null }, - }, ], }); @@ -162,13 +149,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { hasActivityScope: true, syncHealth: { verdict: "failing", since: "2026-05-09T08:00:00.000Z" }, }, - { - integration: "moodlog", - state: "connected", - lastSuccessAt: null, - lastAttemptAt: null, - lastError: null, - }, ], }); @@ -207,13 +187,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { since: "2026-05-09T18:00:00.000Z", }, }, - { - integration: "moodlog", - state: "connected", - lastSuccessAt: null, - lastAttemptAt: null, - lastError: null, - }, ], }); @@ -267,13 +240,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { hasActivityScope: true, syncHealth: { verdict: "parked", since: "2026-05-09T18:00:00.000Z" }, }, - { - integration: "moodlog", - state: "connected", - lastSuccessAt: null, - lastAttemptAt: null, - lastError: null, - }, ], }); diff --git a/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx b/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx index 73f1c33f7..a8cdb2e1f 100644 --- a/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx +++ b/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx @@ -11,6 +11,10 @@ * This is the acceptance test in executable form: render the panel with an * envelope carrying every integration key, and require that each configured * one surfaces either as a bespoke card or as a fallback row. + * + * That provider was retired in v1.32.33, so the card-less case no longer has a + * live instance. It keeps a synthetic one — the fallback row has to be proven + * working before the next card is dropped, not after. */ import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -48,22 +52,35 @@ import { INTEGRATION_DISPLAY_NAMES } from "../integration-fallback-row"; const MONTH_AGO = new Date(Date.now() - 28 * 24 * 60 * 60 * 1000).toISOString(); +/** + * A provider the server ships on the envelope that no card on the panel + * claims. Every provider currently has a card, so the card-less case has to be + * constructed — which is the point: the row must keep working before the next + * card is dropped, not after. Deleting this fixture would retire the fallback + * mechanism's only coverage. + */ +const CARD_LESS_KEY = "example-provider"; + +function entry(integration: string) { + return { + integration, + state: "error_transient", + lastSuccessAt: MONTH_AGO, + lastAttemptAt: MONTH_AGO, + lastError: null, + configured: true, + connected: true, + available: true, + syncHealth: { verdict: "stalled", since: MONTH_AGO }, + metricFreshness: [], + }; +} + /** Every key the server can ship, all configured, all a month silent. */ function fullEnvelope() { return { threshold: 3, - integrations: Object.keys(INTEGRATION_DISPLAY_NAMES).map((integration) => ({ - integration, - state: "error_transient", - lastSuccessAt: MONTH_AGO, - lastAttemptAt: MONTH_AGO, - lastError: null, - configured: true, - connected: true, - available: true, - syncHealth: { verdict: "stalled", since: MONTH_AGO }, - metricFreshness: [], - })), + integrations: Object.keys(INTEGRATION_DISPLAY_NAMES).map(entry), }; } @@ -90,35 +107,44 @@ describe("ConnectionsPanel — every envelope entry surfaces", () => { } }); - it("gives the card-less provider a fallback row with the stalled pill", () => { - // moodLog is the live case: no card since its removal, still on the wire, - // still running an hourly cron against an upstream that stopped answering. - envelope = fullEnvelope(); + it("gives a card-less provider a fallback row with the stalled pill", () => { + // The shape the mechanism exists for. With no display name registered the + // row falls back to the raw key, which is still an honest surface — far + // better than the provider rendering nowhere at all. + envelope = { + threshold: 3, + integrations: [...fullEnvelope().integrations, entry(CARD_LESS_KEY)], + }; const html = render(); expect(html).toContain('data-testid="integration-fallback-row"'); - expect(html).toContain('data-integration="moodlog"'); - expect(html).toContain("moodLog"); + expect(html).toContain(`data-integration="${CARD_LESS_KEY}"`); + expect(html).toContain(CARD_LESS_KEY); expect(html).toContain('data-state="stalled"'); expect(html).toContain("Sync stopped"); expect(html).toContain("Last attempt"); }); - it("renders no fallback row for a provider that is not configured", () => { + it("renders no fallback row when every entry is card-backed", () => { + // The steady state: nothing unclaimed, so the panel adds no catch-all row. + envelope = fullEnvelope(); + expect(render()).not.toContain('data-testid="integration-fallback-row"'); + }); + + it("renders no fallback row for a card-less provider that is not configured", () => { // Absence stays absence: an unconfigured provider is not a problem to // report, so the panel says nothing about it. envelope = { threshold: 3, integrations: [ { - integration: "moodlog", + ...entry(CARD_LESS_KEY), state: "disconnected", lastSuccessAt: null, lastAttemptAt: null, - lastError: null, configured: false, + connected: false, syncHealth: { verdict: "disconnected", since: null }, - metricFreshness: [], }, ], }; diff --git a/src/components/settings/integrations/integration-fallback-row.tsx b/src/components/settings/integrations/integration-fallback-row.tsx index 1c1a2cfe6..70df9e721 100644 --- a/src/components/settings/integrations/integration-fallback-row.tsx +++ b/src/components/settings/integrations/integration-fallback-row.tsx @@ -49,7 +49,6 @@ export const INTEGRATION_DISPLAY_NAMES: Record = { oura: "Oura", "google-health": "Google Health", strava: "Strava", - moodlog: "moodLog", }; export function IntegrationFallbackRow({ diff --git a/src/components/settings/integrations/notification-channels-panel.tsx b/src/components/settings/integrations/notification-channels-panel.tsx index 192b0442c..bd5552851 100644 --- a/src/components/settings/integrations/notification-channels-panel.tsx +++ b/src/components/settings/integrations/notification-channels-panel.tsx @@ -32,7 +32,6 @@ interface GlobalServiceAvailability { ntfyGlobal: boolean; webPushGlobal: boolean; apiGlobal: boolean; - moodLogGlobal: boolean; } export function NotificationChannelsPanel() { diff --git a/src/components/settings/integrations/shared.tsx b/src/components/settings/integrations/shared.tsx index 74a889692..24583bec8 100644 --- a/src/components/settings/integrations/shared.tsx +++ b/src/components/settings/integrations/shared.tsx @@ -27,7 +27,6 @@ export type IntegrationKey = | "withings" | "whoop" | "fitbit" - | "moodlog" | "polar" | "oura" // v1.27.0 — Google Health (Fitbit + Pixel Watch + Fitbit Air). @@ -68,9 +67,6 @@ export interface IntegrationStatusViewModel { // paints a distinct re-consent CTA off this flag, separate from the parked // state. Server-populated on the `/api/integrations/status` envelope. needsReauth?: boolean; - // moodLog webhook secret + entry count. - webhookSecret?: string | null; - entryCount?: number; // Polar / Oura OAuth card: usable-credentials + BYO-key flags. `available` // greys out the connect button when no credentials resolve; `hasOwnCredentials` // drives the saved-placeholder UI. diff --git a/src/components/ui/password-input.tsx b/src/components/ui/password-input.tsx index e9149c3ce..2489fe2be 100644 --- a/src/components/ui/password-input.tsx +++ b/src/components/ui/password-input.tsx @@ -9,7 +9,7 @@ import { useTranslations } from "@/lib/i18n/context"; /** * Password input with a built-in show/hide toggle. Lives in the shared UI - * layer so every surface that needs a masked secret (Withings, moodLog, + * layer so every surface that needs a masked secret (Withings, Nightscout, * ntfy, Telegram, AI provider keys, account password, auth login / * register) can share the same UX without duplicating the * visibility-toggle plumbing. diff --git a/src/lib/__tests__/export.test.ts b/src/lib/__tests__/export.test.ts index 4d620d5b4..c4a2a7ef1 100644 --- a/src/lib/__tests__/export.test.ts +++ b/src/lib/__tests__/export.test.ts @@ -50,8 +50,8 @@ describe("toCSV", () => { // Spreadsheet formula injection (OWASP CSV injection): text cells // starting with a formula trigger are neutralised with a leading `'`. - // Third-party text reaches these cells (mood notes via the moodLog - // webhook), so a crafted payload must open as literal text. + // Third-party text reaches these cells (notes carried in on an import), + // so a crafted payload must open as literal text. it("neutralises formula prefixes in text cells", () => { const records = [ { note: '=HYPERLINK("http://evil","x")' }, diff --git a/src/lib/__tests__/moodlog-secret.test.ts b/src/lib/__tests__/moodlog-secret.test.ts deleted file mode 100644 index d4508c60b..000000000 --- a/src/lib/__tests__/moodlog-secret.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { - encryptMoodLogSecret, - readMoodLogSecret, - isLegacyPlaintext, - rotateLegacyMoodLogSecrets, -} from "../moodlog-secret"; - -const ORIGINAL_ENCRYPTION_KEY = process.env.ENCRYPTION_KEY; - -beforeEach(() => { - process.env.ENCRYPTION_KEY = "a".repeat(64); -}); - -afterEach(() => { - if (ORIGINAL_ENCRYPTION_KEY === undefined) delete process.env.ENCRYPTION_KEY; - else process.env.ENCRYPTION_KEY = ORIGINAL_ENCRYPTION_KEY; -}); - -// V3 audit STILL-V2-C-2: mood_log_webhook_secret was stored in plaintext. -// The new contract encrypts at rest with AES-256-GCM and tolerates legacy -// plaintext rows during the transition window. -describe("moodLog secret encrypt-at-rest", () => { - it("encrypts and decrypts a generated secret round-trip", () => { - const plaintext = "mb_" + "f".repeat(64); - const encrypted = encryptMoodLogSecret(plaintext); - expect(encrypted).not.toBe(plaintext); - expect(readMoodLogSecret(encrypted)).toBe(plaintext); - }); - - it("treats unrecognised values as legacy plaintext (transitional grace)", () => { - expect(readMoodLogSecret("mb_legacyplaintextsecret")).toBe( - "mb_legacyplaintextsecret", - ); - }); - - it("returns null for null/empty stored values", () => { - expect(readMoodLogSecret(null)).toBeNull(); - expect(readMoodLogSecret("")).toBeNull(); - }); - - it("isLegacyPlaintext detects rotation status correctly", () => { - const enc = encryptMoodLogSecret("mb_test"); - expect(isLegacyPlaintext(enc)).toBe(false); - expect(isLegacyPlaintext("mb_test")).toBe(true); - expect(isLegacyPlaintext(null)).toBe(false); - }); - - it("throws when an envelope-shaped row fails to decrypt (tamper or stale key)", () => { - // `.` shape but the keyId is unknown to the live - // config — must surface as an error, not silently return the - // ciphertext as if it were the plaintext secret. - expect(() => readMoodLogSecret("v9.AAAAAAAAAAAAAAAAAAAAAAA=")).toThrow(); - }); - - it("treats envelope-shaped tampered ciphertext as a hard failure", () => { - // Round-trip an encrypted value, then flip a byte in the payload. - // The shape stays envelope-like, so decrypt must throw (AES-GCM - // auth-tag mismatch) instead of falling back to plaintext. - const enc = encryptMoodLogSecret("mb_test"); - const dot = enc.indexOf("."); - const tampered = `${enc.slice(0, dot + 1)}AA${enc.slice(dot + 3)}`; - expect(() => readMoodLogSecret(tampered)).toThrow(); - }); - - it("rotateLegacyMoodLogSecrets encrypts only legacy rows and is idempotent", async () => { - const enc = encryptMoodLogSecret("mb_alreadyencrypted"); - const rows = [ - { id: "u1", moodLogWebhookSecret: "mb_legacy1" }, - { id: "u2", moodLogWebhookSecret: enc }, - { id: "u3", moodLogWebhookSecret: "mb_legacy3" }, - { id: "u4", moodLogWebhookSecret: null }, - ]; - const updates: Array<{ id: string; encrypted: string }> = []; - - const rotated = await rotateLegacyMoodLogSecrets({ - findLegacy: async () => rows, - rotate: async (id, encrypted) => { - updates.push({ id, encrypted }); - }, - }); - - expect(rotated).toBe(2); - expect(updates.map((u) => u.id).sort()).toEqual(["u1", "u3"]); - for (const update of updates) { - // Each rotation must produce a value that decrypts back to the - // original legacy plaintext — no data loss. - const original = rows.find( - (r) => r.id === update.id, - )!.moodLogWebhookSecret; - expect(readMoodLogSecret(update.encrypted)).toBe(original); - } - - // Re-running on the now-encrypted store rotates nothing. - const next = await rotateLegacyMoodLogSecrets({ - findLegacy: async () => [ - { id: "u1", moodLogWebhookSecret: updates[0].encrypted }, - { id: "u2", moodLogWebhookSecret: enc }, - ], - rotate: async () => { - throw new Error("should not be called"); - }, - }); - expect(next).toBe(0); - }); -}); diff --git a/src/lib/__tests__/ssrf-pin-inventory.test.ts b/src/lib/__tests__/ssrf-pin-inventory.test.ts index d4abdc1a6..f6e29ed6d 100644 --- a/src/lib/__tests__/ssrf-pin-inventory.test.ts +++ b/src/lib/__tests__/ssrf-pin-inventory.test.ts @@ -41,14 +41,6 @@ describe("SSRF requirePublicHost pin inventory", () => { // v1.11.2 product-lead M2 (optional) — lock the PRE-EXISTING user/operator // webhook pins too, so the full inventory is CI-guarded and a future edit // that drops one of these reds here rather than shipping silently. - it("moodLog push pins the user-supplied webhook outbound", () => { - expect(read("moodlog/push.ts")).toMatch(/requirePublicHost:\s*true/); - }); - - it("moodLog sync pins the user-supplied webhook outbound", () => { - expect(read("moodlog/sync.ts")).toMatch(/requirePublicHost:\s*true/); - }); - it("ntfy sender pins the user/operator-supplied server outbound", () => { expect(read("notifications/senders/ntfy.ts")).toMatch( /requirePublicHost:\s*true/, diff --git a/src/lib/ai/coach/glp1-snapshot.ts b/src/lib/ai/coach/glp1-snapshot.ts index 316114f2a..cbfeb6faf 100644 --- a/src/lib/ai/coach/glp1-snapshot.ts +++ b/src/lib/ai/coach/glp1-snapshot.ts @@ -170,7 +170,7 @@ const SIDE_EFFECT_TAGS = new Set([ function parseTagList(raw: string | null): string[] { if (!raw) return []; // The MoodEntry.tags column carries either a JSON array (modern - // mood-form writes) or a comma-separated list (legacy moodLog + // mood-form writes) or a comma-separated list (legacy imported // imports). Be permissive. try { const parsed = JSON.parse(raw); diff --git a/src/lib/analytics/bp-in-target.ts b/src/lib/analytics/bp-in-target.ts index f48b9401e..fa10b42a4 100644 --- a/src/lib/analytics/bp-in-target.ts +++ b/src/lib/analytics/bp-in-target.ts @@ -35,7 +35,7 @@ * counting it as good control. * * 3. **No fallback when timestamps don't pair (v1.4.15 fix).** Withings - * imports and moodLog.app imports each write sys + dia as separate + * imports each write sys + dia as separate * Measurement rows that ought to share a `measuredAt` to the second * — but rounding through different timezones can drift them by * minutes. The 5-minute window was tight; data imported with diff --git a/src/lib/app-settings.ts b/src/lib/app-settings.ts index 038addbb6..1287ccdc0 100644 --- a/src/lib/app-settings.ts +++ b/src/lib/app-settings.ts @@ -6,7 +6,6 @@ export interface GlobalServiceAvailability { ntfyGlobal: boolean; webPushGlobal: boolean; apiGlobal: boolean; - moodLogGlobal: boolean; } export async function getGlobalServiceAvailability(): Promise { @@ -18,7 +17,6 @@ export async function getGlobalServiceAvailability(): Promise = new Set([ "lastError", // CoachMessage / InsightNarrative "encryptedContent", - // User (Telegram + moodLog) + // User (Telegram) "telegramBotToken", - "moodLogWebhookSecret", ]); /** diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 39e227968..276062a1b 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -55,9 +55,6 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ { model: "User", field: "codexAccessTokenEncrypted", kind: "string" }, { model: "User", field: "codexRefreshTokenEncrypted", kind: "string" }, { model: "User", field: "telegramBotToken", kind: "string" }, - { model: "User", field: "moodLogWebhookSecret", kind: "string" }, - { model: "User", field: "moodLogUrlEncrypted", kind: "string" }, - { model: "User", field: "moodLogApiKeyEncrypted", kind: "string" }, { model: "User", field: "withingsClientIdEncrypted", kind: "string" }, { model: "User", field: "withingsClientSecretEncrypted", kind: "string" }, { model: "User", field: "whoopClientIdEncrypted", kind: "string" }, diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index a8214d9e0..b5b7100ca 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -468,7 +468,7 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => { input({ syncIssues: [ { integration: "withings", state: "error_reauth" }, - { integration: "moodlog", state: "parked" }, + { integration: "nightscout", state: "parked" }, ], }), t, @@ -508,7 +508,7 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => { medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }), syncIssues: [ { integration: "withings", state: "error_reauth" }, - { integration: "moodlog", state: "parked" }, + { integration: "nightscout", state: "parked" }, { integration: "fitbit", state: "error_reauth" }, ], preventiveDue: [{ label: "Blood panel" }], @@ -646,7 +646,7 @@ describe("buildDailyDigest — coach check-in (S3)", () => { medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }), syncIssues: [ { integration: "withings", state: "error_reauth" }, - { integration: "moodlog", state: "parked" }, + { integration: "nightscout", state: "parked" }, ], coachPlans: [plan()], }), @@ -784,7 +784,7 @@ describe("buildDailyDigest — S11 tension_window item", () => { medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }), syncIssues: [ { integration: "withings", state: "error_reauth" }, - { integration: "moodlog", state: "parked" }, + { integration: "nightscout", state: "parked" }, ], tensionWindow: { partOfDay: "evening" }, }), diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 063e4ad24..5477055ee 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -99,7 +99,7 @@ export interface DailyDigestScore { /** A broken integration, deterministically derived from `IntegrationStatus`. */ export interface DailyDigestSyncIssue { - /** Integration token (`withings`, `moodlog`, …). */ + /** Integration token (`withings`, `whoop`, …). */ integration: string; /** The failure state the row carries (`error_reauth`, `disconnected`, …). */ state: string; diff --git a/src/lib/export.ts b/src/lib/export.ts index 3a05c0947..5ed09e5a5 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -48,7 +48,7 @@ export interface ExportableRecord { * (measurement values, mood scores) stay typed `number` all the way to * this function, so a negative reading exports as `-5.2`, never * `'-5.2`. The text columns are where third-party content can land — - * mood `note` originates from the moodLog webhook, measurement `notes` + * mood `note` originates from user input, measurement `notes` * from arbitrary clients — and a crafted `=HYPERLINK(...)` / * `=cmd|...` there must not execute when an operator opens the export. */ diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 00384b389..82170b68e 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -48,7 +48,6 @@ const appSettings = { webPushVapidPrivateKeyEncrypted: "encrypted-private-key", webPushVapidSubject: "mailto:ops@example.test", apiGlobal: false, - moodLogGlobal: false, umamiEnabled: true, umamiScriptUrl: "https://analytics.example.test/script.js", umamiWebsiteId: "website-id", diff --git a/src/lib/gamification/achievements-result.ts b/src/lib/gamification/achievements-result.ts index ed8115368..0ae30939a 100644 --- a/src/lib/gamification/achievements-result.ts +++ b/src/lib/gamification/achievements-result.ts @@ -669,7 +669,7 @@ export async function buildAchievementsResult( }), // v1.4.18 — mood entries feed the new mood badges + the // entry-streak/consistent-month engagement metrics. Synced from - // moodLog.app or entered directly; we intentionally include all + // imported or entered directly; we intentionally include all // sources because consistency-of-tracking is what the badge // rewards. prisma.moodEntry.findMany({ diff --git a/src/lib/integrations/__tests__/admin-alert.test.ts b/src/lib/integrations/__tests__/admin-alert.test.ts index 681a6989c..90a5a2ac5 100644 --- a/src/lib/integrations/__tests__/admin-alert.test.ts +++ b/src/lib/integrations/__tests__/admin-alert.test.ts @@ -87,18 +87,18 @@ describe("formatAdminAlertPayload — Withings re-auth", () => { }); }); -describe("formatAdminAlertPayload — moodLog transient", () => { - it("uses 'moodLog' display name and the 'investigate the upstream service.' action", () => { +describe("formatAdminAlertPayload — non-Withings transient", () => { + it("uses the provider's display name and the 'investigate the upstream service.' action", () => { const out = formatAdminAlertPayload({ userId: "u-2", - integration: "moodlog", + integration: "nightscout", kind: "transient", - message: "moodLog sync HTTP 502", + message: "Nightscout sync HTTP 502", errorCode: "http_502", consecutiveFailures: 4, subjectLabel: "user@example.com", }); - expect(out.title).toBe("moodLog sync failing for user@example.com"); + expect(out.title).toBe("Nightscout sync failing for user@example.com"); expect(out.message).toContain("Action: investigate the upstream service."); expect(out.message).toContain("(http_502)"); }); @@ -159,14 +159,14 @@ describe("formatAdminAlertPayload — metadata payload", () => { const withoutCode = formatAdminAlertPayload({ userId: "u-6", - integration: "moodlog", + integration: "nightscout", kind: "transient", message: "blip", errorCode: undefined, consecutiveFailures: 7, }); expect(withoutCode.metadata).toEqual({ - integration: "moodlog", + integration: "nightscout", affectedUserId: "u-6", consecutiveFailures: 7, errorCode: null, diff --git a/src/lib/integrations/__tests__/status.test.ts b/src/lib/integrations/__tests__/status.test.ts index 6d78f1f55..4eea74911 100644 --- a/src/lib/integrations/__tests__/status.test.ts +++ b/src/lib/integrations/__tests__/status.test.ts @@ -223,9 +223,9 @@ describe("recordSyncFailure — at threshold", () => { } as never); await recordSyncFailure({ userId: "u1", - integration: "moodlog", + integration: "nightscout", kind: "transient", - message: "moodLog sync HTTP 502", + message: "Nightscout sync HTTP 502", }); expect(dispatchNotification).not.toHaveBeenCalled(); }); @@ -479,7 +479,7 @@ describe("markReauthRequired / markDisconnected / markReconnected", () => { vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce( {} as never, ); - await markDisconnected("u1", "moodlog"); + await markDisconnected("u1", "nightscout"); const args = vi.mocked(prisma.integrationStatus.upsert).mock.calls[0][0]; expect(args.update).toEqual({ state: "disconnected", diff --git a/src/lib/integrations/__tests__/sync-verdict.test.ts b/src/lib/integrations/__tests__/sync-verdict.test.ts index df6c080d2..c119ec25a 100644 --- a/src/lib/integrations/__tests__/sync-verdict.test.ts +++ b/src/lib/integrations/__tests__/sync-verdict.test.ts @@ -35,7 +35,7 @@ describe("resolveSyncVerdict — the ladder", () => { state: "error_transient", lastSuccessAt: ago(55 * DAY), lastAttemptAt, - cadence: INTEGRATION_CADENCE.moodlog, + cadence: INTEGRATION_CADENCE.nightscout, now: NOW, }); expect(result.verdict).toBe("stalled"); diff --git a/src/lib/integrations/metric-freshness.ts b/src/lib/integrations/metric-freshness.ts index 43a3ffc4c..c84e7c972 100644 --- a/src/lib/integrations/metric-freshness.ts +++ b/src/lib/integrations/metric-freshness.ts @@ -35,10 +35,9 @@ export type { } from "./sync-verdict"; /** - * The `MeasurementSource` each sync integration's rows carry. `moodlog` is - * absent — it writes `MoodEntry` rows, not `Measurement` rows, so it has no - * per-metric measurement freshness. `strava` is absent from the measurement - * map for the same reason and picks up its `WORKOUTS` entry below. + * The `MeasurementSource` each sync integration's rows carry. `strava` is + * absent — it writes `Workout` rows, not `Measurement` rows — and picks up its + * `WORKOUTS` entry below. */ export const INTEGRATION_MEASUREMENT_SOURCE: Partial< Record diff --git a/src/lib/integrations/status.ts b/src/lib/integrations/status.ts index 8e87682b6..b844cb4ed 100644 --- a/src/lib/integrations/status.ts +++ b/src/lib/integrations/status.ts @@ -1,8 +1,7 @@ /** * Per-(user, integration) sync-status bookkeeping. * - * v1.4.15 Phase B2 introduces three responsibilities for every Withings / - * moodLog sync attempt: + * v1.4.15 Phase B2 introduces three responsibilities for every sync attempt: * * 1. Record success / failure timestamps so Settings → Integrations * can show "last sync 23 minutes ago" instead of guessing from @@ -23,9 +22,8 @@ * sync entry-point still attempts on the next * run (network blip, 5xx, etc.). * error_reauth → refresh-token grant has revoked. `getValidToken` - * / `syncMoodLogEntries` short-circuit until the - * user reconnects. Cleared by markReconnected() - * on the OAuth callback. + * short-circuits until the user reconnects. Cleared + * by markReconnected() on the OAuth callback. * disconnected → user clicked "Disconnect". Set explicitly by * the disconnect routes; clears any prior error * state. @@ -53,8 +51,7 @@ export type IntegrationKey = // `fitbit` transport, which sunsets Sept 2026. | "google-health" // v1.28.x — Strava OAuth workout source. - | "strava" - | "moodlog"; + | "strava"; /** * Failure kinds carried into `recordSyncFailure`. @@ -311,9 +308,8 @@ export async function isReauthRequired( /** * Record a successful sync. Resets the failure counter, clears any * prior error message, and (importantly) flips state back to - * `connected` even from `error_reauth` — the moodLog flow re-enters - * after the user re-supplies the apiKey, the Withings flow after the - * OAuth callback writes a new refresh token. + * `connected` even from `error_reauth` — the Withings flow re-enters + * after the OAuth callback writes a new refresh token. */ export async function recordSyncSuccess( userId: string, @@ -862,7 +858,6 @@ const INTEGRATION_LABELS: Record = { oura: "Oura", "google-health": "Google Health", strava: "Strava", - moodlog: "moodLog", }; const FAILURE_KIND_COPY: Record< diff --git a/src/lib/integrations/sync-verdict.ts b/src/lib/integrations/sync-verdict.ts index 81c629125..f4ab30bce 100644 --- a/src/lib/integrations/sync-verdict.ts +++ b/src/lib/integrations/sync-verdict.ts @@ -115,14 +115,13 @@ export const INTEGRATION_CADENCE: Record = { oura: POLLED_CADENCE, "google-health": POLLED_CADENCE, strava: POLLED_CADENCE, - moodlog: POLLED_CADENCE, }; export interface SyncVerdictInput { /** * Whether a live connection exists (an OAuth token, a connection row). Pass - * `undefined` for providers that carry no connection concept — moodLog is - * configured by URL alone — so the ladder falls through to `configured`. + * `undefined` for providers that carry no connection concept, so the ladder + * falls through to `configured`. */ connected?: boolean; /** Whether credentials / a URL are stored for this provider. */ diff --git a/src/lib/jobs/geo-backfill.ts b/src/lib/jobs/geo-backfill.ts index 87bb2f6c6..cfe4ce9f0 100644 --- a/src/lib/jobs/geo-backfill.ts +++ b/src/lib/jobs/geo-backfill.ts @@ -51,7 +51,7 @@ export const GEO_BACKFILL_WINDOW_DAYS = 30; /** * v1.4.37 — pg-boss queue name + cron expression for the recurring * backfill. Offset to :40 every hour so the schedule does not collide - * with the existing :00 / :15 / :30 Withings + moodlog crons that + * with the existing :00 / :15 / :30 Withings crons that * already crowd the top of the hour. The helper is idempotent + the * batch cap is per-call, so an hourly cadence is the right balance * between freshness of the admin sign-in "Standort" column and the diff --git a/src/lib/jobs/intake-auto-skip.ts b/src/lib/jobs/intake-auto-skip.ts index 5ecb3856b..07ea45f72 100644 --- a/src/lib/jobs/intake-auto-skip.ts +++ b/src/lib/jobs/intake-auto-skip.ts @@ -61,7 +61,7 @@ export const INTAKE_AUTO_SKIP_QUEUE = "intake-auto-skip"; /** * Cron schedule. Hourly at :05 so the tick doesn't collide with the - * top-of-the-hour reminder-check (:00) or the moodlog-sync (:30) + * top-of-the-hour reminder-check (:00) or the rollup fold (:30) * cadences. Europe/Berlin matches the rest of the worker's schedules. */ export const INTAKE_AUTO_SKIP_CRON = "5 * * * *"; diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index e61f62348..59c7036db 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -20,7 +20,6 @@ import { assertSubsystemEnabled } from "@/lib/process-type"; import { DATABASE_URL, workerLog } from "./reminder/shared"; import { registerShutdownHandlers, - rotateLegacyMoodLogSecretsAtBoot, reconcileImportJobsAtBoot, probeIntegrationStatusAtBoot, } from "./reminder/worker-lifecycle"; @@ -60,10 +59,6 @@ export async function startReminderWorker() { setGlobalBoss(boss); markWorkerStarted(); - // V3 audit STILL-V2-C-2: encrypt-at-rest one-shot migration for any - // plaintext mood_log_webhook_secret rows. Idempotent. - await rotateLegacyMoodLogSecretsAtBoot(); - // Graceful shutdown: drain in-flight jobs on SIGTERM/SIGINT. registerShutdownHandlers(boss); diff --git a/src/lib/jobs/reminder/moodlog-sync.ts b/src/lib/jobs/reminder/moodlog-sync.ts deleted file mode 100644 index deca022fe..000000000 --- a/src/lib/jobs/reminder/moodlog-sync.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * moodLog hourly sync handler. - * - * Extracted from reminder-worker.ts, which owns the queue names, cron - * schedules, and boss.work registrations. - */ -import { type Job } from "pg-boss"; -import { recordError } from "@/lib/jobs/worker-status"; -import { withBackgroundEvent } from "@/lib/logging/background"; -import { syncMoodLogEntries } from "@/lib/moodlog/sync"; -import { getWorkerPrisma } from "./shared"; - -export interface MoodLogSyncPayload { - triggeredAt: string; -} - -/** - * Fallback polling for moodLog data. - * Syncs mood entries for all users with moodLog enabled. - */ -export async function handleMoodLogSync(jobs: Job[]) { - void jobs; - await withBackgroundEvent("job.moodlog_sync", async (evt) => { - const prisma = getWorkerPrisma(); - try { - // Check global toggle - const appSettings = await prisma.appSettings.findUnique({ - where: { id: "singleton" }, - select: { moodLogGlobal: true }, - }); - if (appSettings && !appSettings.moodLogGlobal) { - evt.addMeta("skipped", "global_toggle_disabled"); - return; - } - - const users = await prisma.user.findMany({ - where: { moodLogEnabled: true }, - select: { id: true }, - }); - - if (users.length === 0) return; - - let synced = 0; - let totalImported = 0; - - for (const user of users) { - try { - const imported = await syncMoodLogEntries(user.id); - synced++; - totalImported += imported; - } catch (err) { - evt.addWarning(`Fallback sync failed for user ${user.id}: ${err}`); - } - } - - evt.setBackground({ - task_name: "job.moodlog_sync", - result: { - synced, - total: users.length, - entries_imported: totalImported, - }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); -} diff --git a/src/lib/jobs/reminder/register-integration-sync.ts b/src/lib/jobs/reminder/register-integration-sync.ts index cb5e85d74..85a5e1bf2 100644 --- a/src/lib/jobs/reminder/register-integration-sync.ts +++ b/src/lib/jobs/reminder/register-integration-sync.ts @@ -4,7 +4,7 @@ * Owns the third-party-provider sync queues — Withings (fallback / activity / * sleep), WHOOP (recovery / sleep / workout / cycle + backfill + OAuth-state * cleanup), Fitbit (sync + backfill + sleep repair + OAuth-state cleanup), - * Nightscout, Polar, Oura, MoodLog, and the two one-shot backfills + * Nightscout, Polar, Oura, and the two one-shot backfills * (sleep-timeline, lab-biomarker) — plus their boot-time discovery enqueues. * * v1.4.37 dead-queue contract: every queue name appears in `allQueues`, its @@ -107,7 +107,6 @@ import { handleWhoopWorkoutSync, handleWhoopCycleSync, } from "./whoop-sync"; -import { MoodLogSyncPayload, handleMoodLogSync } from "./moodlog-sync"; import { WithingsOAuthStateCleanupPayload, handleWithingsOAuthStateCleanup, @@ -159,9 +158,6 @@ const WITHINGS_SLEEP_CRON = "15 * * * *"; // every hour at :15 // 20,30,35,50) staggered off every other hourly sync tick. const WITHINGS_ECG_SYNC_CRON = "41 * * * *"; // every hour at :41 -const MOODLOG_SYNC_QUEUE = "moodlog-sync"; - -const MOODLOG_SYNC_CRON = "30 * * * *"; // every hour at :30 // v1.4.47 W6 — daily sweep for the Withings OAuth state ledger. Slots // at :20 between the audit-log cleanup (:15) and the mood-reminder // cleanup (:25), inside the existing 02:xx-03:xx maintenance window. @@ -274,7 +270,6 @@ const allQueues = [ WITHINGS_ACTIVITY_QUEUE, WITHINGS_SLEEP_QUEUE, WITHINGS_ECG_SYNC_QUEUE, - MOODLOG_SYNC_QUEUE, WITHINGS_OAUTH_STATE_CLEANUP_QUEUE, // v1.11.0 — WHOOP sync queues. Webhook-primary + cron-safety-net for // recovery / sleep / workout; cycle is poll-only (no WHOOP webhook). @@ -338,7 +333,6 @@ const schedules: ScheduleEntry[] = [ // v1.32.23 — hourly ECG catch-net (:41) so watch-only accounts pull their // strips without a webhook or a scale. [WITHINGS_ECG_SYNC_QUEUE, WITHINGS_ECG_SYNC_CRON], - [MOODLOG_SYNC_QUEUE, MOODLOG_SYNC_CRON], [WITHINGS_OAUTH_STATE_CLEANUP_QUEUE, WITHINGS_OAUTH_STATE_CLEANUP_CRON], // v1.11.0 — WHOOP poll-fallback crons. Recovery/sleep/workout catch // dropped webhooks; cycle is the sole driver (no webhook). Staggered off @@ -775,12 +769,6 @@ export async function registerIntegrationSyncQueues( } }, ); - await boss.work( - MOODLOG_SYNC_QUEUE, - { localConcurrency: 1 }, - handleMoodLogSync, - ); - return allQueues; } diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index 1ff006be8..921f1595e 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -446,8 +446,8 @@ const schedules: ScheduleEntry[] = [ [MEDICATION_INVENTORY_EXPIRE_QUEUE, MEDICATION_INVENTORY_EXPIRE_CRON], // v1.4.46 — hourly auto-skip for medication intakes the user never // marked. Cron `5 * * * *` slots off the top-of-the-hour - // reminder-check (:00) and the moodlog-sync (:30) so the three - // hourly ticks don't pile up on the same boss poll. + // reminder-check (:00) so the hourly ticks don't pile up on the same + // boss poll. [INTAKE_AUTO_SKIP_QUEUE, INTAKE_AUTO_SKIP_CRON], // v0.5.4 ios-coord — daily mood-reminder dispatch-ledger retention sweep. [MOOD_REMINDER_CLEANUP_QUEUE, MOOD_REMINDER_CLEANUP_CRON], diff --git a/src/lib/jobs/reminder/worker-lifecycle.ts b/src/lib/jobs/reminder/worker-lifecycle.ts index 9b54fb85b..9f50ddee9 100644 --- a/src/lib/jobs/reminder/worker-lifecycle.ts +++ b/src/lib/jobs/reminder/worker-lifecycle.ts @@ -2,7 +2,7 @@ * Reminder-worker boot lifecycle helpers. * * These run once per worker process around the queue/schedule/handler wiring - * in `reminder-worker.ts`: a graceful-shutdown signal binding plus three + * in `reminder-worker.ts`: a graceful-shutdown signal binding plus two * fire-and-forget boot maintenance passes. None of them touch the pg-boss * queue declarations, the `allQueues` array, the `schedules` table, or the * `boss.work` bindings — those stay co-located in `reminder-worker.ts` so the @@ -11,7 +11,6 @@ import type { PgBoss } from "pg-boss"; import { reconcileOrphanImportJobs } from "@/lib/jobs/apple-health-import-worker"; -import { rotateLegacyMoodLogSecrets } from "@/lib/moodlog-secret"; import { probeIntegrationStatusNullBuckets } from "@/lib/jobs/integration-status-null-probe"; import { withBackgroundEvent } from "@/lib/logging/background"; @@ -43,39 +42,6 @@ export function registerShutdownHandlers(boss: PgBoss): void { process.once("SIGINT", () => void onSignal("SIGINT")); } -/** - * V3 audit STILL-V2-C-2: encrypt-at-rest one-shot migration. Rotates any rows - * that still hold a plaintext mood_log_webhook_secret to the AES-256-GCM - * envelope. Idempotent — encrypted rows are skipped. Never throws; a failure - * is logged and boot continues. - */ -export async function rotateLegacyMoodLogSecretsAtBoot(): Promise { - try { - const p = getWorkerPrisma(); - const rotated = await rotateLegacyMoodLogSecrets({ - findLegacy: () => - p.user.findMany({ - where: { moodLogWebhookSecret: { not: null } }, - select: { id: true, moodLogWebhookSecret: true }, - }), - rotate: async (id, encryptedSecret) => { - await p.user.update({ - where: { id }, - data: { moodLogWebhookSecret: encryptedSecret }, - }); - }, - }); - if (rotated > 0) { - workerLog( - "error", - `moodlog-secret-migration: rotated ${rotated} legacy plaintext secret(s)`, - ); - } - } catch (err) { - workerLog("error", `moodlog-secret-migration failed: ${err}`); - } -} - /** * v1.4.34 — reconcile any `ImportJob` rows that were mid-parse when the worker * last shut down. Flips orphaned rows to `failed` so the operator can diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index 6d1c1637d..e5c7526c5 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -600,10 +600,10 @@ describe("get_integration_status", () => { Date.now() - 28 * 24 * 60 * 60 * 1000, ).toISOString(); vi.mocked(prisma.integrationStatus.findMany).mockResolvedValue([ - { integration: "moodlog" }, + { integration: "nightscout" }, ] as never); vi.mocked(getIntegrationStatus).mockResolvedValue({ - integration: "moodlog", + integration: "nightscout", state: "error_transient", lastSuccessAt: new Date( Date.now() - 55 * 24 * 60 * 60 * 1000, @@ -620,7 +620,7 @@ describe("get_integration_status", () => { providers: Array>; }; expect(result.providers[0]).toMatchObject({ - provider: "moodlog", + provider: "nightscout", connected: true, verdict: "stalled", since: lastAttemptAt, diff --git a/src/lib/mcp/__tests__/writes.test.ts b/src/lib/mcp/__tests__/writes.test.ts index 22c1eea9f..78f7e7213 100644 --- a/src/lib/mcp/__tests__/writes.test.ts +++ b/src/lib/mcp/__tests__/writes.test.ts @@ -29,10 +29,6 @@ vi.mock("@/lib/rollups/mood-rollups", () => ({ recomputeMoodBucketsForEntry: vi.fn().mockResolvedValue(undefined), })); -vi.mock("@/lib/moodlog/push", () => ({ - pushMoodEntriesToMoodLog: vi.fn().mockResolvedValue(undefined), -})); - const auditLog = vi.fn().mockResolvedValue(undefined); vi.mock("@/lib/auth/audit", () => ({ auditLog: (...args: unknown[]) => auditLog(...args), diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index d84ace1ac..46be574db 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -1484,7 +1484,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [ name: "get_integration_status", title: "Get device & service sync status", description: - "Fetch the sync health of the user's connected devices and services (Withings, WHOOP, Fitbit, Nightscout, Polar, Oura, moodLog) — which are connected, when each last synced, and whether one needs reconnecting or is failing. Answers 'why is my data stale?'. Reuses the integration-status ledger; carries no secrets or tokens. `connected` means only 'not explicitly disconnected'; `verdict` is the liveness truth (fresh / stale / stalled / failing / reauth_required / parked / pending_first_sync / disconnected), where `stalled` means the sync has stopped even attempting. Returns { present: false } when no integration has ever attempted a sync.", + "Fetch the sync health of the user's connected devices and services (Withings, WHOOP, Fitbit, Nightscout, Polar, Oura, Strava, Google Health) — which are connected, when each last synced, and whether one needs reconnecting or is failing. Answers 'why is my data stale?'. Reuses the integration-status ledger; carries no secrets or tokens. `connected` means only 'not explicitly disconnected'; `verdict` is the liveness truth (fresh / stale / stalled / failing / reauth_required / parked / pending_first_sync / disconnected), where `stalled` means the sync has stopped even attempting. Returns { present: false } when no integration has ever attempted a sync.", inputShape: {}, annotations: READ_ONLY_ANNOTATIONS, outputShape: getIntegrationStatusOutput, diff --git a/src/lib/mcp/writes.ts b/src/lib/mcp/writes.ts index 0db69f8e2..f5ef982cb 100644 --- a/src/lib/mcp/writes.ts +++ b/src/lib/mcp/writes.ts @@ -39,9 +39,8 @@ import { import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels"; -import { getScoreForMood } from "@/lib/validations/moodlog"; +import { getScoreForMood } from "@/lib/validations/mood"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; -import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push"; /** The MCP write provenance — distinct from MANUAL / TELEGRAM. */ const MCP_SOURCE = "MCP" as const; @@ -619,18 +618,6 @@ export async function logMcpMood(input: { ); } - // Reverse-sync to MoodLog (fire-and-forget; never throws). - void pushMoodEntriesToMoodLog(input.userId, [ - { - date: entry.date, - moodLoggedAt, - mood: entry.mood, - note: savedNote, - tags: entry.tags, - source: MCP_SOURCE, - }, - ]).catch(() => {}); - await auditLog("mcp.write.mood", { userId: input.userId, details: { source: MCP_SOURCE, idempotencyKey: input.idempotencyKey }, diff --git a/src/lib/mood/__tests__/create-from-telegram.test.ts b/src/lib/mood/__tests__/create-from-telegram.test.ts index b0c56846c..fc84ec9e0 100644 --- a/src/lib/mood/__tests__/create-from-telegram.test.ts +++ b/src/lib/mood/__tests__/create-from-telegram.test.ts @@ -20,10 +20,6 @@ vi.mock("@/lib/rollups/mood-rollups", () => ({ recomputeMoodBucketsForEntry: vi.fn().mockResolvedValue(undefined), })); -vi.mock("@/lib/moodlog/push", () => ({ - pushMoodEntriesToMoodLog: vi.fn().mockResolvedValue(undefined), -})); - vi.mock("@/lib/logging/context", () => ({ getEvent: () => ({ addMeta: vi.fn() }), })); diff --git a/src/lib/mood/create-from-telegram.ts b/src/lib/mood/create-from-telegram.ts index b8512f798..1e1c9371d 100644 --- a/src/lib/mood/create-from-telegram.ts +++ b/src/lib/mood/create-from-telegram.ts @@ -6,8 +6,8 @@ * the user from the linked chat instead. This helper holds the shared * write so the two entry points agree on the canonical shape: the same * `getScoreForMood` / `moodDateKey` derivation, the same per-user `tz` - * anchoring, the same cache invalidation + DAY-rollup recompute + MoodLog - * reverse-sync push the route fires. `source` is pinned to "TELEGRAM" + * anchoring, the same cache invalidation + DAY-rollup recompute the route + * fires. `source` is pinned to "TELEGRAM" * (already an accepted free-text source value, no enum migration needed). * * The `userId` is NEVER taken from the Telegram payload — the caller @@ -16,12 +16,11 @@ import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import { prisma as defaultPrisma } from "@/lib/db"; import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels"; -import { getScoreForMood } from "@/lib/validations/moodlog"; +import { getScoreForMood } from "@/lib/validations/mood"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; -import { encryptNote, readNote } from "@/lib/crypto/note-cipher"; +import { encryptNote } from "@/lib/crypto/note-cipher"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; -import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push"; import { getEvent } from "@/lib/logging/context"; export interface TelegramMoodResult { @@ -109,18 +108,6 @@ export async function logTelegramMood(input: { ); } - // Reverse-sync to MoodLog (fire-and-forget; never throws). - void pushMoodEntriesToMoodLog(input.userId, [ - { - date: entry.date, - moodLoggedAt, - mood: entry.mood, - note: readNote(entry.noteEncrypted, entry.note), - tags: entry.tags, - source: "TELEGRAM", - }, - ]).catch(() => {}); - return { created: true, moodEntryId: entry.id, score }; } diff --git a/src/lib/moodlog-secret.ts b/src/lib/moodlog-secret.ts deleted file mode 100644 index 762a6ca4f..000000000 --- a/src/lib/moodlog-secret.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * moodLog webhook secret — encrypt-at-rest helpers (V3 audit STILL-V2-C-2). - * - * Previously the `mood_log_webhook_secret` column stored the secret in - * plaintext. The webhook handler did a timing-safe `Buffer.equals` against - * every enabled user's plaintext secret to find a match. - * - * The new contract: - * - All writes go through `encryptMoodLogSecret()` → AES-256-GCM - * base64 envelope. - * - Reads go through `readMoodLogSecret()` which decrypts envelopes; - * if a stored value is NOT a valid envelope it is treated as a legacy - * plaintext secret (transitional grace period). Operators can leave - * existing rows in place — the next write rotates them to encrypted. - * - * The webhook lookup still iterates candidates, but each candidate is - * decrypted before the timing-safe compare. Cost is O(n) decrypt calls - * per webhook invocation; acceptable for small deployments and easily - * upgraded later by adding an HMAC lookup column. - */ -import { encrypt, decrypt, extractKeyId } from "@/lib/crypto"; - -export function encryptMoodLogSecret(plaintext: string): string { - return encrypt(plaintext); -} - -export function readMoodLogSecret(stored: string | null): string | null { - if (!stored) return null; - // Anything carrying a `.` envelope must decrypt - // cleanly. A failure here means either the active key map no longer - // includes the version this row was written under (rotation playbook - // not finished) or the ciphertext was tampered with directly in the - // column. Returning the raw envelope bytes as if they were the - // plaintext secret would silently turn a corrupted row into an - // attacker-chosen webhook secret — so surface the failure instead. - if (extractKeyId(stored) !== null) { - return decrypt(stored); - } - // Legacy plaintext path — unprefixed values predate the encrypt-at- - // rest contract. Return as-is so the integration keeps working until - // the next write rotates them; `rotateLegacyMoodLogSecrets()` runs - // at boot to convert them. - return stored; -} - -/** - * Whether the stored value still looks like an unencrypted legacy secret. - * Used by the boot-time migration helper + the rotation flow. - */ -export function isLegacyPlaintext(stored: string | null): boolean { - if (!stored) return false; - // A row is legacy plaintext only when it does NOT carry the - // `.` envelope. Envelope-shaped rows are encrypted — - // even ones we currently can't decrypt (which should fail loud, not - // get re-rotated as if they were plaintext). - return extractKeyId(stored) === null; -} - -interface MoodLogSecretMigrationDeps { - findLegacy: () => Promise< - Array<{ id: string; moodLogWebhookSecret: string | null }> - >; - rotate: (id: string, encryptedSecret: string) => Promise; -} - -/** - * One-shot startup migration: encrypts any leftover plaintext secret rows - * with AES-GCM. Idempotent — encrypted rows are skipped automatically. - * Returns the number of rows rotated. - */ -export async function rotateLegacyMoodLogSecrets( - deps: MoodLogSecretMigrationDeps, -): Promise { - const candidates = await deps.findLegacy(); - let rotated = 0; - for (const row of candidates) { - if (!row.moodLogWebhookSecret) continue; - if (!isLegacyPlaintext(row.moodLogWebhookSecret)) continue; - const encrypted = encryptMoodLogSecret(row.moodLogWebhookSecret); - await deps.rotate(row.id, encrypted); - rotated += 1; - } - return rotated; -} diff --git a/src/lib/moodlog/__tests__/deprecation.test.ts b/src/lib/moodlog/__tests__/deprecation.test.ts deleted file mode 100644 index 51ab5534e..000000000 --- a/src/lib/moodlog/__tests__/deprecation.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -/** - * v1.12.0 — moodLog is deprecated (superseded by native mood entries + - * structured tags + rated factors), but kept FUNCTIONAL this release. This - * test pins the `@deprecated` JSDoc marker on the surface so a future edit - * that touches these files can't silently drop the deprecation notice - * before the planned removal. - */ -const ROOT = join(__dirname, "..", "..", "..", ".."); - -const DEPRECATED_FILES = [ - "src/app/api/integrations/moodlog/webhook/route.ts", - "src/lib/moodlog/push.ts", - "src/lib/moodlog/sync.ts", - "src/lib/validations/moodlog.ts", -]; - -describe("moodLog deprecation markers", () => { - for (const rel of DEPRECATED_FILES) { - it(`marks ${rel} with @deprecated`, () => { - const src = readFileSync(join(ROOT, rel), "utf8"); - expect(src).toContain("@deprecated"); - // The marker must mention the superseding native path so the note is - // actionable, not a bare tag. - expect(src.toLowerCase()).toContain("superseded"); - }); - } -}); diff --git a/src/lib/moodlog/__tests__/persistence.test.ts b/src/lib/moodlog/__tests__/persistence.test.ts deleted file mode 100644 index f470ab1ff..000000000 --- a/src/lib/moodlog/__tests__/persistence.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/db", () => ({ - prisma: { - moodEntry: { - upsert: vi.fn(), - updateMany: vi.fn(), - findUnique: vi.fn(), - update: vi.fn(), - }, - }, -})); - -import { prisma } from "@/lib/db"; -import { persistMoodLogSourceEntry } from "../persistence"; - -describe("persistMoodLogSourceEntry concurrent legacy adoption", () => { - beforeEach(() => { - vi.resetAllMocks(); - }); - - it("applies the losing replay payload to the row adopted by the winner", async () => { - const moodLoggedAt = new Date("2026-07-19T08:30:00.000Z"); - vi.mocked(prisma.moodEntry.upsert).mockRejectedValueOnce( - Object.assign(new Error("natural-key collision"), { code: "P2002" }), - ); - vi.mocked(prisma.moodEntry.updateMany).mockResolvedValueOnce({ - count: 0, - }); - vi.mocked(prisma.moodEntry.findUnique).mockResolvedValueOnce({ - date: "2026-07-19", - moodLoggedAt, - } as never); - vi.mocked(prisma.moodEntry.update).mockResolvedValueOnce({} as never); - - await persistMoodLogSourceEntry("user-1", { - externalId: "provider-event-1", - date: "2026-07-19", - moodLoggedAt, - mood: "SUPER_GUT", - score: 5, - tags: ["updated"], - }); - - expect(prisma.moodEntry.update).toHaveBeenCalledWith({ - where: { - userId_source_externalId: { - userId: "user-1", - source: "MOODLOG", - externalId: "provider-event-1", - }, - }, - data: { - mood: "SUPER_GUT", - score: 5, - tags: JSON.stringify(["updated"]), - source: "MOODLOG", - deletedAt: null, - date: "2026-07-19", - moodLoggedAt, - }, - }); - }); -}); diff --git a/src/lib/moodlog/__tests__/push.test.ts b/src/lib/moodlog/__tests__/push.test.ts deleted file mode 100644 index 3c150e981..000000000 --- a/src/lib/moodlog/__tests__/push.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -/** - * v1.4.50 — reverse-sync push helper. - * - * The push side mirrors the pull (`sync.ts`) tier by tier: stored - * credentials → SSRF guard → bounded fetch → typed response folding. - * The tests pin the contract the iOS + web mood-entry create handlers - * rely on, so a future refactor of the helper can't silently break - * the fire-and-forget call sites that no longer await the result. - */ -import { describe, it, expect, beforeEach, vi } from "vitest"; - -// safeFetch's requirePublicHost path runs through undici's own `fetch` -// (version-locked with its dispatcher). Delegate it to the global `fetch` -// stub these tests install so the existing interception still applies. -vi.mock("undici", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - fetch: (input: unknown, init?: unknown) => - (globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)( - input, - init, - ), - }; -}); - -vi.mock("@/lib/db", () => ({ - prisma: { user: { findUnique: vi.fn() } }, -})); -vi.mock("@/lib/crypto", () => ({ - decrypt: (v: string) => v.replace(/^enc:/, ""), -})); -vi.mock("@/lib/validations/notifications", () => ({ - isPublicUrl: (u: string) => /^https?:\/\/(?!127\.|localhost|10\.)/.test(u), -})); -vi.mock("@/lib/logging/context", () => ({ - getEvent: () => ({ - addWarning: vi.fn(), - addExternalCall: vi.fn(), - }), -})); - -import { pushMoodEntriesToMoodLog } from "../push"; -import { prisma } from "@/lib/db"; - -const USER_OK = { - moodLogEnabled: true, - moodLogUrlEncrypted: "enc:https://moodlog.example.com", - moodLogApiKeyEncrypted: "enc:secret-key-123", -}; - -const ENTRY = { - date: "2026-05-24", - moodLoggedAt: new Date("2026-05-24T08:30:00Z"), - mood: "GUT", - note: "Felt productive after my run.", - tags: JSON.stringify(["exercise", "morning"]), - source: "MANUAL", -}; - -describe("pushMoodEntriesToMoodLog", () => { - let fetchMock: ReturnType; - - beforeEach(() => { - fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ imported: 1, updated: 0, failed: 0 }), { - status: 200, - }), - ); - globalThis.fetch = fetchMock as never; - vi.mocked(prisma.user.findUnique).mockResolvedValue(USER_OK as never); - }); - - it("skips entirely when no entries pass the MOODLOG filter", async () => { - const result = await pushMoodEntriesToMoodLog("user-1", [ - { ...ENTRY, source: "MOODLOG" }, - ]); - expect(result).toEqual({ pushed: 0, skipped: 1, status: "skipped" }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("skips when the user has no moodLog credentials stored", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - moodLogEnabled: false, - moodLogUrlEncrypted: null, - moodLogApiKeyEncrypted: null, - } as never); - const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]); - expect(result.status).toBe("skipped"); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("refuses to push when the stored URL points at a non-public host", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - ...USER_OK, - moodLogUrlEncrypted: "enc:http://127.0.0.1:3000", - } as never); - const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]); - expect(result.status).toBe("failed"); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("posts the entry to /api/integrations/health-log/mood with HEALTHLOG source", async () => { - const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]); - expect(result.status).toBe("ok"); - expect(result.pushed).toBe(1); - expect(fetchMock).toHaveBeenCalledOnce(); - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toContain("/api/integrations/health-log/mood"); - expect(init.method).toBe("POST"); - expect((init.headers as Record).Authorization).toBe( - "Bearer secret-key-123", - ); - const body = JSON.parse(init.body as string) as { - entries: Array<{ - source: string; - time: string; - date: string; - mood: string; - tags?: string[]; - }>; - }; - expect(body.entries).toHaveLength(1); - expect(body.entries[0].source).toBe("HEALTHLOG"); - expect(body.entries[0].date).toBe("2026-05-24"); - expect(body.entries[0].time).toBe("2026-05-24T08:30:00.000Z"); - expect(body.entries[0].mood).toBe("GUT"); - // Tags are parsed from the JSON-string column into an array of - // key strings the MoodLog endpoint accepts directly. - expect(body.entries[0].tags).toEqual(["exercise", "morning"]); - }); - - it("returns ok with pushed=0 when MoodLog responds 200 but no rows were accepted", async () => { - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify({ imported: 0, updated: 0, failed: 1 }), { - status: 200, - }), - ); - const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]); - expect(result.status).toBe("ok"); - expect(result.pushed).toBe(0); - expect(result.skipped).toBe(1); - }); - - it("reports failed on a 5xx without throwing", async () => { - fetchMock.mockResolvedValueOnce( - new Response("upstream down", { status: 502 }), - ); - const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]); - expect(result.status).toBe("failed"); - expect(result.pushed).toBe(0); - }); - - it("reports failed on a network blip (fetch reject) without throwing", async () => { - fetchMock.mockRejectedValueOnce(new Error("ECONNRESET")); - const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]); - expect(result.status).toBe("failed"); - expect(result.pushed).toBe(0); - }); - - it("refuses to follow a 3xx redirect (SSRF defence)", async () => { - fetchMock.mockResolvedValueOnce(new Response("", { status: 302 })); - const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]); - expect(result.status).toBe("failed"); - }); - - it("omits the tags field when the column is null or malformed JSON", async () => { - await pushMoodEntriesToMoodLog("user-1", [{ ...ENTRY, tags: null }]); - const body = JSON.parse( - (fetchMock.mock.calls[0][1] as RequestInit).body as string, - ) as { entries: Array<{ tags?: unknown }> }; - expect("tags" in body.entries[0]).toBe(false); - - fetchMock.mockClear(); - fetchMock.mockResolvedValue( - new Response(JSON.stringify({ imported: 1 }), { status: 200 }), - ); - await pushMoodEntriesToMoodLog("user-1", [ - { ...ENTRY, tags: "not-valid-json{{" }, - ]); - const body2 = JSON.parse( - (fetchMock.mock.calls[0][1] as RequestInit).body as string, - ) as { entries: Array<{ tags?: unknown }> }; - expect("tags" in body2.entries[0]).toBe(false); - }); -}); diff --git a/src/lib/moodlog/__tests__/sync.test.ts b/src/lib/moodlog/__tests__/sync.test.ts deleted file mode 100644 index ad59e1393..000000000 --- a/src/lib/moodlog/__tests__/sync.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/db", () => ({ - prisma: { - user: { - findUnique: vi.fn(), - update: vi.fn(), - }, - moodEntry: { - upsert: vi.fn(), - }, - }, -})); - -vi.mock("@/lib/crypto", () => ({ - decrypt: vi.fn((value: string) => value), -})); - -vi.mock("@/lib/validations/notifications", () => ({ - isPublicUrl: vi.fn(() => true), -})); - -vi.mock("@/lib/safe-fetch", () => ({ - safeFetch: vi.fn(), -})); - -vi.mock("@/lib/integrations/status", () => ({ - isReauthRequired: vi.fn(), - recordSyncFailure: vi.fn(), - recordSyncSuccess: vi.fn(), -})); - -vi.mock("@/lib/rollups/mood-rollups", () => ({ - recomputeUserMoodRollups: vi.fn(), -})); - -vi.mock("@/lib/logging/context", () => ({ - getEvent: vi.fn(() => ({ - addExternalCall: vi.fn(), - addWarning: vi.fn(), - })), -})); - -import { prisma } from "@/lib/db"; -import { - isReauthRequired, - recordSyncFailure, - recordSyncSuccess, -} from "@/lib/integrations/status"; -import { safeFetch } from "@/lib/safe-fetch"; -import { syncMoodLogEntries } from "../sync"; - -const SOURCE_ENTRY = { - id: "provider-event-1", - date: "2026-07-19", - time: "2026-07-19T08:30:00.000Z", - mood: "GUT", - score: 4, - tags: ["walk"], -}; - -describe("syncMoodLogEntries persistence completion", () => { - beforeEach(() => { - vi.resetAllMocks(); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - moodLogEnabled: true, - moodLogUrlEncrypted: "https://moodlog.example.test", - moodLogApiKeyEncrypted: "api-key", - moodLogLastSyncedAt: new Date("2026-07-18T08:30:00.000Z"), - } as never); - vi.mocked(isReauthRequired).mockResolvedValue(false); - vi.mocked(safeFetch).mockResolvedValue( - new Response(JSON.stringify({ version: "1", entries: [SOURCE_ENTRY] }), { - status: 200, - }), - ); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); - }); - - it("holds the watermark and success marker when a source upsert fails", async () => { - const persistenceError = new Error("database unavailable"); - vi.mocked(prisma.moodEntry.upsert).mockRejectedValueOnce(persistenceError); - - await expect(syncMoodLogEntries("user-1")).rejects.toThrow( - "database unavailable", - ); - - expect(prisma.user.update).not.toHaveBeenCalled(); - expect(recordSyncSuccess).not.toHaveBeenCalled(); - expect(recordSyncFailure).toHaveBeenCalledWith({ - userId: "user-1", - integration: "moodlog", - kind: "transient", - message: "database unavailable", - errorCode: "persistence_failed", - }); - }); -}); diff --git a/src/lib/moodlog/persistence.ts b/src/lib/moodlog/persistence.ts deleted file mode 100644 index b9ac8f6b2..000000000 --- a/src/lib/moodlog/persistence.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { prisma } from "@/lib/db"; -import { isP2002 } from "@/lib/prisma-errors"; - -export interface MoodLogSourceEntry { - externalId: string | null; - date: string; - moodLoggedAt: Date; - mood: string; - score: number; - tags?: readonly string[]; -} - -/** - * Persist one moodLog-owned source row using the provider id when available - * and the legacy wall-clock key otherwise. - * - * A provider may add an id after an older delivery already created the row by - * wall-clock key. In that case the external-id upsert collides with the legacy - * unique constraint; adopt that durable row and attach the provider id rather - * than creating a duplicate or retrying forever. - */ -export async function persistMoodLogSourceEntry( - userId: string, - entry: MoodLogSourceEntry, -): Promise { - const tags = entry.tags ? JSON.stringify(entry.tags) : null; - const update = { - mood: entry.mood, - score: entry.score, - tags, - source: "MOODLOG", - deletedAt: null, - } as const; - - if (!entry.externalId) { - await prisma.moodEntry.upsert({ - where: { - userId_date_moodLoggedAt: { - userId, - date: entry.date, - moodLoggedAt: entry.moodLoggedAt, - }, - }, - update, - create: { - userId, - date: entry.date, - moodLoggedAt: entry.moodLoggedAt, - ...update, - }, - }); - return; - } - - let collisionError: unknown; - try { - await prisma.moodEntry.upsert({ - where: { - userId_source_externalId: { - userId, - source: "MOODLOG", - externalId: entry.externalId, - }, - }, - update: { - ...update, - date: entry.date, - moodLoggedAt: entry.moodLoggedAt, - }, - create: { - userId, - date: entry.date, - moodLoggedAt: entry.moodLoggedAt, - externalId: entry.externalId, - ...update, - }, - }); - return; - } catch (err) { - if (!isP2002(err)) throw err; - collisionError = err; - } - - const adopted = await prisma.moodEntry.updateMany({ - where: { - userId, - date: entry.date, - moodLoggedAt: entry.moodLoggedAt, - source: "MOODLOG", - externalId: null, - }, - data: { - ...update, - externalId: entry.externalId, - }, - }); - if (adopted.count === 1) return; - - // A concurrent replay may have adopted the same legacy row first. - const winner = await prisma.moodEntry.findUnique({ - where: { - userId_source_externalId: { - userId, - source: "MOODLOG", - externalId: entry.externalId, - }, - }, - select: { date: true, moodLoggedAt: true }, - }); - if ( - winner?.date === entry.date && - winner.moodLoggedAt.getTime() === entry.moodLoggedAt.getTime() - ) { - await prisma.moodEntry.update({ - where: { - userId_source_externalId: { - userId, - source: "MOODLOG", - externalId: entry.externalId, - }, - }, - data: { - ...update, - date: entry.date, - moodLoggedAt: entry.moodLoggedAt, - }, - }); - return; - } - - throw collisionError; -} diff --git a/src/lib/moodlog/push.ts b/src/lib/moodlog/push.ts deleted file mode 100644 index 21de59dbc..000000000 --- a/src/lib/moodlog/push.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { prisma } from "@/lib/db"; -import { decrypt } from "@/lib/crypto"; -import { getEvent } from "@/lib/logging/context"; -import { isPublicUrl } from "@/lib/validations/notifications"; -import { safeFetch } from "@/lib/safe-fetch"; - -/** - * v1.4.50 — reverse-sync push: HealthLog → MoodLog. - * - * The historic sync direction (`syncMoodLogEntries` in `sync.ts`) - * polls MoodLog every 15 minutes and imports new rows. That's a - * one-way bridge: anything the user logs INSIDE HealthLog (the iOS - * app's mood reminder, the web form, a Telegram nudge handler) never - * reaches MoodLog. The maintainer's report: they tap a mood in iOS, the entry - * lands in HealthLog, but never appears in MoodLog's timeline. - * - * This helper closes that gap. Every successful create on - * `POST /api/mood-entries` and `POST /api/mood-entries/bulk` triggers - * a fire-and-forget push to MoodLog's `POST /api/integrations/health- - * log/mood` (added in MoodLog's matching release). The push: - * - * - Skips rows whose `source` is already `MOODLOG` — those came - * FROM MoodLog via the pull side, pushing them back would echo. - * - Uses the same per-user `moodLogUrlEncrypted` + `moodLogApiKey - * Encrypted` credentials the pull side reads, plus the same - * `isPublicUrl` SSRF guard. - * - Carries `mood`, `note`, `tags` (as key strings), and the - * original `moodLoggedAt` in the body's `time` field so MoodLog's - * upsert dedup key `(userId, date, createdAt)` stays consistent - * across both directions. - * - Tags the inbound entries with `source: "HEALTHLOG"` on the - * MoodLog side so the next pull cycle's `syncMoodLogEntries` can - * filter them out (avoiding an echo-loop where the same row - * bounces back as a MOODLOG-tagged entry). - * - * Failure mode: this is a best-effort push, intentionally NOT awaited - * by the create handler. Network errors, MoodLog being down, or - * MoodLog rejecting the payload all emit a wide-event warning but - * never bubble back to the user's create request. The next entry the - * user logs retries the push; if MoodLog has been down for hours, the - * user can hit "Force sync" in settings to backfill (TBD — for v1.5). - */ -export interface MoodEntryForPush { - /** YYYY-MM-DD, anchored to the user's display timezone. */ - date: string; - /** ISO timestamp — `moodLoggedAt` from the HealthLog row. */ - moodLoggedAt: Date; - mood: string; - note: string | null; - /** JSON-encoded string or null — pull side normalises to key array. */ - tags: string | null; - /** v1.4.50 — only `MANUAL` / `WEB` / `TELEGRAM` / `iOS` push to MoodLog. */ - source: string; -} - -/** - * @deprecated The moodLog integration is superseded by native mood entries - * plus structured tags and rated factors. Mood lives fully inside HealthLog, - * so the reverse-sync bridge no longer adds value. Kept functional for - * existing setups; slated for removal in a future major release. - */ -export async function pushMoodEntriesToMoodLog( - userId: string, - entries: ReadonlyArray, -): Promise<{ - pushed: number; - skipped: number; - status: "ok" | "skipped" | "failed"; -}> { - // Entries originating in MoodLog mustn't loop back. The pull side - // also filters incoming `HEALTHLOG`-tagged rows; this is the - // outbound mirror of that gate. - const candidates = entries.filter((e) => e.source !== "MOODLOG"); - if (candidates.length === 0) { - return { pushed: 0, skipped: entries.length, status: "skipped" }; - } - - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { - moodLogUrlEncrypted: true, - moodLogApiKeyEncrypted: true, - moodLogEnabled: true, - }, - }); - - if ( - !user?.moodLogEnabled || - !user.moodLogUrlEncrypted || - !user.moodLogApiKeyEncrypted - ) { - return { - pushed: 0, - skipped: candidates.length, - status: "skipped", - }; - } - - const baseUrl = decrypt(user.moodLogUrlEncrypted); - const apiKey = decrypt(user.moodLogApiKeyEncrypted); - - if (!isPublicUrl(baseUrl)) { - // Same SSRF guard pull side uses — a stored row pointing at an - // internal target must never receive the user's apiKey. - getEvent()?.addWarning( - `moodlog push refused for ${userId}: stored URL points at non-public host`, - ); - return { pushed: 0, skipped: candidates.length, status: "failed" }; - } - - const url = new URL("/api/integrations/health-log/mood", baseUrl); - - const body = { - entries: candidates.map((e) => ({ - date: e.date, - time: e.moodLoggedAt.toISOString(), - mood: e.mood, - note: e.note, - tags: parseTagKeys(e.tags), - source: "HEALTHLOG" as const, - })), - }; - - const start = performance.now(); - let response: Response; - try { - // requirePublicHost adds the DNS-rebinding pin to the input-time - // isPublicUrl guard (issue #217). The reverse-sync path attaches a - // user-supplied bearer; a rebinding flip would leak it on the - // connect call to the swapped private address. - response = await safeFetch( - url.toString(), - { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }, - { timeoutMs: 10_000, requirePublicHost: true }, - ); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - getEvent()?.addExternalCall({ - service: "moodlog", - method: "pushMoodEntriesToMoodLog", - duration_ms: Math.round(performance.now() - start), - }); - getEvent()?.addWarning( - `moodlog push failed for ${userId} (fetch): ${message}`, - ); - return { pushed: 0, skipped: candidates.length, status: "failed" }; - } - - getEvent()?.addExternalCall({ - service: "moodlog", - method: "pushMoodEntriesToMoodLog", - duration_ms: Math.round(performance.now() - start), - status: response.status, - }); - - // 3xx — manual redirect mode surfaces them. Match the pull-side - // behaviour: a redirect from a public host that ended up pointing - // at an RFC1918 target would otherwise leak the apiKey. - if (response.status >= 300 && response.status < 400) { - getEvent()?.addWarning( - `moodlog push refused redirect for ${userId}: HTTP ${response.status}`, - ); - return { pushed: 0, skipped: candidates.length, status: "failed" }; - } - - if (!response.ok) { - getEvent()?.addWarning( - `moodlog push HTTP ${response.status} for ${userId}`, - ); - return { pushed: 0, skipped: candidates.length, status: "failed" }; - } - - // Surface MoodLog's per-entry receipt counts so the dashboard - // admin diagnostic + Wide Event timeline can attribute partial - // failures correctly (e.g. an unknown tag key dropped on the - // MoodLog side reports `failed > 0` while imported/updated > 0). - type PushResult = { - imported?: number; - updated?: number; - failed?: number; - }; - let result: PushResult | null = null; - try { - result = (await response.json()) as PushResult; - } catch { - // MoodLog should always return JSON; tolerate the parse failure - // so a stale upstream doesn't undo the successful HTTP write. - } - - const imported = result?.imported ?? 0; - const updated = result?.updated ?? 0; - const failed = result?.failed ?? 0; - return { - pushed: imported + updated, - skipped: entries.length - candidates.length + failed, - status: "ok", - }; -} - -/** - * Parse the JSON-encoded `tags` column into an array of key strings. - * Returns an empty array on null / parse failure so the push body - * always carries a well-formed `tags: string[]` (or omitted entirely). - */ -function parseTagKeys(raw: string | null | undefined): string[] | undefined { - if (!raw) return undefined; - try { - const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed)) return undefined; - const keys = parsed - .filter((v): v is string => typeof v === "string" && v.length > 0) - .slice(0, 50); - return keys.length > 0 ? keys : undefined; - } catch { - return undefined; - } -} diff --git a/src/lib/moodlog/sync.ts b/src/lib/moodlog/sync.ts deleted file mode 100644 index 6de5ab246..000000000 --- a/src/lib/moodlog/sync.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { prisma } from "@/lib/db"; -import { decrypt } from "@/lib/crypto"; -import { getEvent } from "@/lib/logging/context"; -import { isPublicUrl } from "@/lib/validations/notifications"; -import { safeFetch } from "@/lib/safe-fetch"; -import { - isReauthRequired, - recordSyncFailure, - recordSyncSuccess, -} from "@/lib/integrations/status"; -import { persistMoodLogSourceEntry } from "@/lib/moodlog/persistence"; -import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups"; - -/** - * Sync mood entries from a user's moodLog instance. - * Fetches from the health-log export endpoint and upserts into local DB. - * - * @deprecated The moodLog integration is superseded by native mood entries - * plus structured tags and rated factors. Mood is tracked fully inside - * HealthLog now, so the external pull bridge no longer adds value. Kept - * functional for existing setups; slated for removal in a future major - * release. - */ -export async function syncMoodLogEntries( - userId: string, - opts?: { fullSync?: boolean }, -): Promise { - // 1. Read user credentials - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { - moodLogUrlEncrypted: true, - moodLogApiKeyEncrypted: true, - moodLogEnabled: true, - moodLogLastSyncedAt: true, - }, - }); - - if ( - !user?.moodLogEnabled || - !user.moodLogUrlEncrypted || - !user.moodLogApiKeyEncrypted - ) { - return 0; - } - - // Park: if we already know the apiKey is rejected, skip the network - // round-trip until the user re-saves credentials. The settings PUT - // route clears this state (see /api/settings/moodlog). - if (await isReauthRequired(userId, "moodlog")) { - getEvent()?.addWarning( - `moodlog sync skipped for ${userId}: parked at error_reauth`, - ); - return 0; - } - - const baseUrl = decrypt(user.moodLogUrlEncrypted); - const apiKey = decrypt(user.moodLogApiKeyEncrypted); - - // SSRF guard at the actual fetch site. The credential write path - // is also guarded (moodLogCredentialsSchema), but a row stored - // before that guard landed could still point at an internal IP. - // Re-checking here means the sync worker refuses internal targets - // even on legacy data, and the user's apiKey is never sent there. - if (!isPublicUrl(baseUrl)) { - getEvent()?.addWarning( - `moodLog sync refused for user ${userId}: stored URL points at non-public host`, - ); - await recordSyncFailure({ - userId, - integration: "moodlog", - kind: "reauth_required", - message: "Stored moodLog URL points at a non-public host", - errorCode: "ssrf_refused", - }); - return 0; - } - - // 2. Determine date range - const now = new Date(); - const to = now.toISOString().slice(0, 10); // YYYY-MM-DD - - let from: string; - if (opts?.fullSync || !user.moodLogLastSyncedAt) { - // Full sync: fetch all available data - from = "2000-01-01"; - } else { - // Incremental: from lastSyncedAt - 1 hour overlap - const d = new Date(user.moodLogLastSyncedAt.getTime() - 3600 * 1000); - from = d.toISOString().slice(0, 10); - } - - // 3. Fetch from moodLog - const url = new URL("/api/integrations/health-log/mood", baseUrl); - url.searchParams.set("from", from); - url.searchParams.set("to", to); - - // safeFetch keeps the no-redirect-follow guard (the apiKey would - // otherwise leak on an attacker-controlled 302 hop) and adds a hard - // upper bound via AbortSignal.timeout. requirePublicHost routes the - // dial through the pinned-IP dispatcher so a DNS rebinding cannot - // flip a public hostname to 169.254.169.254 / RFC1918 between the - // input-time accept and the connect call. - const timeoutMs = opts?.fullSync ? 60_000 : 15_000; - let response: Response; - const fetchStart = performance.now(); - try { - response = await safeFetch( - url.toString(), - { headers: { Authorization: `Bearer ${apiKey}` } }, - { timeoutMs, requirePublicHost: true }, - ); - } catch (err) { - getEvent()?.addExternalCall({ - service: "moodlog", - method: "syncMoodLogEntries", - duration_ms: Math.round(performance.now() - fetchStart), - }); - const message = err instanceof Error ? err.message : String(err); - await recordSyncFailure({ - userId, - integration: "moodlog", - kind: "transient", - message, - errorCode: "fetch_failed", - }); - return 0; - } - getEvent()?.addExternalCall({ - service: "moodlog", - method: "syncMoodLogEntries", - duration_ms: Math.round(performance.now() - fetchStart), - status: response.status, - }); - - // Treat any 3xx as failure (manual redirect mode surfaces them). - if (response.status >= 300 && response.status < 400) { - getEvent()?.addWarning( - `moodLog sync refused redirect for user ${userId}: HTTP ${response.status}`, - ); - await recordSyncFailure({ - userId, - integration: "moodlog", - kind: "transient", - message: `moodLog sync refused redirect (HTTP ${response.status})`, - errorCode: `http_${response.status}`, - }); - return 0; - } - - if (!response.ok) { - getEvent()?.addWarning( - `Sync failed for user ${userId}: HTTP ${response.status}`, - ); - // 401/403 — apiKey rejected. Park the integration so we don't - // hammer the upstream until the user re-saves credentials. - const isAuthFailure = response.status === 401 || response.status === 403; - await recordSyncFailure({ - userId, - integration: "moodlog", - kind: isAuthFailure ? "reauth_required" : "transient", - message: `moodLog sync HTTP ${response.status}`, - errorCode: `http_${response.status}`, - }); - return 0; - } - - let data: unknown; - try { - data = await response.json(); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - await recordSyncFailure({ - userId, - integration: "moodlog", - kind: "transient", - message: `moodLog sync invalid JSON: ${message}`, - errorCode: "invalid_json", - }); - return 0; - } - - // Validate response has entries array - if ( - !data || - typeof data !== "object" || - !("entries" in data) || - !Array.isArray((data as { entries?: unknown }).entries) - ) { - getEvent()?.addWarning(`Invalid response format for user ${userId}`); - await recordSyncFailure({ - userId, - integration: "moodlog", - kind: "transient", - message: "moodLog sync invalid response format (missing 'entries' array)", - errorCode: "invalid_format", - }); - return 0; - } - const entries = (data as { entries: unknown[] }).entries; - - // 4. Upsert entries (note field is intentionally NOT imported) - // - // v1.4.50 — entries MoodLog re-exports with `loggedVia: "HEALTHLOG"` - // are echoes of rows HealthLog pushed via the reverse-sync POST. - // Re-importing them would flip the `source` column from the original - // attribution (MANUAL / WEB / TELEGRAM / iOS) to MOODLOG, losing the - // provenance the user already established and double-counting the - // entry in any source-segregated dashboard. Skip them on the pull - // side so the loop closes at one round-trip. - let imported = 0; - let echoSkipped = 0; - for (const e of entries) { - const entry = e as { - id?: string; - time: string; - date: string; - mood: string; - score: number; - tags?: string[]; - loggedVia?: string; - }; - if (entry.loggedVia === "HEALTHLOG") { - echoSkipped += 1; - continue; - } - try { - const moodLoggedAt = new Date(entry.time); - const date = entry.date; - // v1.12.1 — when the upstream supplies a stable id, upsert on - // `(userId, source, externalId)` so a re-pull with a re-rounded / - // re-zoned `time` is idempotent; otherwise the legacy wall-clock - // key. The id is bounded to the column size to match the webhook. - const externalId = - typeof entry.id === "string" && entry.id.length > 0 - ? entry.id.slice(0, 120) - : null; - - await persistMoodLogSourceEntry(userId, { - externalId, - date, - moodLoggedAt, - mood: entry.mood, - score: entry.score, - tags: entry.tags, - }); - imported++; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - getEvent()?.addWarning(`Failed to upsert moodLog entry: ${message}`); - await recordSyncFailure({ - userId, - integration: "moodlog", - kind: "transient", - message, - errorCode: "persistence_failed", - }); - throw err; - } - } - - // 5. Update lastSyncedAt + clear sync-status streak - await prisma.user.update({ - where: { id: userId }, - data: { moodLogLastSyncedAt: now }, - }); - await recordSyncSuccess(userId, "moodlog"); - - // v1.4.50 — annotate the echo-skip count so an operator can see how - // many reverse-sync entries the pull side filtered out per cycle. - // Should match the user's recent `pushMoodEntriesToMoodLog` count; - // a divergent number signals either a stale MoodLog deploy - // (pre-HEALTHLOG-source) or a configuration drift. - if (echoSkipped > 0) { - getEvent()?.addExternalCall({ - service: "moodlog", - method: "syncMoodLogEntries.echoSkipped", - duration_ms: 0, - }); - } - - // v1.4.39 W-MOOD — re-fold the rollup tier after a sync. The - // sync upserts a batch spanning many days; one bounded - // recompute is cheaper than firing N per-row hooks. Best-effort: - // a rollup failure here must not bubble up and undo the - // recordSyncSuccess we just committed. - if (imported > 0) { - try { - await recomputeUserMoodRollups(userId, { granularities: ["DAY"] }); - } catch (err) { - getEvent()?.addWarning(`Mood rollup recompute failed after sync: ${err}`); - } - } - - return imported; -} diff --git a/src/lib/openapi/routes/mood.ts b/src/lib/openapi/routes/mood.ts index 4cdcdf468..0ca5e2f99 100644 --- a/src/lib/openapi/routes/mood.ts +++ b/src/lib/openapi/routes/mood.ts @@ -23,7 +23,7 @@ import { hideCatalogueTagSchema, } from "@/lib/mood/custom-tags"; import { moodTagLayoutSchema } from "@/lib/mood/tag-layout"; -import { moodLevelEnum, moodSourceEnum } from "@/lib/validations/moodlog"; +import { moodLevelEnum, moodSourceEnum } from "@/lib/validations/mood"; import { dataEnvelope, errorEnvelope, diff --git a/src/lib/query-keys/integrations.ts b/src/lib/query-keys/integrations.ts index 6d03f8313..be0fea8af 100644 --- a/src/lib/query-keys/integrations.ts +++ b/src/lib/query-keys/integrations.ts @@ -1,6 +1,6 @@ /** * Query keys — external integrations: API tokens, Telegram, Withings, - * WHOOP, Fitbit, moodLog, and the Apple Health export import job. + * WHOOP, Fitbit, and the Apple Health export import job. * Part of the centralized factory; aggregated in `./index.ts`. */ export const integrationKeys = { @@ -63,7 +63,6 @@ export const integrationKeys = { strava: () => ["strava"] as const, stravaStatus: () => ["strava", "status"] as const, - moodlogStatus: () => ["moodlog-status"] as const, integrationsStatus: () => ["integrations", "status"] as const, // Apple Health live-sync freshness comes from the existing HealthKit config // endpoint. It is intentionally separate from the OAuth integration ledger: diff --git a/src/lib/targets/mood-builder.ts b/src/lib/targets/mood-builder.ts index 65a2e9f88..2e48e76c7 100644 --- a/src/lib/targets/mood-builder.ts +++ b/src/lib/targets/mood-builder.ts @@ -113,7 +113,7 @@ export function buildMoodTargets({ ? { category: "Moderate", color: "var(--dracula-yellow)" } : { category: "Low", color: "var(--destructive)" } : null, - source: "moodLog", + source: "Mood entries", ...consistency, }, ]; @@ -137,7 +137,7 @@ export function buildMoodTargets({ : standardDeviation <= 1 ? { category: "Stable", color: "var(--dracula-yellow)" } : { category: "Fluctuating", color: "var(--destructive)" }, - source: "moodLog", + source: "Mood entries", ...consistency, }); } diff --git a/src/lib/validations/__tests__/backup.test.ts b/src/lib/validations/__tests__/backup.test.ts index aba2c9535..d65b46652 100644 --- a/src/lib/validations/__tests__/backup.test.ts +++ b/src/lib/validations/__tests__/backup.test.ts @@ -10,7 +10,7 @@ * * We accept three shapes: * - `null` (and omitted/undefined): "no tags on this entry" - * - empty string `""`: legacy moodLog wire format (handled as null) + * - empty string `""`: legacy wire format (handled as null) * - a JSON-string that parses to an array of strings. * * Anything else (object, JSON-with-numbers, garbage) is rejected at @@ -51,7 +51,7 @@ describe("moodEntrySchema.tags strict validation", () => { expect(r.success).toBe(true); }); - it("accepts an empty string (legacy moodLog wire format)", () => { + it("accepts an empty string (legacy wire format)", () => { const r = parseWithMoodTags(""); expect(r.success).toBe(true); }); diff --git a/src/lib/validations/__tests__/moodlog.test.ts b/src/lib/validations/__tests__/mood.test.ts similarity index 64% rename from src/lib/validations/__tests__/moodlog.test.ts rename to src/lib/validations/__tests__/mood.test.ts index 29f039389..182473b6e 100644 --- a/src/lib/validations/__tests__/moodlog.test.ts +++ b/src/lib/validations/__tests__/mood.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - moodLogCredentialsSchema, - createMoodEntrySchema, - updateMoodEntrySchema, -} from "../moodlog"; +import { createMoodEntrySchema, updateMoodEntrySchema } from "../mood"; describe("createMoodEntrySchema — note + structured tagKeys (v1.8.5)", () => { const base = { mood: "GUT", moodLoggedAt: "2026-06-01T12:00:00.000Z" }; @@ -71,44 +67,3 @@ describe("createMoodEntrySchema — note + structured tagKeys (v1.8.5)", () => { ).toBe(false); }); }); - -describe("moodLogCredentialsSchema SSRF guard", () => { - it("accepts a public moodLog URL", () => { - const r = moodLogCredentialsSchema.safeParse({ - url: "https://moodlog.app", - apiKey: "k".repeat(40), - }); - expect(r.success).toBe(true); - }); - - it("rejects RFC1918 URLs", () => { - for (const url of [ - "http://10.0.0.1", - "http://192.168.1.1", - "http://172.16.0.1", - "http://127.0.0.1", - ]) { - const r = moodLogCredentialsSchema.safeParse({ - url, - apiKey: "k".repeat(40), - }); - expect(r.success, `expected reject for ${url}`).toBe(false); - } - }); - - it("rejects link-local (cloud metadata) URLs", () => { - const r = moodLogCredentialsSchema.safeParse({ - url: "http://169.254.169.254/latest/meta-data/iam/security-credentials/", - apiKey: "k".repeat(40), - }); - expect(r.success).toBe(false); - }); - - it("rejects localhost", () => { - const r = moodLogCredentialsSchema.safeParse({ - url: "http://localhost:8080", - apiKey: "k".repeat(40), - }); - expect(r.success).toBe(false); - }); -}); diff --git a/src/lib/validations/admin.ts b/src/lib/validations/admin.ts index 889bdfe42..be2cce810 100644 --- a/src/lib/validations/admin.ts +++ b/src/lib/validations/admin.ts @@ -66,7 +66,6 @@ export const adminSettingsSchema = z glitchtipEnvironment: z.string().optional(), reminderLateMinutes: z.number().int().min(15).max(480).optional(), reminderMissedMinutes: z.number().int().min(30).max(720).optional(), - moodLogGlobal: z.boolean().optional(), // Document vault — the two admin-tunable limits. The per-file cap is // bounded by the hard 100 MiB ceiling (single-shot GCM + bounded // in-memory reads are load-bearing past that; the runtime resolver diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 48acd29cc..326bec119 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -320,7 +320,6 @@ const appSettingsBackupSchema = z webPushVapidPrivateKeyEncrypted: z.string().nullable(), webPushVapidSubject: z.string().nullable(), apiGlobal: z.boolean(), - moodLogGlobal: z.boolean(), umamiEnabled: z.boolean(), umamiScriptUrl: z.string().nullable(), umamiWebsiteId: z.string().nullable(), diff --git a/src/lib/validations/moodlog.ts b/src/lib/validations/mood.ts similarity index 67% rename from src/lib/validations/moodlog.ts rename to src/lib/validations/mood.ts index afb7a1073..2d3a5ce30 100644 --- a/src/lib/validations/moodlog.ts +++ b/src/lib/validations/mood.ts @@ -1,70 +1,13 @@ -import { z } from "zod/v4"; -import { isPublicUrl } from "@/lib/validations/notifications"; -import { validateEntryInstant } from "@/lib/validations/entry-instant"; - /** - * @deprecated The standalone moodLog integration is superseded by native - * mood entries plus structured tags and rated factors — mood is tracked - * fully inside HealthLog now. These schemas remain only to keep the - * existing webhook + sync paths functional; the surface is slated for - * removal in a future major release. The native mood schemas - * (`createMoodEntrySchema` etc.) below are the supported path. + * Mood-entry validation schemas. + * + * v1.32.33 — the file previously also carried the credential, webhook and + * pull-response schemas of the retired moodLog bridge. Mood is tracked + * natively, so only the entry schemas remain and the module is named for + * what it validates. */ -export const moodLogCredentialsSchema = z.object({ - url: z - .string() - .url() - .max(500) - // SSRF guard: stored URL must point at a public host. The sync - // worker fetches from this URL with the user's apiKey in the - // Authorization header, so a stored RFC1918 / link-local target - // would let any user pull cloud-metadata or local-network data - // back through their account. - .refine((u) => isPublicUrl(u), { - message: "URL must point at a public host (no RFC1918 / link-local)", - }), - apiKey: z.string().min(1).max(200), -}); - -export const moodLogWebhookPayloadSchema = z.object({ - event: z.enum(["mood.created", "mood.updated", "mood.deleted"]), - timestamp: z.string().datetime(), - entry: z.object({ - // v1.12.1 — optional source-stable entry id. When present, the - // webhook dedups on `(userId, source, externalId)` so a re-emit with - // a re-rounded / re-zoned `time` is idempotent instead of minting a - // second row. Absent → the legacy `(userId, date, moodLoggedAt)` - // path. Bounded so a malformed upstream id can't bloat the column. - id: z.string().min(1).max(120).optional(), - date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), - time: z.string().datetime(), - mood: z.enum(["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"]), - score: z.number().int().min(1).max(5), - tags: z.array(z.string()).max(50).optional(), - loggedVia: z.enum(["WEB", "TELEGRAM", "DAYLIO"]).optional(), - }), -}); - -export const moodLogSyncResponseSchema = z.object({ - version: z.string(), - entries: z.array( - z.object({ - // v1.12.1 — optional source-stable id, mirrors the webhook entry. - // Carried into `externalId` for idempotent re-import. - id: z.string().min(1).max(120).optional(), - date: z.string(), - time: z.string(), - mood: z.string(), - score: z.number(), - tags: z.array(z.string()).optional(), - loggedVia: z.string().optional(), - }), - ), -}); - -export type MoodLogCredentials = z.infer; -export type MoodLogWebhookPayload = z.infer; -export type MoodLogSyncResponse = z.infer; +import { z } from "zod/v4"; +import { validateEntryInstant } from "@/lib/validations/entry-instant"; // --- CRUD schemas for mood entries --- @@ -76,6 +19,12 @@ export const moodLevelEnum = z.enum([ "LAUSIG", ]); +/** + * Provenance of a stored mood entry. `MOODLOG` is a legacy value: the bridge + * that wrote it was removed in v1.32.33, but rows it created are the user's + * own history and keep their label, so the list filter and the source badge + * must keep resolving it. Nothing mints new `MOODLOG` rows. + */ export const moodSourceEnum = z.enum([ "MANUAL", "MOODLOG", diff --git a/src/proxy.ts b/src/proxy.ts index ea7e0e998..1a2b435f5 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -43,7 +43,6 @@ const PUBLIC_PATHS = [ // must bypass the auth gate (mirrors the Withings webhook entry). "/api/whoop/webhook", "/api/telegram/webhook", - "/api/integrations/moodlog/webhook", "/api/ingest/", // v1.4.26 — `/privacy` is a public legal page. iOS App Store Connect // requires a publicly reachable Privacy-Policy URL during submission; diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index ac4c7ca13..506fad988 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -131,7 +131,6 @@ describe("canonical disaster-recovery backup round-trip", () => { webPushVapidPrivateKeyEncrypted: "private-dr", webPushVapidSubject: "mailto:dr@example.test", apiGlobal: false, - moodLogGlobal: false, umamiEnabled: true, umamiScriptUrl: "https://analytics.example.test/script.js", umamiWebsiteId: "website-dr", diff --git a/tests/integration/integration-status.test.ts b/tests/integration/integration-status.test.ts index 93090064c..98bd8abb6 100644 --- a/tests/integration/integration-status.test.ts +++ b/tests/integration/integration-status.test.ts @@ -114,14 +114,14 @@ describe("IntegrationStatus end-to-end", () => { it("getIntegrationStatus returns plaintext error after roundtrip (decryption succeeds)", async () => { await recordSyncFailure({ userId: TEST_USER_ID, - integration: "moodlog", + integration: "nightscout", kind: "transient", - message: "moodLog sync HTTP 502", + message: "Nightscout sync HTTP 502", errorCode: "http_502", }); - const snapshot = await getIntegrationStatus(TEST_USER_ID, "moodlog"); + const snapshot = await getIntegrationStatus(TEST_USER_ID, "nightscout"); expect(snapshot.state).toBe("error_transient"); - expect(snapshot.lastError).toBe("moodLog sync HTTP 502"); + expect(snapshot.lastError).toBe("Nightscout sync HTTP 502"); // v1.4.47 W1 — snapshot drops the legacy `consecutiveFailures` // scalar; the per-kind bucket carries the count. expect(snapshot.consecutiveFailuresByKind).toMatchObject({ @@ -217,12 +217,12 @@ describe("IntegrationStatus end-to-end", () => { it("kind=reauth_required parks the integration so isReauthRequired() returns true", async () => { await recordSyncFailure({ userId: TEST_USER_ID, - integration: "moodlog", + integration: "nightscout", kind: "reauth_required", - message: "moodLog sync HTTP 401", + message: "Nightscout sync HTTP 401", errorCode: "http_401", }); - expect(await isReauthRequired(TEST_USER_ID, "moodlog")).toBe(true); + expect(await isReauthRequired(TEST_USER_ID, "nightscout")).toBe(true); expect(await isReauthRequired(TEST_USER_ID, "withings")).toBe(false); }); @@ -246,14 +246,14 @@ describe("IntegrationStatus end-to-end", () => { it("markDisconnected wipes the error state but leaves the row as a tombstone", async () => { await recordSyncFailure({ userId: TEST_USER_ID, - integration: "moodlog", + integration: "nightscout", kind: "transient", message: "blip", }); - await markDisconnected(TEST_USER_ID, "moodlog"); + await markDisconnected(TEST_USER_ID, "nightscout"); const row = await getPrismaClient().integrationStatus.findUnique({ where: { - userId_integration: { userId: TEST_USER_ID, integration: "moodlog" }, + userId_integration: { userId: TEST_USER_ID, integration: "nightscout" }, }, }); expect(row!.state).toBe("disconnected"); diff --git a/tests/integration/partial-unique-live-rows.test.ts b/tests/integration/partial-unique-live-rows.test.ts index c73ae9870..109a0c674 100644 --- a/tests/integration/partial-unique-live-rows.test.ts +++ b/tests/integration/partial-unique-live-rows.test.ts @@ -1,28 +1,25 @@ /** * v1.12.1 — partial live-row unique index on MedicationIntakeEvent - * (migration 0121) + MoodEntry external-id dedup key (migration 0122). + * (migration 0121). * - * These pin the delete-then-resync / re-import contracts on real - * Postgres, against the migrations replayed by `global-setup.ts`. + * This pins the delete-then-resync contract on real Postgres, against the + * migrations replayed by `global-setup.ts`: the real re-take path through + * `applyCanonicalSlotWrite` takes a slot, soft-deletes it (user "forgot + * this"), then re-takes the SAME canonical slot. Pre-0121 the create P2002'd + * against the tombstone and the catch re-found only live rows (none) and + * re-threw — the re-take 500'd. With the partial live-row unique it re-creates + * cleanly. * - * 1. MedicationIntakeEvent — the real re-take path through - * `applyCanonicalSlotWrite`: take a slot, soft-delete it (user - * "forgot this"), then re-take the SAME canonical slot. Pre-0121 - * the create P2002'd against the tombstone and the catch re-found - * only live rows (none) and re-threw — the re-take 500'd. With the - * partial live-row unique it re-creates cleanly. - * - * 2. MoodEntry — a moodLog re-import with the SAME stable `id` but a - * re-zoned `moodLoggedAt` updates one row (idempotent on - * `(userId, source, externalId)`), while two imports WITHOUT an id - * still land two rows on the legacy wall-clock key. + * The MoodEntry half of this file drove migration 0122's external-id dedup key + * through the moodLog webhook. That bridge was retired in v1.32.33; the same + * index is pinned end-to-end through the surviving native write paths in + * `mood-entries-external-id.test.ts`. * * Note: Measurement is intentionally NOT made partial-unique (its * compound-key writes use `prisma.upsert` → native ON CONFLICT, which * Postgres cannot arbiter against a partial unique). See migration 0121's * header for the full rationale. */ -import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; process.env.ENCRYPTION_KEY ??= @@ -122,300 +119,3 @@ describe("MedicationIntakeEvent — re-take after delete (v1.12.1 / 0121)", () = ).rejects.toThrow(); }); }); - -describe("MoodEntry — external-id re-import idempotency (v1.12.1 / 0122)", () => { - function webhookRequest(secret: string, body: unknown): NextRequest { - return new NextRequest( - "http://localhost/api/integrations/moodlog/webhook", - { - method: "POST", - headers: { - "content-type": "application/json", - "x-webhook-secret": secret, - }, - body: JSON.stringify(body), - }, - ); - } - - it("re-import with the same stable id but re-zoned time updates one row", async () => { - const prisma = getPrismaClient(); - const secret = "mood-secret-idempotent"; - const user = await prisma.user.create({ - data: { - username: "mood-external-id", - email: "mood-external-id@example.test", - role: "USER", - moodLogEnabled: true, - // Legacy plaintext secret — readMoodLogSecret tolerates it. - moodLogWebhookSecret: secret, - }, - }); - - const { POST } = - await import("@/app/api/integrations/moodlog/webhook/route"); - - // First import — entry carries a stable upstream id. - const r1 = await POST( - webhookRequest(secret, { - event: "mood.created", - timestamp: "2026-06-04T10:00:00.000Z", - entry: { - id: "daylio-row-42", - date: "2026-06-04", - time: "2026-06-04T10:00:00.000Z", - mood: "GUT", - score: 4, - }, - }), - ); - expect(r1.status).toBe(200); - - // Re-import — SAME id, but a re-rounded / re-zoned time (and the - // upstream re-scored the mood). Pre-0122 this minted a 2nd row on - // the differing (date, moodLoggedAt) key. - const r2 = await POST( - webhookRequest(secret, { - event: "mood.updated", - timestamp: "2026-06-04T10:30:00.000Z", - entry: { - id: "daylio-row-42", - date: "2026-06-04", - time: "2026-06-04T08:00:00.000Z", - mood: "SUPER_GUT", - score: 5, - }, - }), - ); - expect(r2.status).toBe(200); - - const rows = await prisma.moodEntry.findMany({ - where: { userId: user.id, source: "MOODLOG" }, - }); - expect(rows).toHaveLength(1); - expect(rows[0].externalId).toBe("daylio-row-42"); - // The update applied (mood + score + re-zoned time). - expect(rows[0].mood).toBe("SUPER_GUT"); - expect(rows[0].score).toBe(5); - }); - - it("acknowledges an exact provider replay with one stable durable row", async () => { - const prisma = getPrismaClient(); - const secret = "mood-secret-replay"; - const user = await prisma.user.create({ - data: { - username: "mood-provider-replay", - email: "mood-provider-replay@example.test", - role: "USER", - moodLogEnabled: true, - moodLogWebhookSecret: secret, - }, - }); - const { POST } = - await import("@/app/api/integrations/moodlog/webhook/route"); - const payload = { - event: "mood.created", - timestamp: "2026-06-04T11:00:00.000Z", - entry: { - id: "provider-event-replay-1", - date: "2026-06-04", - time: "2026-06-04T11:00:00.000Z", - mood: "GUT", - score: 4, - }, - }; - - const first = await POST(webhookRequest(secret, payload)); - expect(first.status).toBe(200); - const firstRow = await prisma.moodEntry.findUniqueOrThrow({ - where: { - userId_source_externalId: { - userId: user.id, - source: "MOODLOG", - externalId: "provider-event-replay-1", - }, - }, - }); - - const replay = await POST(webhookRequest(secret, payload)); - expect(replay.status).toBe(200); - const rows = await prisma.moodEntry.findMany({ - where: { userId: user.id, source: "MOODLOG" }, - }); - expect(rows).toHaveLength(1); - expect(rows[0].id).toBe(firstRow.id); - }); - - it("adopts a legacy natural-key row when a replay gains a provider id", async () => { - const prisma = getPrismaClient(); - const secret = "mood-secret-key-upgrade"; - const user = await prisma.user.create({ - data: { - username: "mood-key-upgrade", - email: "mood-key-upgrade@example.test", - role: "USER", - moodLogEnabled: true, - moodLogWebhookSecret: secret, - }, - }); - const { POST } = - await import("@/app/api/integrations/moodlog/webhook/route"); - const entry = { - date: "2026-06-04", - time: "2026-06-04T12:00:00.000Z", - mood: "GUT", - score: 4, - }; - - const legacy = await POST( - webhookRequest(secret, { - event: "mood.created", - timestamp: "2026-06-04T12:00:00.000Z", - entry, - }), - ); - expect(legacy.status).toBe(200); - const legacyRow = await prisma.moodEntry.findFirstOrThrow({ - where: { userId: user.id, source: "MOODLOG" }, - }); - expect(legacyRow.externalId).toBeNull(); - - const upgraded = await POST( - webhookRequest(secret, { - event: "mood.updated", - timestamp: "2026-06-04T12:05:00.000Z", - entry: { ...entry, id: "provider-key-upgrade-1" }, - }), - ); - expect(upgraded.status).toBe(200); - - const rows = await prisma.moodEntry.findMany({ - where: { userId: user.id, source: "MOODLOG" }, - }); - expect(rows).toHaveLength(1); - expect(rows[0].id).toBe(legacyRow.id); - expect(rows[0].externalId).toBe("provider-key-upgrade-1"); - }); - - it("does not adopt an unrelated row that occupies the natural key", async () => { - const prisma = getPrismaClient(); - const secret = "mood-secret-unrelated-collision"; - const user = await prisma.user.create({ - data: { - username: "mood-unrelated-collision", - email: "mood-unrelated-collision@example.test", - role: "USER", - moodLogEnabled: true, - moodLogWebhookSecret: secret, - }, - }); - const moodLoggedAt = new Date("2026-06-04T13:00:00.000Z"); - const manual = await prisma.moodEntry.create({ - data: { - userId: user.id, - date: "2026-06-04", - moodLoggedAt, - mood: "OKAY", - score: 3, - source: "MANUAL", - }, - }); - const { POST } = - await import("@/app/api/integrations/moodlog/webhook/route"); - - const response = await POST( - webhookRequest(secret, { - event: "mood.created", - timestamp: "2026-06-04T13:00:00.000Z", - entry: { - id: "provider-unrelated-collision-1", - date: "2026-06-04", - time: moodLoggedAt.toISOString(), - mood: "GUT", - score: 4, - }, - }), - ); - expect(response.status).toBe(503); - - const row = await prisma.moodEntry.findUniqueOrThrow({ - where: { id: manual.id }, - }); - expect(row).toMatchObject({ - source: "MANUAL", - externalId: null, - mood: "OKAY", - score: 3, - }); - expect(await prisma.moodEntry.count({ where: { userId: user.id } })).toBe( - 1, - ); - }); - - it("entries without an id still dedup on the legacy wall-clock key", async () => { - const prisma = getPrismaClient(); - const secret = "mood-secret-legacy"; - const user = await prisma.user.create({ - data: { - username: "mood-legacy-key", - email: "mood-legacy-key@example.test", - role: "USER", - moodLogEnabled: true, - moodLogWebhookSecret: secret, - }, - }); - - const { POST } = - await import("@/app/api/integrations/moodlog/webhook/route"); - - // Two entries, no id, DIFFERENT times → two rows (legacy behaviour - // preserved). A re-emit at the SAME time updates in place. - await POST( - webhookRequest(secret, { - event: "mood.created", - timestamp: "2026-06-05T09:00:00.000Z", - entry: { - date: "2026-06-05", - time: "2026-06-05T09:00:00.000Z", - mood: "OKAY", - score: 3, - }, - }), - ); - await POST( - webhookRequest(secret, { - event: "mood.created", - timestamp: "2026-06-05T21:00:00.000Z", - entry: { - date: "2026-06-05", - time: "2026-06-05T21:00:00.000Z", - mood: "GUT", - score: 4, - }, - }), - ); - // Re-emit the first at the exact same time → in-place update. - await POST( - webhookRequest(secret, { - event: "mood.updated", - timestamp: "2026-06-05T09:00:00.000Z", - entry: { - date: "2026-06-05", - time: "2026-06-05T09:00:00.000Z", - mood: "SUPER_GUT", - score: 5, - }, - }), - ); - - const rows = await prisma.moodEntry.findMany({ - where: { userId: user.id, source: "MOODLOG" }, - orderBy: { moodLoggedAt: "asc" }, - }); - expect(rows).toHaveLength(2); - expect(rows.every((r) => r.externalId === null)).toBe(true); - // The morning row was updated in place by the same-time re-emit. - expect(rows[0].mood).toBe("SUPER_GUT"); - expect(rows[1].mood).toBe("GUT"); - }); -});