feat(moodlog): reverse-sync push HealthLog → MoodLog (v1.4.50)

Marc reported that mood entries logged inside HealthLog — specifically
via the iOS app — never reached MoodLog. The historic integration
polled MoodLog every 15 minutes and pulled new rows, but nothing
flowed the other direction. iOS users who tracked mood in HealthLog
ended up with one log per app and no overlap.

`src/lib/moodlog/push.ts` adds `pushMoodEntriesToMoodLog(userId,
entries)`. Best-effort reverse-sync push to MoodLog's new POST
`/api/integrations/health-log/mood` endpoint (shipped in MoodLog
v0.2.0). 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.

Pull side filters echoes:
`syncMoodLogEntries` now skips entries where MoodLog reports
`loggedVia: "HEALTHLOG"`. Those are reflections 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.

Cross-repo:
The matching MoodLog v0.2.0 release adds the inbound POST handler
this push targets. Both apps must deploy together; MoodLog deploys
first so HealthLog never hits a 405 / 404 on the new endpoint.

Tests: 9 cases in src/lib/moodlog/__tests__/push.test.ts cover 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 pass, lint clean, typecheck clean.
This commit is contained in:
Marc-André Bombeck committed 2026-05-24 00:38:31 +02:00
1 parent 49c9b425bb
commit a39f3bf6af
7 files changed
+496 -1

No files matched your search

+35
View File
@@ -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<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,
+25
View File
@@ -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 (