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

+2 -2
View File
@@ -10,7 +10,7 @@
*
* We accept three shapes:
* - `null` (and omitted/undefined): "no tags on this entry"
* - empty string `""`: legacy moodLog wire format (handled as null)
* - empty string `""`: legacy wire format (handled as null)
* - a JSON-string that parses to an array of strings.
*
* Anything else (object, JSON-with-numbers, garbage) is rejected at
@@ -51,7 +51,7 @@ describe("moodEntrySchema.tags strict validation", () => {
expect(r.success).toBe(true);
});
it("accepts an empty string (legacy moodLog wire format)", () => {
it("accepts an empty string (legacy wire format)", () => {
const r = parseWithMoodTags("");
expect(r.success).toBe(true);
});
@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest";
import {
moodLogCredentialsSchema,
createMoodEntrySchema,
updateMoodEntrySchema,
} from "../moodlog";
import { createMoodEntrySchema, updateMoodEntrySchema } from "../mood";
describe("createMoodEntrySchema — note + structured tagKeys (v1.8.5)", () => {
const base = { mood: "GUT", moodLoggedAt: "2026-06-01T12:00:00.000Z" };
@@ -71,44 +67,3 @@ describe("createMoodEntrySchema — note + structured tagKeys (v1.8.5)", () => {
).toBe(false);
});
});
describe("moodLogCredentialsSchema SSRF guard", () => {
it("accepts a public moodLog URL", () => {
const r = moodLogCredentialsSchema.safeParse({
url: "https://moodlog.app",
apiKey: "k".repeat(40),
});
expect(r.success).toBe(true);
});
it("rejects RFC1918 URLs", () => {
for (const url of [
"http://10.0.0.1",
"http://192.168.1.1",
"http://172.16.0.1",
"http://127.0.0.1",
]) {
const r = moodLogCredentialsSchema.safeParse({
url,
apiKey: "k".repeat(40),
});
expect(r.success, `expected reject for ${url}`).toBe(false);
}
});
it("rejects link-local (cloud metadata) URLs", () => {
const r = moodLogCredentialsSchema.safeParse({
url: "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
apiKey: "k".repeat(40),
});
expect(r.success).toBe(false);
});
it("rejects localhost", () => {
const r = moodLogCredentialsSchema.safeParse({
url: "http://localhost:8080",
apiKey: "k".repeat(40),
});
expect(r.success).toBe(false);
});
});
-1
View File
@@ -66,7 +66,6 @@ export const adminSettingsSchema = z
glitchtipEnvironment: z.string().optional(),
reminderLateMinutes: z.number().int().min(15).max(480).optional(),
reminderMissedMinutes: z.number().int().min(30).max(720).optional(),
moodLogGlobal: z.boolean().optional(),
// Document vault — the two admin-tunable limits. The per-file cap is
// bounded by the hard 100 MiB ceiling (single-shot GCM + bounded
// in-memory reads are load-bearing past that; the runtime resolver
-1
View File
@@ -320,7 +320,6 @@ const appSettingsBackupSchema = z
webPushVapidPrivateKeyEncrypted: z.string().nullable(),
webPushVapidSubject: z.string().nullable(),
apiGlobal: z.boolean(),
moodLogGlobal: z.boolean(),
umamiEnabled: z.boolean(),
umamiScriptUrl: z.string().nullable(),
umamiWebsiteId: z.string().nullable(),
@@ -1,70 +1,13 @@
import { z } from "zod/v4";
import { isPublicUrl } from "@/lib/validations/notifications";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
/**
* @deprecated The standalone moodLog integration is superseded by native
* mood entries plus structured tags and rated factors — mood is tracked
* fully inside HealthLog now. These schemas remain only to keep the
* existing webhook + sync paths functional; the surface is slated for
* removal in a future major release. The native mood schemas
* (`createMoodEntrySchema` etc.) below are the supported path.
* Mood-entry validation schemas.
*
* v1.32.33 — the file previously also carried the credential, webhook and
* pull-response schemas of the retired moodLog bridge. Mood is tracked
* natively, so only the entry schemas remain and the module is named for
* what it validates.
*/
export const moodLogCredentialsSchema = z.object({
url: z
.string()
.url()
.max(500)
// SSRF guard: stored URL must point at a public host. The sync
// worker fetches from this URL with the user's apiKey in the
// Authorization header, so a stored RFC1918 / link-local target
// would let any user pull cloud-metadata or local-network data
// back through their account.
.refine((u) => isPublicUrl(u), {
message: "URL must point at a public host (no RFC1918 / link-local)",
}),
apiKey: z.string().min(1).max(200),
});
export const moodLogWebhookPayloadSchema = z.object({
event: z.enum(["mood.created", "mood.updated", "mood.deleted"]),
timestamp: z.string().datetime(),
entry: z.object({
// v1.12.1 — optional source-stable entry id. When present, the
// webhook dedups on `(userId, source, externalId)` so a re-emit with
// a re-rounded / re-zoned `time` is idempotent instead of minting a
// second row. Absent → the legacy `(userId, date, moodLoggedAt)`
// path. Bounded so a malformed upstream id can't bloat the column.
id: z.string().min(1).max(120).optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
time: z.string().datetime(),
mood: z.enum(["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"]),
score: z.number().int().min(1).max(5),
tags: z.array(z.string()).max(50).optional(),
loggedVia: z.enum(["WEB", "TELEGRAM", "DAYLIO"]).optional(),
}),
});
export const moodLogSyncResponseSchema = z.object({
version: z.string(),
entries: z.array(
z.object({
// v1.12.1 — optional source-stable id, mirrors the webhook entry.
// Carried into `externalId` for idempotent re-import.
id: z.string().min(1).max(120).optional(),
date: z.string(),
time: z.string(),
mood: z.string(),
score: z.number(),
tags: z.array(z.string()).optional(),
loggedVia: z.string().optional(),
}),
),
});
export type MoodLogCredentials = z.infer<typeof moodLogCredentialsSchema>;
export type MoodLogWebhookPayload = z.infer<typeof moodLogWebhookPayloadSchema>;
export type MoodLogSyncResponse = z.infer<typeof moodLogSyncResponseSchema>;
import { z } from "zod/v4";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
// --- CRUD schemas for mood entries ---
@@ -76,6 +19,12 @@ export const moodLevelEnum = z.enum([
"LAUSIG",
]);
/**
* Provenance of a stored mood entry. `MOODLOG` is a legacy value: the bridge
* that wrote it was removed in v1.32.33, but rows it created are the user's
* own history and keep their label, so the list filter and the source badge
* must keep resolving it. Nothing mints new `MOODLOG` rows.
*/
export const moodSourceEnum = z.enum([
"MANUAL",
"MOODLOG",