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

-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.