mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +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
@@ -150,7 +150,7 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
// ── DATA LAYER (mood) ─────────────────────────────────────────────
|
||||
// Raw MoodEntry CRUD. The module gate governs whether mood SURFACES
|
||||
// (nav, dashboard tile, analysis, doctor report), not whether the row
|
||||
// store accepts writes. An importer / the moodLog sync / the user's
|
||||
// store accepts writes. An importer / the user's
|
||||
// ability to delete or restore entries must keep working while the
|
||||
// module is off, and re-enabling must find the rows intact.
|
||||
"src/app/api/mood-entries/route.ts",
|
||||
|
||||
@@ -343,7 +343,6 @@ const handler = apiHandler(
|
||||
settings.webPushVapidPrivateKeyEncrypted,
|
||||
webPushVapidSubject: settings.webPushVapidSubject,
|
||||
apiGlobal: settings.apiGlobal,
|
||||
moodLogGlobal: settings.moodLogGlobal,
|
||||
umamiEnabled: settings.umamiEnabled,
|
||||
umamiScriptUrl: settings.umamiScriptUrl,
|
||||
umamiWebsiteId: settings.umamiWebsiteId,
|
||||
|
||||
@@ -122,7 +122,6 @@ describe("GET /api/admin/settings", () => {
|
||||
glitchtipEnvironment: "production",
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
|
||||
const res = await GET();
|
||||
@@ -182,7 +181,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnvironment: "production",
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
|
||||
const res = await PUT(jsonReq({ webPushVapidPrivateKey: "plain-private" }));
|
||||
@@ -222,7 +220,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnvironment: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
const res = await PUT(jsonReq({ clearWebPushVapidPrivateKey: true }));
|
||||
expect(res.status).toBe(200);
|
||||
@@ -250,7 +247,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnvironment: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
|
||||
const res = await PUT(
|
||||
@@ -283,7 +279,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnvironment: "staging",
|
||||
reminderLateMinutes: 30,
|
||||
reminderMissedMinutes: 90,
|
||||
moodLogGlobal: false,
|
||||
} as never);
|
||||
|
||||
const res = await PUT(
|
||||
@@ -295,7 +290,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
webPushGlobal: false,
|
||||
apiGlobal: false,
|
||||
umamiEnabled: true,
|
||||
moodLogGlobal: false,
|
||||
glitchtipEnabled: true,
|
||||
webPushVapidPublicKey: "vapid-pub",
|
||||
webPushVapidSubject: "mailto:ops@healthlog.dev",
|
||||
@@ -350,7 +344,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnvironment: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
|
||||
await PUT(
|
||||
@@ -405,7 +398,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnvironment: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
defaultUserTimezone: "Pacific/Auckland",
|
||||
} as never);
|
||||
const res = await PUT(jsonReq({ defaultUserTimezone: "Pacific/Auckland" }));
|
||||
@@ -434,7 +426,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnvironment: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
defaultUserTimezone: null,
|
||||
} as never);
|
||||
const res = await PUT(jsonReq({ defaultUserTimezone: "" }));
|
||||
|
||||
@@ -50,7 +50,6 @@ export const GET = apiHandler(async () => {
|
||||
glitchtipEnvironment: settings?.glitchtipEnvironment ?? "production",
|
||||
reminderLateMinutes: settings?.reminderLateMinutes ?? 120,
|
||||
reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240,
|
||||
moodLogGlobal: settings?.moodLogGlobal ?? true,
|
||||
// Document vault limits (per-file cap + per-user quota default). BigInt
|
||||
// column → Number for the JSON envelope (values are far below 2^53).
|
||||
documentMaxFileBytes: settings?.documentMaxFileBytes ?? 26_214_400,
|
||||
@@ -94,7 +93,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
"apiGlobal",
|
||||
"umamiEnabled",
|
||||
"glitchtipEnabled",
|
||||
"moodLogGlobal",
|
||||
] as const;
|
||||
for (const field of booleanFields) {
|
||||
if (data[field] !== undefined) {
|
||||
@@ -257,7 +255,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
glitchtipEnvironment: settings.glitchtipEnvironment ?? "production",
|
||||
reminderLateMinutes: settings.reminderLateMinutes,
|
||||
reminderMissedMinutes: settings.reminderMissedMinutes,
|
||||
moodLogGlobal: settings.moodLogGlobal,
|
||||
documentMaxFileBytes: settings.documentMaxFileBytes,
|
||||
documentQuotaBytes: Number(settings.documentQuotaBytes),
|
||||
defaultUserTimezone: settings.defaultUserTimezone,
|
||||
|
||||
@@ -49,7 +49,7 @@ vi.mock("@/lib/daily/morning-refresh-trigger", () => ({
|
||||
// v1.4.39.1 — surface the rollup hook so the new regression test can
|
||||
// assert that the import path now folds the persistent rollup tier
|
||||
// for each touched (type, day). Mood rollup mock stays no-op because
|
||||
// the existing v1.4.39 W-MOOD hook already covers the moodlog branch.
|
||||
// the existing v1.4.39 W-MOOD hook already covers the import branch.
|
||||
vi.mock("@/lib/rollups/measurement-rollups", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/rollups/measurement-rollups")
|
||||
|
||||
@@ -645,7 +645,7 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
category: "Good",
|
||||
color: "var(--success)",
|
||||
},
|
||||
source: "moodLog",
|
||||
source: "Mood entries",
|
||||
...fiveDaysInRange,
|
||||
},
|
||||
{
|
||||
@@ -660,7 +660,7 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
category: "Very stable",
|
||||
color: "var(--success)",
|
||||
},
|
||||
source: "moodLog",
|
||||
source: "Mood entries",
|
||||
...fiveDaysInRange,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readMoodLogSecret } from "@/lib/moodlog-secret";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "moodlog.status" } });
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
moodLogUrlEncrypted: true,
|
||||
moodLogEnabled: true,
|
||||
moodLogLastSyncedAt: true,
|
||||
moodLogWebhookSecret: true,
|
||||
},
|
||||
});
|
||||
|
||||
const entryCount = await prisma.moodEntry.count({
|
||||
// v1.7.0 sync — exclude tombstoned rows from the entry count.
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
});
|
||||
|
||||
// V3 audit STILL-V2-C-2: stored secret is now AES-GCM encrypted at rest.
|
||||
// Decrypt for the user's settings page; legacy plaintext is also handled.
|
||||
const webhookSecret = readMoodLogSecret(dbUser?.moodLogWebhookSecret ?? null);
|
||||
|
||||
return apiSuccess({
|
||||
configured: Boolean(dbUser?.moodLogUrlEncrypted),
|
||||
enabled: dbUser?.moodLogEnabled ?? false,
|
||||
lastSyncedAt: dbUser?.moodLogLastSyncedAt ?? null,
|
||||
entryCount,
|
||||
webhookSecret,
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { syncMoodLogEntries } from "@/lib/moodlog/sync";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "moodlog.sync" } });
|
||||
|
||||
// Check global toggle
|
||||
const appSettings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { moodLogGlobal: true },
|
||||
});
|
||||
if (appSettings && !appSettings.moodLogGlobal) {
|
||||
return apiError("moodLog integration is disabled", 403);
|
||||
}
|
||||
|
||||
let fullSync = false;
|
||||
try {
|
||||
const raw = await request.text();
|
||||
// Flag-only payload — cap the parse cost (mirrors safeJson maxBytes).
|
||||
if (raw.length > 64 * 1024) {
|
||||
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
|
||||
}
|
||||
const body = JSON.parse(raw);
|
||||
fullSync = body?.fullSync === true;
|
||||
} catch {
|
||||
// No body or invalid JSON — use defaults
|
||||
}
|
||||
|
||||
const imported = await syncMoodLogEntries(user.id, {
|
||||
fullSync,
|
||||
});
|
||||
return apiSuccess({ imported });
|
||||
});
|
||||
@@ -1,170 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// safeFetch's requirePublicHost path runs through undici's own `fetch`
|
||||
// (version-locked with its dispatcher). Delegate it to the global `fetch`
|
||||
// stub these tests install so the existing interception still applies.
|
||||
vi.mock("undici", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("undici")>();
|
||||
return {
|
||||
...actual,
|
||||
fetch: (input: unknown, init?: unknown) =>
|
||||
(globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)(
|
||||
input,
|
||||
init,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: { id: "u-1" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((x: string) => x),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
data: null;
|
||||
error: string;
|
||||
}
|
||||
interface ApiSuccessEnvelope<T> {
|
||||
data: T;
|
||||
error: null;
|
||||
}
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockReset();
|
||||
global.fetch = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function emptyRequest(): Request {
|
||||
return new Request("http://localhost/api/integrations/moodlog/test", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/integrations/moodlog/test", () => {
|
||||
it("happy path returns ok with shape", async () => {
|
||||
const lastSyncedAt = new Date("2026-05-01T10:00:00.000Z");
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
moodLogUrlEncrypted: "https://example.com",
|
||||
moodLogApiKeyEncrypted: "ml-key-123",
|
||||
moodLogLastSyncedAt: lastSyncedAt,
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ entries: [] }), { status: 200 }),
|
||||
);
|
||||
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as ApiSuccessEnvelope<{
|
||||
ok: boolean;
|
||||
statusCode: number;
|
||||
latencyMs: number;
|
||||
lastSyncedAt: string;
|
||||
}>;
|
||||
expect(body.data.ok).toBe(true);
|
||||
expect(body.data.statusCode).toBe(200);
|
||||
expect(typeof body.data.latencyMs).toBe("number");
|
||||
expect(body.data.lastSyncedAt).toBe(lastSyncedAt.toISOString());
|
||||
|
||||
// Probe must hit the actual sync endpoint with Bearer auth, not a bare HEAD
|
||||
// against the configured URL.
|
||||
const fetchCall = (global.fetch as unknown as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0];
|
||||
const init = fetchCall[1] as RequestInit;
|
||||
expect(init.method).toBe("GET");
|
||||
const headers = init.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBe("Bearer ml-key-123");
|
||||
expect(init.redirect).toBe("manual");
|
||||
});
|
||||
|
||||
it("rate-limit denial returns 429 with no upstream call", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(429);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when no moodLog URL configured", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
moodLogUrlEncrypted: null,
|
||||
moodLogApiKeyEncrypted: null,
|
||||
moodLogLastSyncedAt: null,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toMatch(/not configured/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects SSRF (private IP) with 422", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
moodLogUrlEncrypted: "http://127.0.0.1/moodlog",
|
||||
moodLogApiKeyEncrypted: "ml-key-123",
|
||||
moodLogLastSyncedAt: null,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not leak upstream URL or Bearer token on upstream rejection", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
moodLogUrlEncrypted: "https://example.com",
|
||||
moodLogApiKeyEncrypted: "sk-secret",
|
||||
moodLogLastSyncedAt: null,
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error(
|
||||
"Authorization: Bearer sk-secret echoed from https://example.com/api/integrations/health-log/mood",
|
||||
),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
const text = await response.text();
|
||||
expect(response.status).toBe(502);
|
||||
expect(text).not.toMatch(/sk-/);
|
||||
expect(text).not.toMatch(/example\.com/);
|
||||
});
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
function categoriseHttpStatus(status: number): {
|
||||
code: string;
|
||||
message: string;
|
||||
} {
|
||||
if (status === 401 || status === 403) {
|
||||
return {
|
||||
code: "credentials_rejected",
|
||||
message: "moodLog rejected the API key",
|
||||
};
|
||||
}
|
||||
if (status === 404) {
|
||||
return {
|
||||
code: "endpoint_not_found",
|
||||
message: "moodLog endpoint not found at the configured URL",
|
||||
};
|
||||
}
|
||||
if (status === 429) {
|
||||
return {
|
||||
code: "rate_limited",
|
||||
message: "moodLog rate-limited the request",
|
||||
};
|
||||
}
|
||||
if (status >= 500) {
|
||||
return {
|
||||
code: "upstream_error",
|
||||
message: "moodLog returned a server error",
|
||||
};
|
||||
}
|
||||
return { code: "connection_failed", message: "moodLog connection failed" };
|
||||
}
|
||||
|
||||
function redact(text: string): string {
|
||||
return text
|
||||
.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
|
||||
.replace(/https?:\/\/\S+/gi, "[url]");
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
void request;
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "integrations.moodlog.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`moodlog-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
moodLogUrlEncrypted: true,
|
||||
moodLogApiKeyEncrypted: true,
|
||||
moodLogLastSyncedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dbUser?.moodLogUrlEncrypted || !dbUser.moodLogApiKeyEncrypted) {
|
||||
return apiError("moodLog not configured", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
let baseUrl: string;
|
||||
let apiKey: string;
|
||||
try {
|
||||
baseUrl = decrypt(dbUser.moodLogUrlEncrypted);
|
||||
apiKey = decrypt(dbUser.moodLogApiKeyEncrypted);
|
||||
} catch {
|
||||
return apiError("moodLog credentials unreadable", 422, {
|
||||
errorCode: "credentials_unreadable",
|
||||
});
|
||||
}
|
||||
|
||||
if (!isPublicUrl(baseUrl)) {
|
||||
return apiError("moodLog URL must be a public HTTP(S) endpoint", 422, {
|
||||
errorCode: "url_not_public",
|
||||
});
|
||||
}
|
||||
|
||||
// Probe the actual sync endpoint with the actual auth header — a HEAD on the
|
||||
// bare URL would return 200 from the static landing page even if the API key
|
||||
// is invalid. Use a 1-day window to keep the upstream payload small.
|
||||
let probeUrl: URL;
|
||||
try {
|
||||
probeUrl = new URL("/api/integrations/health-log/mood", baseUrl);
|
||||
} catch {
|
||||
return apiError("moodLog URL is invalid", 422, {
|
||||
errorCode: "url_invalid",
|
||||
});
|
||||
}
|
||||
const yesterday = new Date(Date.now() - 24 * 3600 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
probeUrl.searchParams.set("from", yesterday);
|
||||
probeUrl.searchParams.set("to", today);
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
// safeFetch keeps the manual-redirect guard (the apiKey would
|
||||
// otherwise leak on a 302 to 169.254.169.254 / RFC1918) and adds
|
||||
// the AbortSignal.timeout convention. requirePublicHost layers in
|
||||
// the connect-time IP pin so a DNS rebinding cannot flip the host
|
||||
// between accept and dispatch (issue #217).
|
||||
const res = await safeFetch(
|
||||
probeUrl.toString(),
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
cache: "no-store",
|
||||
},
|
||||
{ timeoutMs: TIMEOUT_MS, requirePublicHost: true },
|
||||
);
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
// 3xx in `manual` redirect mode → we never followed; treat as success
|
||||
// failure rather than chase the Location header.
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
annotate({ meta: { moodlog_test_status: res.status } });
|
||||
return apiError("moodLog redirected — check the configured URL", 502, {
|
||||
errorCode: "redirected",
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const cat = categoriseHttpStatus(res.status);
|
||||
annotate({
|
||||
meta: {
|
||||
moodlog_test_status: res.status,
|
||||
moodlog_test_code: cat.code,
|
||||
moodlog_test_latency_ms: latencyMs,
|
||||
},
|
||||
});
|
||||
return apiError(cat.message, 502, { errorCode: cat.code });
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
ok: true,
|
||||
statusCode: res.status,
|
||||
latencyMs,
|
||||
lastSyncedAt: dbUser.moodLogLastSyncedAt,
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
const isAbort =
|
||||
(e instanceof SafeFetchError && e.kind === "timeout") ||
|
||||
err.name === "AbortError";
|
||||
const code = isAbort ? "timeout" : "connection_failed";
|
||||
annotate({
|
||||
meta: {
|
||||
moodlog_test_code: code,
|
||||
moodlog_test_error: redact(err.message).slice(0, 300),
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
isAbort ? "moodLog request timed out" : "moodLog connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,284 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// --- Module-boundary mocks must come before importing the route. ---
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
user: { findMany: vi.fn() },
|
||||
moodEntry: {
|
||||
upsert: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/moodlog-secret", () => ({
|
||||
readMoodLogSecret: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => ({ setAuth: vi.fn(), addWarning: vi.fn() })),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { readMoodLogSecret } from "@/lib/moodlog-secret";
|
||||
|
||||
const VALID_PAYLOAD = {
|
||||
event: "mood.created" as const,
|
||||
timestamp: "2026-05-08T12:00:00.000Z",
|
||||
entry: {
|
||||
date: "2026-05-08",
|
||||
time: "2026-05-08T12:00:00.000Z",
|
||||
mood: "GUT" as const,
|
||||
score: 4,
|
||||
tags: ["walk", "sun"],
|
||||
loggedVia: "WEB" as const,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 30,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findMany).mockResolvedValue([
|
||||
{ id: "user-1", moodLogWebhookSecret: "encrypted-blob-1" },
|
||||
] as never);
|
||||
vi.mocked(readMoodLogSecret).mockReturnValue("plaintext-secret");
|
||||
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.moodEntry.deleteMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
});
|
||||
|
||||
function jsonRequest(
|
||||
body: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
): NextRequest {
|
||||
return new NextRequest("http://localhost/api/integrations/moodlog/webhook", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/integrations/moodlog/webhook", () => {
|
||||
it("returns 401 when X-Webhook-Secret header is missing", async () => {
|
||||
const res = await POST(jsonRequest(VALID_PAYLOAD));
|
||||
expect(res.status).toBe(401);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/missing/i);
|
||||
expect(prisma.moodEntry.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 401 when no enabled user matches the supplied secret", async () => {
|
||||
// The single candidate decrypts to a different plaintext.
|
||||
vi.mocked(readMoodLogSecret).mockReturnValue("different-plaintext");
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(readMoodLogSecret).toHaveBeenCalledWith("encrypted-blob-1");
|
||||
expect(prisma.moodEntry.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("decrypts each candidate via readMoodLogSecret and matches timing-safely", async () => {
|
||||
vi.mocked(prisma.user.findMany).mockResolvedValueOnce([
|
||||
{ id: "user-A", moodLogWebhookSecret: "blob-A" },
|
||||
{ id: "user-B", moodLogWebhookSecret: "blob-B" },
|
||||
] as never);
|
||||
|
||||
// First candidate decrypts to a wrong value, second decrypts to the
|
||||
// submitted secret. The route must walk the list and pick user-B.
|
||||
vi.mocked(readMoodLogSecret)
|
||||
.mockReturnValueOnce("nope")
|
||||
.mockReturnValueOnce("plaintext-secret");
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(readMoodLogSecret).toHaveBeenNthCalledWith(1, "blob-A");
|
||||
expect(readMoodLogSecret).toHaveBeenNthCalledWith(2, "blob-B");
|
||||
const upsertArgs = vi.mocked(prisma.moodEntry.upsert).mock.calls[0][0];
|
||||
// The route must use the matched user's id, not the first candidate's.
|
||||
expect(
|
||||
(upsertArgs.where as { userId_date_moodLoggedAt: { userId: string } })
|
||||
.userId_date_moodLoggedAt.userId,
|
||||
).toBe("user-B");
|
||||
});
|
||||
|
||||
it("happy path: valid secret + mood.created upserts a mood entry tagged MOODLOG", async () => {
|
||||
const res = await POST(
|
||||
jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.moodEntry.upsert).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(prisma.moodEntry.upsert).mock.calls[0][0];
|
||||
expect(args).toMatchObject({
|
||||
where: {
|
||||
userId_date_moodLoggedAt: {
|
||||
userId: "user-1",
|
||||
date: "2026-05-08",
|
||||
moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"),
|
||||
},
|
||||
},
|
||||
update: {
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
tags: JSON.stringify(["walk", "sun"]),
|
||||
source: "MOODLOG",
|
||||
},
|
||||
create: {
|
||||
userId: "user-1",
|
||||
date: "2026-05-08",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
tags: JSON.stringify(["walk", "sun"]),
|
||||
source: "MOODLOG",
|
||||
moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("idempotency: a duplicate mood.updated event hits upsert with the same composite key, so the DB no-ops", async () => {
|
||||
const updated = { ...VALID_PAYLOAD, event: "mood.updated" as const };
|
||||
|
||||
const r1 = await POST(
|
||||
jsonRequest(updated, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
const r2 = await POST(
|
||||
jsonRequest(updated, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r2.status).toBe(200);
|
||||
expect(prisma.moodEntry.upsert).toHaveBeenCalledTimes(2);
|
||||
const wheres = vi.mocked(prisma.moodEntry.upsert).mock.calls.map(
|
||||
(c) =>
|
||||
(
|
||||
c[0].where as {
|
||||
userId_date_moodLoggedAt: {
|
||||
userId: string;
|
||||
date: string;
|
||||
moodLoggedAt: Date;
|
||||
};
|
||||
}
|
||||
).userId_date_moodLoggedAt,
|
||||
);
|
||||
// Both calls target the same composite key — Postgres ON CONFLICT
|
||||
// makes the second upsert a no-op write.
|
||||
expect(wheres[0]).toEqual(wheres[1]);
|
||||
});
|
||||
|
||||
it("returns retryable failure when the source record cannot be persisted", async () => {
|
||||
vi.mocked(prisma.moodEntry.upsert).mockRejectedValueOnce(
|
||||
new Error("database unavailable"),
|
||||
);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
expect(await res.json()).toMatchObject({
|
||||
data: null,
|
||||
error: expect.stringMatching(/persist/i),
|
||||
});
|
||||
expect(prisma.moodEntry.upsert).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("mood.deleted removes any matching entry rather than upserting", async () => {
|
||||
const deletedPayload = {
|
||||
...VALID_PAYLOAD,
|
||||
event: "mood.deleted" as const,
|
||||
};
|
||||
const res = await POST(
|
||||
jsonRequest(deletedPayload, {
|
||||
"x-webhook-secret": "plaintext-secret",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.moodEntry.upsert).not.toHaveBeenCalled();
|
||||
expect(prisma.moodEntry.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId: "user-1",
|
||||
date: "2026-05-08",
|
||||
moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("webhook.test ping short-circuits to 200 with no DB writes", async () => {
|
||||
const res = await POST(
|
||||
jsonRequest(
|
||||
{ event: "webhook.test" },
|
||||
{ "x-webhook-secret": "plaintext-secret" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.moodEntry.upsert).not.toHaveBeenCalled();
|
||||
expect(prisma.moodEntry.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects payloads that don't match the schema with 400", async () => {
|
||||
const bad = {
|
||||
event: "mood.created",
|
||||
timestamp: "2026-05-08T12:00:00.000Z",
|
||||
// entry missing required fields
|
||||
entry: { date: "not-a-date", time: "x", mood: "BAD", score: 0 },
|
||||
};
|
||||
const res = await POST(
|
||||
jsonRequest(bad, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/invalid payload/i);
|
||||
expect(prisma.moodEntry.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 403 when the global moodLog toggle is disabled", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
moodLogGlobal: false,
|
||||
} as never);
|
||||
const res = await POST(
|
||||
jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(prisma.user.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 when rate limit is exceeded — no secret lookup performed", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const res = await POST(
|
||||
jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getClientIp } from "@/lib/api-response";
|
||||
import { moodLogWebhookPayloadSchema } from "@/lib/validations/moodlog";
|
||||
import { readMoodLogSecret } from "@/lib/moodlog-secret";
|
||||
import { persistMoodLogSourceEntry } from "@/lib/moodlog/persistence";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* @deprecated The standalone moodLog webhook is superseded by native mood
|
||||
* entries plus structured tags and rated factors — mood is now tracked
|
||||
* fully inside HealthLog, so the external moodLog bridge no longer adds
|
||||
* anything. Kept functional for existing integrations; slated for removal
|
||||
* in a future major release. Do not build new callers against it.
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "moodlog.webhook" } });
|
||||
|
||||
// 1. Rate limit (30/min per IP)
|
||||
const ip = getClientIp(request) ?? "unknown";
|
||||
const rl = await checkRateLimit(`moodlog-webhook:${ip}`, 30, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Rate limit exceeded", 429);
|
||||
}
|
||||
|
||||
// 2. Check global toggle
|
||||
const appSettings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { moodLogGlobal: true },
|
||||
});
|
||||
if (appSettings && !appSettings.moodLogGlobal) {
|
||||
return apiError("moodLog integration is disabled", 403);
|
||||
}
|
||||
|
||||
// 3. Get webhook secret from header
|
||||
const webhookSecret = request.headers.get("x-webhook-secret");
|
||||
if (!webhookSecret) {
|
||||
return apiError("Missing X-Webhook-Secret header", 401);
|
||||
}
|
||||
|
||||
// 4. Find all enabled users with webhook secrets and compare timing-safe
|
||||
const candidates = await prisma.user.findMany({
|
||||
where: {
|
||||
moodLogEnabled: true,
|
||||
moodLogWebhookSecret: { not: null },
|
||||
},
|
||||
select: { id: true, moodLogWebhookSecret: true },
|
||||
});
|
||||
|
||||
// V3 audit STILL-V2-C-2: stored secret is now encrypted at rest. Decrypt
|
||||
// each candidate before the timing-safe compare. `readMoodLogSecret`
|
||||
// also tolerates legacy plaintext rows during the transition window.
|
||||
const receivedBuf = Buffer.from(webhookSecret, "utf8");
|
||||
const user = candidates.find((c) => {
|
||||
const expected = readMoodLogSecret(c.moodLogWebhookSecret);
|
||||
if (!expected) return false;
|
||||
const expectedBuf = Buffer.from(expected, "utf8");
|
||||
if (expectedBuf.length !== receivedBuf.length) return false;
|
||||
return timingSafeEqual(expectedBuf, receivedBuf);
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return apiError("Invalid webhook secret", 401);
|
||||
}
|
||||
|
||||
getEvent()?.setAuth({ user_id: user.id, auth_method: "webhook_secret" });
|
||||
|
||||
// 5. Parse body
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
const raw = await request.text();
|
||||
if (raw.length > 256 * 1024) {
|
||||
return apiError(`Request body exceeds ${256 * 1024} bytes`, 413);
|
||||
}
|
||||
body = JSON.parse(raw);
|
||||
} catch {
|
||||
return apiError("Invalid JSON", 400);
|
||||
}
|
||||
|
||||
// Handle test pings from moodLog's "Test senden" button
|
||||
if (body.event === "webhook.test") {
|
||||
annotate({ meta: { webhook_event: "webhook.test" } });
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
const parsed = moodLogWebhookPayloadSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid payload", 400);
|
||||
}
|
||||
|
||||
const { event, entry } = parsed.data;
|
||||
annotate({ meta: { webhook_event: event } });
|
||||
|
||||
// 5. Process event and acknowledge only after the source write commits.
|
||||
const moodLoggedAt = new Date(entry.time);
|
||||
// v1.12.1 — when the upstream supplies a stable entry id, dedup on
|
||||
// `(userId, source, externalId)` so a re-emit with a re-rounded /
|
||||
// re-zoned `time` updates the same row instead of minting a duplicate.
|
||||
// Absent → the legacy `(userId, date, moodLoggedAt)` key. MOODLOG is
|
||||
// the fixed source here, matching the create/update writes below.
|
||||
const externalId = entry.id ?? null;
|
||||
|
||||
try {
|
||||
if (event === "mood.deleted") {
|
||||
await prisma.moodEntry.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(externalId
|
||||
? { source: "MOODLOG", externalId }
|
||||
: { date: entry.date, moodLoggedAt }),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await persistMoodLogSourceEntry(user.id, {
|
||||
externalId,
|
||||
date: entry.date,
|
||||
moodLoggedAt,
|
||||
mood: entry.mood,
|
||||
score: entry.score,
|
||||
tags: entry.tags,
|
||||
});
|
||||
}
|
||||
|
||||
// v1.4.39 W-MOOD — refresh the persisted rollup for the entry's
|
||||
// bucket. Inline best-effort: a failure here must not block the
|
||||
// webhook 200 response (the rollup is a cache tier, the source-of-
|
||||
// truth is the mood_entries write that already committed).
|
||||
try {
|
||||
await recomputeMoodBucketsForEntry(user.id, entry.date);
|
||||
} catch (rollupErr) {
|
||||
getEvent()?.addWarning(
|
||||
"Mood rollup recompute failed: " +
|
||||
(rollupErr instanceof Error ? rollupErr.message : String(rollupErr)),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
getEvent()?.addWarning(`MoodLog source persistence failed: ${message}`);
|
||||
return apiError("Failed to persist moodLog event", 503);
|
||||
}
|
||||
|
||||
// 6. The source record is durable; the provider can stop retrying.
|
||||
return new Response(null, { status: 200 });
|
||||
});
|
||||
@@ -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,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;
|
||||
|
||||
@@ -8,10 +8,7 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import {
|
||||
updateMoodEntrySchema,
|
||||
getScoreForMood,
|
||||
} from "@/lib/validations/moodlog";
|
||||
import { updateMoodEntrySchema, getScoreForMood } from "@/lib/validations/mood";
|
||||
import { NextRequest } from "next/server";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
|
||||
@@ -66,9 +66,6 @@ vi.mock("@/lib/cache/invalidate", () => ({
|
||||
vi.mock("@/lib/rollups/mood-rollups", () => ({
|
||||
recomputeMoodBucketsForEntry: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/moodlog/push", () => ({
|
||||
pushMoodEntriesToMoodLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -87,7 +84,6 @@ import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { createTagLinks } from "@/lib/mood/tag-links";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -117,14 +113,9 @@ beforeEach(() => {
|
||||
);
|
||||
txClient.moodEntryTagLink.findMany.mockResolvedValue([]);
|
||||
vi.mocked(createTagLinks).mockResolvedValue(undefined);
|
||||
// `reset` blanks the post-commit best-effort mocks; restore the promise
|
||||
// returns so `recompute(...)` awaits and `push(...).catch()` is callable.
|
||||
// `reset` blanks the post-commit best-effort mock; restore the promise
|
||||
// return so `recompute(...)` awaits.
|
||||
vi.mocked(recomputeMoodBucketsForEntry).mockResolvedValue(undefined);
|
||||
vi.mocked(pushMoodEntriesToMoodLog).mockResolvedValue({
|
||||
pushed: 0,
|
||||
skipped: 0,
|
||||
status: "ok",
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* entry still resolves its own "inserted" / "duplicate" state through the
|
||||
* exact key its dedup contract uses (externalId when carried, else the
|
||||
* legacy date+moodLoggedAt key). The per-entry `upsert` + tombstone check +
|
||||
* tag links + reverse push are unchanged. Tests below drive `findMany`
|
||||
* tag links are unchanged. Tests below drive `findMany`
|
||||
* instead of `findUnique`.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -52,10 +52,6 @@ vi.mock("@/lib/mood/tag-links", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/moodlog/push", () => ({
|
||||
pushMoodEntriesToMoodLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -94,7 +90,6 @@ import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { createTagLinks } from "@/lib/mood/tag-links";
|
||||
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -138,14 +133,6 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(createTagLinks).mockResolvedValue(undefined);
|
||||
// `vi.resetAllMocks()` above wipes the factory default, so the
|
||||
// best-effort reverse-sync push must be re-stubbed to a resolved
|
||||
// promise — the route calls `.catch()` on its return value.
|
||||
vi.mocked(pushMoodEntriesToMoodLog).mockResolvedValue({
|
||||
pushed: 0,
|
||||
skipped: 0,
|
||||
status: "skipped",
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
|
||||
@@ -42,12 +42,11 @@ import {
|
||||
getScoreForMood,
|
||||
moodLevelEnum,
|
||||
moodSourceEnum,
|
||||
} from "@/lib/validations/moodlog";
|
||||
} from "@/lib/validations/mood";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
|
||||
import { createTagLinks } from "@/lib/mood/tag-links";
|
||||
|
||||
const MAX_ENTRIES_PER_BATCH = 500;
|
||||
@@ -459,40 +458,6 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// v1.4.50 — reverse-sync the freshly-inserted batch to MoodLog so
|
||||
// an iOS one-shot backfill surfaces in both apps. Duplicates and
|
||||
// skips do NOT push (MoodLog already has them — or we'd just be
|
||||
// retrying a known failure). The helper filters MOODLOG-sourced
|
||||
// rows internally so a re-ingest of a MoodLog pull doesn't echo.
|
||||
if (inserted > 0) {
|
||||
const pushBatch: Array<{
|
||||
date: string;
|
||||
moodLoggedAt: Date;
|
||||
mood: string;
|
||||
note: string | null;
|
||||
tags: string | null;
|
||||
source: string;
|
||||
}> = [];
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
if (results[i]?.status !== "inserted") continue;
|
||||
const e = entries[i];
|
||||
const tz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
pushBatch.push({
|
||||
date: moodDateKey(e.moodLoggedAt, tz),
|
||||
moodLoggedAt: e.moodLoggedAt,
|
||||
mood: e.mood,
|
||||
note: e.note ?? null,
|
||||
tags: e.tags ? JSON.stringify(e.tags) : null,
|
||||
source: e.source,
|
||||
});
|
||||
}
|
||||
if (pushBatch.length > 0) {
|
||||
void pushMoodEntriesToMoodLog(user.id, pushBatch).catch(() => {
|
||||
/* helper wraps errors in wide-event warnings; defence-in-depth */
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
|
||||
@@ -12,17 +12,16 @@ import {
|
||||
createMoodEntrySchema,
|
||||
listMoodEntriesSchema,
|
||||
getScoreForMood,
|
||||
} from "@/lib/validations/moodlog";
|
||||
} from "@/lib/validations/mood";
|
||||
import { NextRequest } from "next/server";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { encryptNote, readNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
|
||||
import {
|
||||
createTagLinks,
|
||||
RatedFactorOutOfRangeError,
|
||||
@@ -345,30 +344,6 @@ async function postMoodEntry(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// v1.4.50 — reverse-sync push to MoodLog. Fire-and-forget; the
|
||||
// helper itself is best-effort and never throws, so a 502 from
|
||||
// MoodLog or a transient network blip can never bubble back to
|
||||
// the user's create. The pull side (15-min cron) backfills any
|
||||
// entry that fails the push window. Entries with `source ===
|
||||
// "MOODLOG"` skip inside the helper to avoid an echo loop.
|
||||
void pushMoodEntriesToMoodLog(user.id, [
|
||||
{
|
||||
date: entry.date,
|
||||
moodLoggedAt: entry.moodLoggedAt,
|
||||
mood: entry.mood,
|
||||
note: readNote(entry.noteEncrypted, entry.note),
|
||||
tags: entry.tags,
|
||||
source: entry.source,
|
||||
},
|
||||
]).catch(() => {
|
||||
// The helper already wraps its own errors in wide-event
|
||||
// warnings. The void + .catch is defence-in-depth so a
|
||||
// synchronous throw inside the helper's promise chain (e.g. a
|
||||
// future refactor that adds an await on a rejected promise
|
||||
// before the first try/catch) can't surface as an unhandled
|
||||
// rejection in the Next.js runtime.
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
|
||||
|
||||
@@ -95,11 +95,6 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
telegramEnabled: false,
|
||||
withingsClientIdEncrypted: null,
|
||||
withingsClientSecretEncrypted: null,
|
||||
moodLogUrlEncrypted: null,
|
||||
moodLogApiKeyEncrypted: null,
|
||||
moodLogEnabled: false,
|
||||
moodLogLastSyncedAt: null,
|
||||
moodLogWebhookSecret: null,
|
||||
onboardingCompletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { encryptMoodLogSecret, readMoodLogSecret } from "@/lib/moodlog-secret";
|
||||
import { moodLogCredentialsSchema } from "@/lib/validations/moodlog";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { markDisconnected, markReconnected } from "@/lib/integrations/status";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
const raw = await request.text();
|
||||
if (raw.length > 64 * 1024) {
|
||||
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
|
||||
}
|
||||
body = JSON.parse(raw);
|
||||
} catch {
|
||||
return apiError("Invalid JSON", 400);
|
||||
}
|
||||
|
||||
const parsed = moodLogCredentialsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid input", 422);
|
||||
}
|
||||
|
||||
const { url, apiKey } = parsed.data;
|
||||
|
||||
// Generate webhook secret if not yet set; otherwise reuse the existing
|
||||
// one (decrypted from at-rest storage; legacy plaintext is also handled).
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { moodLogWebhookSecret: true },
|
||||
});
|
||||
|
||||
const webhookSecret =
|
||||
readMoodLogSecret(existing?.moodLogWebhookSecret ?? null) ??
|
||||
`mb_${randomBytes(32).toString("hex")}`;
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
moodLogUrlEncrypted: encrypt(url),
|
||||
moodLogApiKeyEncrypted: encrypt(apiKey),
|
||||
moodLogEnabled: true,
|
||||
// V3 audit STILL-V2-C-2: encrypt at rest with AES-256-GCM. A legacy
|
||||
// plaintext value (rare) is rotated to the encrypted form on this
|
||||
// write transparently.
|
||||
moodLogWebhookSecret: encryptMoodLogSecret(webhookSecret),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({ action: { name: "settings.moodlog.update" } });
|
||||
|
||||
// Re-saving credentials clears any prior `error_reauth` state so the
|
||||
// next scheduled sync can run again. We don't write a fresh
|
||||
// success-time — that happens on the first real sync.
|
||||
await markReconnected(user.id, "moodlog");
|
||||
|
||||
return apiSuccess({
|
||||
configured: true,
|
||||
enabled: true,
|
||||
webhookSecret,
|
||||
});
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Delete all mood entries for this user
|
||||
await prisma.moodEntry.deleteMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
// v1.4.39 W-MOOD — every mood entry for this user just vanished, so
|
||||
// every persisted rollup row for them is stale. Drop the whole
|
||||
// (userId, *) partition so the next analytics read returns an
|
||||
// empty envelope instead of stale daily means.
|
||||
await prisma.moodEntryRollup.deleteMany({ where: { userId: user.id } });
|
||||
invalidateUserMood(user.id);
|
||||
|
||||
// Clear credentials
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
moodLogUrlEncrypted: null,
|
||||
moodLogApiKeyEncrypted: null,
|
||||
moodLogEnabled: false,
|
||||
moodLogLastSyncedAt: null,
|
||||
moodLogWebhookSecret: null,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({ action: { name: "settings.moodlog.disconnect" } });
|
||||
|
||||
await markDisconnected(user.id, "moodlog");
|
||||
|
||||
return apiSuccess({ disconnected: true });
|
||||
});
|
||||
Reference in new issue
Block a user