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

@@ -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;