Files
HealthLog/src/lib/moodlog/sync.ts
T
Marc-André Bombeck a39f3bf6af 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.
2026-05-24 00:38:31 +02:00

292 lines
8.8 KiB
TypeScript

import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { getEvent } from "@/lib/logging/context";
import { isPublicUrl } from "@/lib/validations/notifications";
import {
isReauthRequired,
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
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.
*/
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);
const controller = new AbortController();
const timeoutMs = opts?.fullSync ? 60000 : 15000;
const timeout = setTimeout(() => controller.abort(), timeoutMs);
let response: Response;
const fetchStart = performance.now();
try {
response = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${apiKey}` },
signal: controller.signal,
// SSRF defence-in-depth: do NOT follow redirects. A public
// host that 302s to an RFC1918 target would otherwise leak
// the apiKey to the internal hop.
redirect: "manual",
});
} catch (err) {
clearTimeout(timeout);
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;
} finally {
clearTimeout(timeout);
}
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 {
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;
await prisma.moodEntry.upsert({
where: {
userId_date_moodLoggedAt: {
userId,
date,
moodLoggedAt,
},
},
update: {
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
},
create: {
userId,
date,
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
moodLoggedAt,
},
});
imported++;
} catch (err) {
getEvent()?.addWarning(`Failed to upsert entry: ${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;
}