diff --git a/CHANGELOG.md b/CHANGELOG.md index 994869fde..d3e1abab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [1.4.50] — 2026-05-24 — MoodLog reverse-sync (HealthLog → MoodLog push) + +Marc reported that mood entries logged inside HealthLog — specifically via the iOS app — never reached MoodLog. The historic integration ran one-way: `syncMoodLogEntries` polled MoodLog every 15 minutes and pulled new rows, but nothing flowed the other direction. A user tracking mood in HealthLog ended up with one log per app and no overlap. + +### Added + +- `src/lib/moodlog/push.ts` — `pushMoodEntriesToMoodLog(userId, entries)`. Best-effort reverse-sync push to MoodLog's new `POST /api/integrations/health-log/mood` endpoint. Same per-user `moodLogUrlEncrypted` + `moodLogApiKeyEncrypted` credentials the pull side uses, same `isPublicUrl` SSRF guard, same manual-redirect policy. Wraps every failure mode in a wide-event warning so a network blip or a stale MoodLog deploy can never bubble back to the user's mood-create request. +- `POST /api/mood-entries` (single) and `POST /api/mood-entries/bulk` now fire the push as a fire-and-forget side-effect after a successful create. Entries with `source === "MOODLOG"` skip inside the helper to avoid an echo loop. Bulk push only includes rows that landed as `inserted` — duplicates and skips don't retry a known failure. + +### Changed + +- `src/lib/moodlog/sync.ts` (pull side) filters out entries where MoodLog reports `loggedVia: "HEALTHLOG"`. Those are echoes of rows we just pushed; re-importing them would flip the `source` column from the original attribution (MANUAL / WEB / TELEGRAM / iOS) to MOODLOG and double-count in any source-segregated dashboard. An `echoSkipped` wide-event annotation lets the operator confirm the round-trip is closing at one hop. + +### Tests + +- `src/lib/moodlog/__tests__/push.test.ts` — 9 cases covering the MOODLOG-source filter, no-credentials skip, SSRF refusal, headers + body shape on the successful path, 5xx + network-reject + 3xx-redirect failure modes, and the `tags` JSON-string → array key normalisation. Pins the contract the call sites no longer await so future helper refactors can't silently break the fire-and-forget integration. +- HealthLog suite: 5279 unit + integration pass, lint clean, typecheck clean. + +### Cross-repo coordination + +The matching MoodLog v0.2.0 release adds the `POST /api/integrations/health-log/mood` handler this push targets. Both apps must deploy together for the round-trip to close — MoodLog first so HealthLog never hits a 405 / 404 on the new endpoint. + ## [1.4.49.4] — 2026-05-24 — BD-tile number truncation + medication-compliance test tz drift Marc reported the dashboard BD (Sys) tile rendering `"13… mmHg"` — the systolic / diastolic pair (`131/85`) was wider than the narrowest grid column and the value-row's `truncate` ellipsis chopped the most important part of the tile (the number itself). diff --git a/package.json b/package.json index 1078d78c3..3f5e5cceb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.4.49.4", + "version": "1.4.50", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index 9af714e1f..c65608b8b 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -46,6 +46,7 @@ import { 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"; const MAX_ENTRIES_PER_BATCH = 500; const BATCH_RATE_LIMIT_MAX = 60; @@ -282,6 +283,40 @@ async function postBulk(request: NextRequest): Promise { } } + // v1.4.50 — reverse-sync the freshly-inserted batch to MoodLog so + // an iOS one-shot backfill surfaces in both apps. Duplicates and + // skips do NOT push (MoodLog already has them — or we'd just be + // retrying a known failure). The helper filters MOODLOG-sourced + // rows internally so a re-ingest of a MoodLog pull doesn't echo. + if (inserted > 0) { + const pushBatch: Array<{ + date: string; + moodLoggedAt: Date; + mood: string; + note: string | null; + tags: string | null; + source: string; + }> = []; + for (let i = 0; i < entries.length; i++) { + if (results[i]?.status !== "inserted") continue; + const e = entries[i]; + const tz = user.timezone ?? DEFAULT_TIMEZONE; + pushBatch.push({ + date: moodDateKey(e.moodLoggedAt, tz), + moodLoggedAt: e.moodLoggedAt, + mood: e.mood, + note: e.note ?? null, + tags: e.tags ? JSON.stringify(e.tags) : null, + source: e.source, + }); + } + if (pushBatch.length > 0) { + void pushMoodEntriesToMoodLog(user.id, pushBatch).catch(() => { + /* helper wraps errors in wide-event warnings; defence-in-depth */ + }); + } + } + return apiSuccess({ processed: entries.length, inserted, diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 22805b071..de4efcfca 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -21,6 +21,7 @@ import { withIdempotency } from "@/lib/idempotency"; 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"; function parseTags(tags: string | null): string[] { if (!tags) return []; @@ -198,6 +199,30 @@ 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: entry.note ?? null, + 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({ ...entry, tags: parseTags(entry.tags) }, 201); } catch (err) { if ( diff --git a/src/lib/moodlog/__tests__/push.test.ts b/src/lib/moodlog/__tests__/push.test.ts new file mode 100644 index 000000000..b2057c126 --- /dev/null +++ b/src/lib/moodlog/__tests__/push.test.ts @@ -0,0 +1,173 @@ +/** + * 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"; + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn() } }, +})); +vi.mock("@/lib/crypto", () => ({ + decrypt: (v: string) => v.replace(/^enc:/, ""), +})); +vi.mock("@/lib/validations/notifications", () => ({ + isPublicUrl: (u: string) => /^https?:\/\/(?!127\.|localhost|10\.)/.test(u), +})); +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ + addWarning: vi.fn(), + addExternalCall: vi.fn(), + }), +})); + +import { pushMoodEntriesToMoodLog } from "../push"; +import { prisma } from "@/lib/db"; + +const USER_OK = { + moodLogEnabled: true, + moodLogUrlEncrypted: "enc:https://moodlog.example.com", + moodLogApiKeyEncrypted: "enc:secret-key-123", +}; + +const ENTRY = { + date: "2026-05-24", + moodLoggedAt: new Date("2026-05-24T08:30:00Z"), + mood: "GUT", + note: "Felt productive after my run.", + tags: JSON.stringify(["exercise", "morning"]), + source: "MANUAL", +}; + +describe("pushMoodEntriesToMoodLog", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ imported: 1, updated: 0, failed: 0 }), + { status: 200 }, + ), + ); + globalThis.fetch = fetchMock as never; + vi.mocked(prisma.user.findUnique).mockResolvedValue(USER_OK as never); + }); + + it("skips entirely when no entries pass the MOODLOG filter", async () => { + const result = await pushMoodEntriesToMoodLog("user-1", [ + { ...ENTRY, source: "MOODLOG" }, + ]); + expect(result).toEqual({ pushed: 0, skipped: 1, status: "skipped" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("skips when the user has no moodLog credentials stored", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + moodLogEnabled: false, + moodLogUrlEncrypted: null, + moodLogApiKeyEncrypted: null, + } as never); + const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]); + expect(result.status).toBe("skipped"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("refuses to push when the stored URL points at a non-public host", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + ...USER_OK, + moodLogUrlEncrypted: "enc:http://127.0.0.1:3000", + } as never); + const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]); + expect(result.status).toBe("failed"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("posts the entry to /api/integrations/health-log/mood with HEALTHLOG source", async () => { + const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]); + expect(result.status).toBe("ok"); + expect(result.pushed).toBe(1); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/api/integrations/health-log/mood"); + expect(init.method).toBe("POST"); + expect((init.headers as Record).Authorization).toBe( + "Bearer secret-key-123", + ); + const body = JSON.parse(init.body as string) as { + entries: Array<{ + source: string; + time: string; + date: string; + mood: string; + tags?: string[]; + }>; + }; + expect(body.entries).toHaveLength(1); + expect(body.entries[0].source).toBe("HEALTHLOG"); + expect(body.entries[0].date).toBe("2026-05-24"); + expect(body.entries[0].time).toBe("2026-05-24T08:30:00.000Z"); + expect(body.entries[0].mood).toBe("GUT"); + // Tags are parsed from the JSON-string column into an array of + // key strings the MoodLog endpoint accepts directly. + expect(body.entries[0].tags).toEqual(["exercise", "morning"]); + }); + + it("returns ok with pushed=0 when MoodLog responds 200 but no rows were accepted", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ imported: 0, updated: 0, failed: 1 }), + { status: 200 }, + ), + ); + const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]); + expect(result.status).toBe("ok"); + expect(result.pushed).toBe(0); + expect(result.skipped).toBe(1); + }); + + it("reports failed on a 5xx without throwing", async () => { + fetchMock.mockResolvedValueOnce(new Response("upstream down", { status: 502 })); + const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]); + expect(result.status).toBe("failed"); + expect(result.pushed).toBe(0); + }); + + it("reports failed on a network blip (fetch reject) without throwing", async () => { + fetchMock.mockRejectedValueOnce(new Error("ECONNRESET")); + const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]); + expect(result.status).toBe("failed"); + expect(result.pushed).toBe(0); + }); + + it("refuses to follow a 3xx redirect (SSRF defence)", async () => { + fetchMock.mockResolvedValueOnce(new Response("", { status: 302 })); + const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]); + expect(result.status).toBe("failed"); + }); + + it("omits the tags field when the column is null or malformed JSON", async () => { + await pushMoodEntriesToMoodLog("user-1", [ + { ...ENTRY, tags: null }, + ]); + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as RequestInit).body as string, + ) as { entries: Array<{ tags?: unknown }> }; + expect("tags" in body.entries[0]).toBe(false); + + fetchMock.mockClear(); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ imported: 1 }), { status: 200 }), + ); + await pushMoodEntriesToMoodLog("user-1", [ + { ...ENTRY, tags: "not-valid-json{{" }, + ]); + const body2 = JSON.parse( + (fetchMock.mock.calls[0][1] as RequestInit).body as string, + ) as { entries: Array<{ tags?: unknown }> }; + expect("tags" in body2.entries[0]).toBe(false); + }); +}); diff --git a/src/lib/moodlog/push.ts b/src/lib/moodlog/push.ts new file mode 100644 index 000000000..fe0409ad9 --- /dev/null +++ b/src/lib/moodlog/push.ts @@ -0,0 +1,213 @@ +import { prisma } from "@/lib/db"; +import { decrypt } from "@/lib/crypto"; +import { getEvent } from "@/lib/logging/context"; +import { isPublicUrl } from "@/lib/validations/notifications"; + +/** + * 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. Marc's report: he taps 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; +} + +export async function pushMoodEntriesToMoodLog( + userId: string, + entries: ReadonlyArray, +): Promise<{ pushed: number; skipped: number; status: "ok" | "skipped" | "failed" }> { + // Entries originating in MoodLog mustn't loop back. The pull side + // also filters incoming `HEALTHLOG`-tagged rows; this is the + // outbound mirror of that gate. + const candidates = entries.filter((e) => e.source !== "MOODLOG"); + if (candidates.length === 0) { + return { pushed: 0, skipped: entries.length, status: "skipped" }; + } + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + moodLogUrlEncrypted: true, + moodLogApiKeyEncrypted: true, + moodLogEnabled: true, + }, + }); + + if ( + !user?.moodLogEnabled || + !user.moodLogUrlEncrypted || + !user.moodLogApiKeyEncrypted + ) { + return { + pushed: 0, + skipped: candidates.length, + status: "skipped", + }; + } + + const baseUrl = decrypt(user.moodLogUrlEncrypted); + const apiKey = decrypt(user.moodLogApiKeyEncrypted); + + if (!isPublicUrl(baseUrl)) { + // Same SSRF guard pull side uses — a stored row pointing at an + // internal target must never receive the user's apiKey. + getEvent()?.addWarning( + `moodlog push refused for ${userId}: stored URL points at non-public host`, + ); + return { pushed: 0, skipped: candidates.length, status: "failed" }; + } + + const url = new URL("/api/integrations/health-log/mood", baseUrl); + + const body = { + entries: candidates.map((e) => ({ + date: e.date, + time: e.moodLoggedAt.toISOString(), + mood: e.mood, + note: e.note, + tags: parseTagKeys(e.tags), + source: "HEALTHLOG" as const, + })), + }; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + + const start = performance.now(); + let response: Response; + try { + response = await fetch(url.toString(), { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: controller.signal, + redirect: "manual", + }); + } catch (err) { + clearTimeout(timeout); + 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" }; + } finally { + clearTimeout(timeout); + } + + getEvent()?.addExternalCall({ + service: "moodlog", + method: "pushMoodEntriesToMoodLog", + duration_ms: Math.round(performance.now() - start), + status: response.status, + }); + + // 3xx — manual redirect mode surfaces them. Match the pull-side + // behaviour: a redirect from a public host that ended up pointing + // at an RFC1918 target would otherwise leak the apiKey. + if (response.status >= 300 && response.status < 400) { + getEvent()?.addWarning( + `moodlog push refused redirect for ${userId}: HTTP ${response.status}`, + ); + return { pushed: 0, skipped: candidates.length, status: "failed" }; + } + + if (!response.ok) { + getEvent()?.addWarning( + `moodlog push HTTP ${response.status} for ${userId}`, + ); + return { pushed: 0, skipped: candidates.length, status: "failed" }; + } + + // Surface MoodLog's per-entry receipt counts so the dashboard + // admin diagnostic + Wide Event timeline can attribute partial + // failures correctly (e.g. an unknown tag key dropped on the + // MoodLog side reports `failed > 0` while imported/updated > 0). + type PushResult = { + imported?: number; + updated?: number; + failed?: number; + }; + let result: PushResult | null = null; + try { + result = (await response.json()) as PushResult; + } catch { + // MoodLog should always return JSON; tolerate the parse failure + // so a stale upstream doesn't undo the successful HTTP write. + } + + const imported = result?.imported ?? 0; + const updated = result?.updated ?? 0; + const failed = result?.failed ?? 0; + return { + pushed: imported + updated, + skipped: entries.length - candidates.length + failed, + status: "ok", + }; +} + +/** + * Parse the JSON-encoded `tags` column into an array of key strings. + * Returns an empty array on null / parse failure so the push body + * always carries a well-formed `tags: string[]` (or omitted entirely). + */ +function parseTagKeys(raw: string | null | undefined): string[] | undefined { + if (!raw) return undefined; + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return undefined; + const keys = parsed + .filter((v): v is string => typeof v === "string" && v.length > 0) + .slice(0, 50); + return keys.length > 0 ? keys : undefined; + } catch { + return undefined; + } +} diff --git a/src/lib/moodlog/sync.ts b/src/lib/moodlog/sync.ts index c4a49804b..b549c33d5 100644 --- a/src/lib/moodlog/sync.ts +++ b/src/lib/moodlog/sync.ts @@ -195,7 +195,16 @@ export async function syncMoodLogEntries( 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 { time: string; @@ -203,7 +212,12 @@ export async function syncMoodLogEntries( 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; @@ -245,6 +259,19 @@ export async function syncMoodLogEntries( }); 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: