feat(integrations): remove the moodLog integration

Mood has been a first-class module here for a long time, with entries,
structured tags, rated factors and notes. The bridge to the external moodLog
service had nothing left to add, and its Settings card was withdrawn in
a56e6fe61 on the promise of a dedicated removal. This delivers it.

Removed: the hourly `moodlog-sync` queue, cron and handler binding; the
inbound webhook and its shared-secret handling; the reverse push and its four
call sites; the pull-sync module; the per-user credential routes and the
instance-wide toggle; the entry on the `/api/integrations/status` envelope,
the `IntegrationKey` union, the cadence map and the display-name table; the
boot-time secret rotation pass; 22 message keys per locale.

Migration 0271 drops the five `users` credential columns and
`app_settings.mood_log_global`, clears the ledger row for the provider, and
deletes the retired pg-boss schedule so no cron keeps enqueuing work no
handler drains.

Mood data is untouched. `mood_entries` is not named anywhere in the migration
and cannot be reached from it: the dropped columns carry no index, constraint
or foreign key, and `mood_entries.user_id` references `users(id)`, not any of
them. Rows the bridge imported keep `source = 'MOODLOG'`, and every read path
- list filter, source badge, exports, backups - still resolves that value.

`src/lib/validations/moodlog.ts` held the native mood-entry schemas alongside
the bridge's own; it is now `validations/mood.ts` and carries only the former.
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 11:36:18 +02:00
1 parent 4ed8c8f39b
commit d0b45e36b3
106 files changed
+379 -3326

No files matched your search

@@ -39,7 +39,6 @@ body:
- Notifications
- AI Insights
- Withings integration
- moodLog integration
- Self-hosting / Docker
- Other
validations:
+1 -3
View File
@@ -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/
+1 -1
View File
@@ -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: `<surface>:${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.
+1 -1
View File
@@ -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
+3 -3
View File
@@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 620" font-family="ui-sans-serif, -apple-system, Segoe UI, Roboto, sans-serif" role="img" aria-labelledby="title desc">
<title id="title">HealthLog data flow — device to insight</title>
<desc id="desc">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.</desc>
<desc id="desc">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.</desc>
<!-- Dracula background -->
<rect x="0" y="0" width="1200" height="620" fill="#282a36" />
@@ -45,8 +45,8 @@
<text x="120" y="411" text-anchor="middle" fill="#bd93f9" font-size="11">PWA · iOS Shortcuts</text>
<rect x="30" y="444" width="180" height="56" rx="10" fill="#44475a" stroke="#8be9fd" stroke-width="1.5" />
<text x="120" y="469" text-anchor="middle" font-weight="600">moodLog.app</text>
<text x="120" y="487" text-anchor="middle" fill="#bd93f9" font-size="11">webhook</text>
<text x="120" y="469" text-anchor="middle" font-weight="600">Nightscout</text>
<text x="120" y="487" text-anchor="middle" fill="#bd93f9" font-size="11">pull sync</text>
</g>
<!-- INGEST (orange #ffb86c) -->

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+1 -1
View File
@@ -81,7 +81,7 @@
<rect x="1010" y="240" width="160" height="100" rx="10" fill="#44475a" stroke="#8be9fd" stroke-width="1.5" stroke-dasharray="4 4" />
<text x="1090" y="266" text-anchor="middle" fill="#f8f8f2" font-size="13" font-weight="600">Withings</text>
<text x="1090" y="282" text-anchor="middle" fill="#6272a4" font-size="11">Apple Health</text>
<text x="1090" y="298" text-anchor="middle" fill="#6272a4" font-size="11">moodLog.app</text>
<text x="1090" y="298" text-anchor="middle" fill="#6272a4" font-size="11">Nightscout</text>
<text x="1090" y="314" text-anchor="middle" fill="#6272a4" font-size="11">AI provider</text>
<text x="1090" y="332" text-anchor="middle" fill="#bd93f9" font-size="11">(per-user)</text>
</g>

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

+1 -1
View File
@@ -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. |
+2 -2
View File
@@ -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
+8 -27
View File
@@ -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(
-22
View File
@@ -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)",
-22
View File
@@ -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)",
-22
View File
@@ -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)",
-22
View File
@@ -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)",
-22
View File
@@ -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)",
-22
View File
@@ -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)",
@@ -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";
+11 -15
View File
@@ -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")
+1 -1
View File
@@ -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/*`
+2 -3
View File
@@ -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` /
+2 -9
View File
@@ -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 "=============================================================="
+1 -5
View File
@@ -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",
@@ -109,7 +109,6 @@ const LEGIT_IDENTICAL = new Set<string>([
"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",
+105
View File
@@ -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<string, unknown>;
admin: Record<string, unknown>;
};
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<string, string> };
expect(en.mood.sourceMoodlog).toBeTruthy();
});
});
@@ -150,7 +150,7 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
// ── 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",
@@ -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,
@@ -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: "" }));
-3
View File
@@ -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,
+1 -1
View File
@@ -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")
@@ -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,
},
{
@@ -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,
});
});
@@ -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 });
});
@@ -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<typeof import("undici")>();
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: <T extends (...args: unknown[]) => 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<T> {
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<typeof vi.fn>).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<typeof vi.fn>).mock
.calls[0];
const init = fetchCall[1] as RequestInit;
expect(init.method).toBe("GET");
const headers = init.headers as Record<string, string>;
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<typeof vi.fn>).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/);
});
});
@@ -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 },
);
}
});
@@ -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: <T extends (...args: unknown[]) => 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<string, string> = {},
): 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();
});
});
@@ -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<string, unknown>;
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 });
});
@@ -40,7 +40,6 @@ vi.mock("@/lib/integrations/status", () => ({
const perIntegrationLedger: Record<string, Record<string, unknown>> = {};
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 () => {
+5 -50
View File
@@ -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/<provider>/status round-trips are gone
* — it carries every field the cards render (Withings activity scope,
* WHOOP/Fitbit backfill state) so the per-card /api/<provider>/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/<provider>/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;
+1 -4
View File
@@ -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";
@@ -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)", () => {
@@ -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)", () => {
+1 -36
View File
@@ -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<Response> {
}
}
// 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,
+2 -27
View File
@@ -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.
-5
View File
@@ -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,
},
});
-106
View File
@@ -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 });
});
-1
View File
@@ -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.
+1 -11
View File
@@ -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}
/>
<SettingsToggle
label="moodLog"
description={t("admin.moodLogGlobal")}
icon={Smile}
checked={settings?.moodLogGlobal ?? true}
onCheckedChange={(checked) =>
updateSettings.mutate({ moodLogGlobal: checked })
}
disabled={updateSettings.isPending}
/>
<SettingsToggle
label="API"
description={t("admin.apiGlobal")}
+1 -1
View File
@@ -72,7 +72,7 @@ import {
queryKeys,
refetchInactiveDailyReads,
} from "@/lib/query-keys";
import { moodSourceEnum } from "@/lib/validations/moodlog";
import { moodSourceEnum } from "@/lib/validations/mood";
import { Checkbox } from "@/components/ui/checkbox";
import {
SortableHead,
@@ -265,7 +265,7 @@ export function GettingStartedChecklist() {
// v1.17.0 — any connected data source satisfies the step, so we read
// the consolidated integrations envelope (Withings / WHOOP / Fitbit /
// moodLog) rather than the Withings-only status route.
// Google Health / …) rather than the Withings-only status route.
const { data: integrationsData } = useQuery<IntegrationsStatus>({
queryKey: queryKeys.integrationsStatus(),
queryFn: async () => {
@@ -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,
},
],
});
@@ -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: [],
},
],
};
@@ -49,7 +49,6 @@ export const INTEGRATION_DISPLAY_NAMES: Record<IntegrationKey, string> = {
oura: "Oura",
"google-health": "Google Health",
strava: "Strava",
moodlog: "moodLog",
};
export function IntegrationFallbackRow({
@@ -32,7 +32,6 @@ interface GlobalServiceAvailability {
ntfyGlobal: boolean;
webPushGlobal: boolean;
apiGlobal: boolean;
moodLogGlobal: boolean;
}
export function NotificationChannelsPanel() {
@@ -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.
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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")' },
-106
View File
@@ -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)", () => {
// `<keyId>.<payload>` 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);
});
});
@@ -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/,
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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
-4
View File
@@ -6,7 +6,6 @@ export interface GlobalServiceAvailability {
ntfyGlobal: boolean;
webPushGlobal: boolean;
apiGlobal: boolean;
moodLogGlobal: boolean;
}
export async function getGlobalServiceAvailability(): Promise<GlobalServiceAvailability> {
@@ -18,7 +17,6 @@ export async function getGlobalServiceAvailability(): Promise<GlobalServiceAvail
ntfyGlobal: true,
webPushGlobal: true,
apiGlobal: true,
moodLogGlobal: true,
},
});
@@ -27,7 +25,6 @@ export async function getGlobalServiceAvailability(): Promise<GlobalServiceAvail
ntfyGlobal: settings?.ntfyGlobal ?? true,
webPushGlobal: settings?.webPushGlobal ?? true,
apiGlobal: settings?.apiGlobal ?? true,
moodLogGlobal: settings?.moodLogGlobal ?? true,
};
} catch {
getEvent()?.addWarning("Failed to load app settings, using defaults");
@@ -36,7 +33,6 @@ export async function getGlobalServiceAvailability(): Promise<GlobalServiceAvail
ntfyGlobal: true,
webPushGlobal: true,
apiGlobal: true,
moodLogGlobal: true,
};
}
}
@@ -32,9 +32,8 @@ const NON_SUFFIX_ENCRYPTED: ReadonlySet<string> = new Set([
"lastError",
// CoachMessage / InsightNarrative
"encryptedContent",
// User (Telegram + moodLog)
// User (Telegram)
"telegramBotToken",
"moodLogWebhookSecret",
]);
/**
-3
View File
@@ -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" },
+4 -4
View File
@@ -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" },
}),
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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.
*/
@@ -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",
+1 -1
View File
@@ -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({
@@ -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,
@@ -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",
@@ -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");
+3 -4
View File
@@ -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<IntegrationKey, MeasurementSource>
+6 -11
View File
@@ -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<IntegrationKey, string> = {
oura: "Oura",
"google-health": "Google Health",
strava: "Strava",
moodlog: "moodLog",
};
const FAILURE_KIND_COPY: Record<
+2 -3
View File
@@ -115,14 +115,13 @@ export const INTEGRATION_CADENCE: Record<IntegrationKey, SyncCadence> = {
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. */
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 * * * *";
-5
View File
@@ -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);
-70
View File
@@ -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<MoodLogSyncPayload>[]) {
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;
}
});
}
@@ -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<MoodLogSyncPayload>(
MOODLOG_SYNC_QUEUE,
{ localConcurrency: 1 },
handleMoodLogSync,
);
return allQueues;
}
@@ -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],
+1 -35
View File
@@ -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<void> {
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
+3 -3
View File
@@ -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<Record<string, unknown>>;
};
expect(result.providers[0]).toMatchObject({
provider: "moodlog",
provider: "nightscout",
connected: true,
verdict: "stalled",
since: lastAttemptAt,
-4
View File
@@ -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),
+1 -1
View File
@@ -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,
+1 -14
View File
@@ -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 },
@@ -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() }),
}));
+4 -17
View File
@@ -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 };
}
-84
View File
@@ -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 `<keyId>.<payload>` 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
// `<keyId>.<payload>` 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<void>;
}
/**
* 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<number> {
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;
}
@@ -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");
});
}
});
@@ -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,
},
});
});
});
-186
View File
@@ -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<typeof import("undici")>();
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<typeof vi.fn>;
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<string, string>).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);
});
});
-98
View File
@@ -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",
});
});
});
-132
View File
@@ -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<void> {
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;
}
-224
View File
@@ -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<MoodEntryForPush>,
): 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;
}
}
-296
View File
@@ -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<number> {
// 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;
}
+1 -1
View File
@@ -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,
+1 -2
View File
@@ -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:
+2 -2
View File
@@ -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,
});
}
+2 -2
View File
@@ -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);
});
@@ -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);
});
});
-1
View File
@@ -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
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more