mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
1 parent
4ed8c8f39b
commit
d0b45e36b3
106 files changed
+379
-3326
No files matched your search
@@ -131,7 +131,6 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
webPushVapidPrivateKeyEncrypted: "private-dr",
|
||||
webPushVapidSubject: "mailto:dr@example.test",
|
||||
apiGlobal: false,
|
||||
moodLogGlobal: false,
|
||||
umamiEnabled: true,
|
||||
umamiScriptUrl: "https://analytics.example.test/script.js",
|
||||
umamiWebsiteId: "website-dr",
|
||||
|
||||
@@ -114,14 +114,14 @@ describe("IntegrationStatus end-to-end", () => {
|
||||
it("getIntegrationStatus returns plaintext error after roundtrip (decryption succeeds)", async () => {
|
||||
await recordSyncFailure({
|
||||
userId: TEST_USER_ID,
|
||||
integration: "moodlog",
|
||||
integration: "nightscout",
|
||||
kind: "transient",
|
||||
message: "moodLog sync HTTP 502",
|
||||
message: "Nightscout sync HTTP 502",
|
||||
errorCode: "http_502",
|
||||
});
|
||||
const snapshot = await getIntegrationStatus(TEST_USER_ID, "moodlog");
|
||||
const snapshot = await getIntegrationStatus(TEST_USER_ID, "nightscout");
|
||||
expect(snapshot.state).toBe("error_transient");
|
||||
expect(snapshot.lastError).toBe("moodLog sync HTTP 502");
|
||||
expect(snapshot.lastError).toBe("Nightscout sync HTTP 502");
|
||||
// v1.4.47 W1 — snapshot drops the legacy `consecutiveFailures`
|
||||
// scalar; the per-kind bucket carries the count.
|
||||
expect(snapshot.consecutiveFailuresByKind).toMatchObject({
|
||||
@@ -217,12 +217,12 @@ describe("IntegrationStatus end-to-end", () => {
|
||||
it("kind=reauth_required parks the integration so isReauthRequired() returns true", async () => {
|
||||
await recordSyncFailure({
|
||||
userId: TEST_USER_ID,
|
||||
integration: "moodlog",
|
||||
integration: "nightscout",
|
||||
kind: "reauth_required",
|
||||
message: "moodLog sync HTTP 401",
|
||||
message: "Nightscout sync HTTP 401",
|
||||
errorCode: "http_401",
|
||||
});
|
||||
expect(await isReauthRequired(TEST_USER_ID, "moodlog")).toBe(true);
|
||||
expect(await isReauthRequired(TEST_USER_ID, "nightscout")).toBe(true);
|
||||
expect(await isReauthRequired(TEST_USER_ID, "withings")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -246,14 +246,14 @@ describe("IntegrationStatus end-to-end", () => {
|
||||
it("markDisconnected wipes the error state but leaves the row as a tombstone", async () => {
|
||||
await recordSyncFailure({
|
||||
userId: TEST_USER_ID,
|
||||
integration: "moodlog",
|
||||
integration: "nightscout",
|
||||
kind: "transient",
|
||||
message: "blip",
|
||||
});
|
||||
await markDisconnected(TEST_USER_ID, "moodlog");
|
||||
await markDisconnected(TEST_USER_ID, "nightscout");
|
||||
const row = await getPrismaClient().integrationStatus.findUnique({
|
||||
where: {
|
||||
userId_integration: { userId: TEST_USER_ID, integration: "moodlog" },
|
||||
userId_integration: { userId: TEST_USER_ID, integration: "nightscout" },
|
||||
},
|
||||
});
|
||||
expect(row!.state).toBe("disconnected");
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
/**
|
||||
* v1.12.1 — partial live-row unique index on MedicationIntakeEvent
|
||||
* (migration 0121) + MoodEntry external-id dedup key (migration 0122).
|
||||
* (migration 0121).
|
||||
*
|
||||
* These pin the delete-then-resync / re-import contracts on real
|
||||
* Postgres, against the migrations replayed by `global-setup.ts`.
|
||||
* This pins the delete-then-resync contract on real Postgres, against the
|
||||
* migrations replayed by `global-setup.ts`: the real re-take path through
|
||||
* `applyCanonicalSlotWrite` takes a slot, soft-deletes it (user "forgot
|
||||
* this"), then re-takes the SAME canonical slot. Pre-0121 the create P2002'd
|
||||
* against the tombstone and the catch re-found only live rows (none) and
|
||||
* re-threw — the re-take 500'd. With the partial live-row unique it re-creates
|
||||
* cleanly.
|
||||
*
|
||||
* 1. MedicationIntakeEvent — the real re-take path through
|
||||
* `applyCanonicalSlotWrite`: take a slot, soft-delete it (user
|
||||
* "forgot this"), then re-take the SAME canonical slot. Pre-0121
|
||||
* the create P2002'd against the tombstone and the catch re-found
|
||||
* only live rows (none) and re-threw — the re-take 500'd. With the
|
||||
* partial live-row unique it re-creates cleanly.
|
||||
*
|
||||
* 2. MoodEntry — a moodLog re-import with the SAME stable `id` but a
|
||||
* re-zoned `moodLoggedAt` updates one row (idempotent on
|
||||
* `(userId, source, externalId)`), while two imports WITHOUT an id
|
||||
* still land two rows on the legacy wall-clock key.
|
||||
* The MoodEntry half of this file drove migration 0122's external-id dedup key
|
||||
* through the moodLog webhook. That bridge was retired in v1.32.33; the same
|
||||
* index is pinned end-to-end through the surviving native write paths in
|
||||
* `mood-entries-external-id.test.ts`.
|
||||
*
|
||||
* Note: Measurement is intentionally NOT made partial-unique (its
|
||||
* compound-key writes use `prisma.upsert` → native ON CONFLICT, which
|
||||
* Postgres cannot arbiter against a partial unique). See migration 0121's
|
||||
* header for the full rationale.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
@@ -122,300 +119,3 @@ describe("MedicationIntakeEvent — re-take after delete (v1.12.1 / 0121)", () =
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MoodEntry — external-id re-import idempotency (v1.12.1 / 0122)", () => {
|
||||
function webhookRequest(secret: string, body: unknown): NextRequest {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/integrations/moodlog/webhook",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-webhook-secret": secret,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
it("re-import with the same stable id but re-zoned time updates one row", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const secret = "mood-secret-idempotent";
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "mood-external-id",
|
||||
email: "mood-external-id@example.test",
|
||||
role: "USER",
|
||||
moodLogEnabled: true,
|
||||
// Legacy plaintext secret — readMoodLogSecret tolerates it.
|
||||
moodLogWebhookSecret: secret,
|
||||
},
|
||||
});
|
||||
|
||||
const { POST } =
|
||||
await import("@/app/api/integrations/moodlog/webhook/route");
|
||||
|
||||
// First import — entry carries a stable upstream id.
|
||||
const r1 = await POST(
|
||||
webhookRequest(secret, {
|
||||
event: "mood.created",
|
||||
timestamp: "2026-06-04T10:00:00.000Z",
|
||||
entry: {
|
||||
id: "daylio-row-42",
|
||||
date: "2026-06-04",
|
||||
time: "2026-06-04T10:00:00.000Z",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(r1.status).toBe(200);
|
||||
|
||||
// Re-import — SAME id, but a re-rounded / re-zoned time (and the
|
||||
// upstream re-scored the mood). Pre-0122 this minted a 2nd row on
|
||||
// the differing (date, moodLoggedAt) key.
|
||||
const r2 = await POST(
|
||||
webhookRequest(secret, {
|
||||
event: "mood.updated",
|
||||
timestamp: "2026-06-04T10:30:00.000Z",
|
||||
entry: {
|
||||
id: "daylio-row-42",
|
||||
date: "2026-06-04",
|
||||
time: "2026-06-04T08:00:00.000Z",
|
||||
mood: "SUPER_GUT",
|
||||
score: 5,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(r2.status).toBe(200);
|
||||
|
||||
const rows = await prisma.moodEntry.findMany({
|
||||
where: { userId: user.id, source: "MOODLOG" },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].externalId).toBe("daylio-row-42");
|
||||
// The update applied (mood + score + re-zoned time).
|
||||
expect(rows[0].mood).toBe("SUPER_GUT");
|
||||
expect(rows[0].score).toBe(5);
|
||||
});
|
||||
|
||||
it("acknowledges an exact provider replay with one stable durable row", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const secret = "mood-secret-replay";
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "mood-provider-replay",
|
||||
email: "mood-provider-replay@example.test",
|
||||
role: "USER",
|
||||
moodLogEnabled: true,
|
||||
moodLogWebhookSecret: secret,
|
||||
},
|
||||
});
|
||||
const { POST } =
|
||||
await import("@/app/api/integrations/moodlog/webhook/route");
|
||||
const payload = {
|
||||
event: "mood.created",
|
||||
timestamp: "2026-06-04T11:00:00.000Z",
|
||||
entry: {
|
||||
id: "provider-event-replay-1",
|
||||
date: "2026-06-04",
|
||||
time: "2026-06-04T11:00:00.000Z",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
},
|
||||
};
|
||||
|
||||
const first = await POST(webhookRequest(secret, payload));
|
||||
expect(first.status).toBe(200);
|
||||
const firstRow = await prisma.moodEntry.findUniqueOrThrow({
|
||||
where: {
|
||||
userId_source_externalId: {
|
||||
userId: user.id,
|
||||
source: "MOODLOG",
|
||||
externalId: "provider-event-replay-1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const replay = await POST(webhookRequest(secret, payload));
|
||||
expect(replay.status).toBe(200);
|
||||
const rows = await prisma.moodEntry.findMany({
|
||||
where: { userId: user.id, source: "MOODLOG" },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].id).toBe(firstRow.id);
|
||||
});
|
||||
|
||||
it("adopts a legacy natural-key row when a replay gains a provider id", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const secret = "mood-secret-key-upgrade";
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "mood-key-upgrade",
|
||||
email: "mood-key-upgrade@example.test",
|
||||
role: "USER",
|
||||
moodLogEnabled: true,
|
||||
moodLogWebhookSecret: secret,
|
||||
},
|
||||
});
|
||||
const { POST } =
|
||||
await import("@/app/api/integrations/moodlog/webhook/route");
|
||||
const entry = {
|
||||
date: "2026-06-04",
|
||||
time: "2026-06-04T12:00:00.000Z",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
};
|
||||
|
||||
const legacy = await POST(
|
||||
webhookRequest(secret, {
|
||||
event: "mood.created",
|
||||
timestamp: "2026-06-04T12:00:00.000Z",
|
||||
entry,
|
||||
}),
|
||||
);
|
||||
expect(legacy.status).toBe(200);
|
||||
const legacyRow = await prisma.moodEntry.findFirstOrThrow({
|
||||
where: { userId: user.id, source: "MOODLOG" },
|
||||
});
|
||||
expect(legacyRow.externalId).toBeNull();
|
||||
|
||||
const upgraded = await POST(
|
||||
webhookRequest(secret, {
|
||||
event: "mood.updated",
|
||||
timestamp: "2026-06-04T12:05:00.000Z",
|
||||
entry: { ...entry, id: "provider-key-upgrade-1" },
|
||||
}),
|
||||
);
|
||||
expect(upgraded.status).toBe(200);
|
||||
|
||||
const rows = await prisma.moodEntry.findMany({
|
||||
where: { userId: user.id, source: "MOODLOG" },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].id).toBe(legacyRow.id);
|
||||
expect(rows[0].externalId).toBe("provider-key-upgrade-1");
|
||||
});
|
||||
|
||||
it("does not adopt an unrelated row that occupies the natural key", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const secret = "mood-secret-unrelated-collision";
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "mood-unrelated-collision",
|
||||
email: "mood-unrelated-collision@example.test",
|
||||
role: "USER",
|
||||
moodLogEnabled: true,
|
||||
moodLogWebhookSecret: secret,
|
||||
},
|
||||
});
|
||||
const moodLoggedAt = new Date("2026-06-04T13:00:00.000Z");
|
||||
const manual = await prisma.moodEntry.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
date: "2026-06-04",
|
||||
moodLoggedAt,
|
||||
mood: "OKAY",
|
||||
score: 3,
|
||||
source: "MANUAL",
|
||||
},
|
||||
});
|
||||
const { POST } =
|
||||
await import("@/app/api/integrations/moodlog/webhook/route");
|
||||
|
||||
const response = await POST(
|
||||
webhookRequest(secret, {
|
||||
event: "mood.created",
|
||||
timestamp: "2026-06-04T13:00:00.000Z",
|
||||
entry: {
|
||||
id: "provider-unrelated-collision-1",
|
||||
date: "2026-06-04",
|
||||
time: moodLoggedAt.toISOString(),
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(503);
|
||||
|
||||
const row = await prisma.moodEntry.findUniqueOrThrow({
|
||||
where: { id: manual.id },
|
||||
});
|
||||
expect(row).toMatchObject({
|
||||
source: "MANUAL",
|
||||
externalId: null,
|
||||
mood: "OKAY",
|
||||
score: 3,
|
||||
});
|
||||
expect(await prisma.moodEntry.count({ where: { userId: user.id } })).toBe(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("entries without an id still dedup on the legacy wall-clock key", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const secret = "mood-secret-legacy";
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "mood-legacy-key",
|
||||
email: "mood-legacy-key@example.test",
|
||||
role: "USER",
|
||||
moodLogEnabled: true,
|
||||
moodLogWebhookSecret: secret,
|
||||
},
|
||||
});
|
||||
|
||||
const { POST } =
|
||||
await import("@/app/api/integrations/moodlog/webhook/route");
|
||||
|
||||
// Two entries, no id, DIFFERENT times → two rows (legacy behaviour
|
||||
// preserved). A re-emit at the SAME time updates in place.
|
||||
await POST(
|
||||
webhookRequest(secret, {
|
||||
event: "mood.created",
|
||||
timestamp: "2026-06-05T09:00:00.000Z",
|
||||
entry: {
|
||||
date: "2026-06-05",
|
||||
time: "2026-06-05T09:00:00.000Z",
|
||||
mood: "OKAY",
|
||||
score: 3,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await POST(
|
||||
webhookRequest(secret, {
|
||||
event: "mood.created",
|
||||
timestamp: "2026-06-05T21:00:00.000Z",
|
||||
entry: {
|
||||
date: "2026-06-05",
|
||||
time: "2026-06-05T21:00:00.000Z",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
},
|
||||
}),
|
||||
);
|
||||
// Re-emit the first at the exact same time → in-place update.
|
||||
await POST(
|
||||
webhookRequest(secret, {
|
||||
event: "mood.updated",
|
||||
timestamp: "2026-06-05T09:00:00.000Z",
|
||||
entry: {
|
||||
date: "2026-06-05",
|
||||
time: "2026-06-05T09:00:00.000Z",
|
||||
mood: "SUPER_GUT",
|
||||
score: 5,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const rows = await prisma.moodEntry.findMany({
|
||||
where: { userId: user.id, source: "MOODLOG" },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
});
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows.every((r) => r.externalId === null)).toBe(true);
|
||||
// The morning row was updated in place by the same-time re-emit.
|
||||
expect(rows[0].mood).toBe("SUPER_GUT");
|
||||
expect(rows[1].mood).toBe("GUT");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user