chore(release): v1.4.49 — audit-backlog closure + server reminder suppression + diagnostic endpoint + analytics cold-path fix

Bundles the deferred v1.4.47 Mediums + Lows with the v1.4.48-deferred items
into a single release. Headline: server-side suppression of MEDICATION_REMINDER
APNs for iOS clients that own their local reminders (closes GH #206), a
push_attempts table backing /api/admin/notifications/diagnostic, and a sweep
of v1.4.48 QA forward-findings.

Performance (the user-visible win):
- /api/analytics cold-fallback path splits the heavy single-statement
  aggregate into two parallel queries. The linearly composable columns
  (count / min / max / mean) keep the full-partition scan; the windowed
  7 / 30 / 90-day avg / slope / r² columns take a 90-day outer cap so
  the planner does an index range scan on (user_id, type, measured_at)
  instead of a full-partition sequential scan. Output is bit-identical;
  cold slim slice drops from multi-second to sub-second on tenants with
  large measurement partitions.

Server-side notifications:
- GET + PATCH /api/auth/me/notification-prefs with deep-merge semantics.
  Initial category medication.clientManaged: boolean (default false). Cron
  medicationReminderJob skips dose-due APNs when the flag is true,
  emitting a medication_reminder.suppressed_client_managed wide-event
  annotation per skip.
- push_attempts table backs the diagnostic endpoint. Every APNS /
  Web-Push / Telegram / NTFY sender writes a fire-and-forget row.
  Daily 03:35 Europe/Berlin cleanup prunes rows older than 90 days.
- /api/admin/notifications/diagnostic — admin-only endpoint surfacing
  device APNs token presence (masked), channel config presence, and
  recent push attempts. Registered in OpenAPI.
- Per-channel APNs "Send test" button now fires with
  eventType: "MEDICATION_REMINDER" so the dispatcher's time-sensitive +
  priority-10 branch runs. A successful self-test now exercises the
  exact path production reminders take; an unsuccessful one points the
  user at iOS Settings → Time Sensitive Notifications rather than
  appearing to be a server-side bug.

Withings reliability:
- OAuth callback consumes the nonce atomically (delete-first with
  P2025 catch for the replay branch). The previous read-then-delete
  pattern allowed a race between two concurrent callbacks.
- ?reason=state redirect differentiated into csrf1 / replay / expired /
  cross_user so operators can tell mismatch types apart without DB
  shell access.
- connect endpoint rate-limited to 10/60s/user; row-create failures
  redirect to a consistent error surface.

Observability hardening:
- redactSensitiveFields(body) denylist helper covers password / token /
  secret / apiKey / authorization / csrfState / nonce, recursive,
  case-insensitive. Caught: /api/devices invalid_format issues were
  echoing apnsToken values verbatim into the audit log.
- sanitiseZodIssues(issues, { stripValuesFromMessage: true }) overload
  applied to 14 audit-log sites.

Migrations: 0079 (notification_prefs), 0080 (push_attempts).

Tests: 4815 → 5272 unit + integration. Workout-batch integration suite
that had been red on main since v1.4.45 (three release commits shipped
through it) now green again — updated to match v1.4.42 cross-source
dedup semantics.

Resolves PR #207.
This commit is contained in:
Marc-André Bombeck committed 2026-05-23 12:51:41 +02:00
1 parent 30ab2112db
commit f92e644154
87 files changed
+7939 -2950

No files matched your search

@@ -0,0 +1,398 @@
/**
* v1.4.48 H-APNs-1 — coverage for the admin notification diagnostic
* endpoint. The route surfaces what the dispatcher would observe when
* targeting the calling admin's account so an operator can debug push
* delivery without DB-shell access; these tests pin:
*
* * 401 for an unauthenticated caller (no session cookie),
* * 403 for an authenticated non-admin (USER role),
* * 200 + the documented envelope shape for an admin with an empty
* device + channel set,
* * APNs token masking (prefix 8 + suffix 8 hex only, full token
* never returned),
* * per-channel `configPresent` reflecting the decrypted JSON blob
* for TELEGRAM / NTFY and the row-existence shortcut for APNS /
* WEB_PUSH (the latter two store delivery targets on sibling
* tables, not in the channel config blob).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
device: {
findMany: vi.fn(),
},
notificationChannel: {
findMany: vi.fn(),
},
// v1.4.49 — backing query for `recentPushAttempts`. The route
// pulls the trailing 20 rows ordered by `createdAt` DESC and maps
// the `createdAt` Date to an `at` ISO string for the response.
pushAttempt: {
findMany: vi.fn(),
},
},
}));
// The route imports requireAdmin from `@/lib/api-handler`. We keep
// apiHandler real so HttpError throws produce the proper 401 / 403
// JSON envelope, but stub requireAdmin to drive the three auth cases.
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
requireAdmin: vi.fn(),
};
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
eventStorage: {
run: <T>(_evt: unknown, fn: () => Promise<T>) => fn(),
},
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((value: string) => value.replace(/^enc\(|\)$/g, "")),
encrypt: vi.fn((value: string) => `enc(${value})`),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin, HttpError } from "@/lib/api-handler";
const ADMIN_USER_ID = "admin-1";
const ADMIN_CTX = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: ADMIN_USER_ID,
username: "admin",
role: "ADMIN",
},
} as never;
interface DiagnosticEnvelope {
data: {
devices: Array<{
id: string;
platform: string;
hasApnsToken: boolean;
apnsTokenPrefix: string | null;
apnsTokenSuffix: string | null;
apnsEnvironment: string | null;
lastSeenAt: string;
}>;
notificationChannels: Array<{
type: string;
enabled: boolean;
configPresent: boolean;
}>;
recentPushAttempts: Array<{
eventType: string;
channel: string;
result: string;
reason: string | null;
at: string;
}>;
};
error: string | null;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.device.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([] as never);
});
describe("GET /api/admin/notifications/diagnostic — auth gates", () => {
it("returns 401 when the caller has no session", async () => {
vi.mocked(requireAdmin).mockRejectedValue(
new HttpError(401, "Not authenticated"),
);
const res = await GET();
expect(res.status).toBe(401);
const body = (await res.json()) as { data: null; error: string };
expect(body.data).toBeNull();
expect(body.error).toBe("Not authenticated");
});
it("returns 403 when the caller is authenticated but not ADMIN", async () => {
vi.mocked(requireAdmin).mockRejectedValue(
new HttpError(403, "Admin access required"),
);
const res = await GET();
expect(res.status).toBe(403);
const body = (await res.json()) as { data: null; error: string };
expect(body.data).toBeNull();
expect(body.error).toBe("Admin access required");
});
});
describe("GET /api/admin/notifications/diagnostic — empty state", () => {
it("returns the documented envelope shape with empty arrays for an admin with no devices or channels", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
const res = await GET();
expect(res.status).toBe(200);
const body = (await res.json()) as DiagnosticEnvelope;
expect(body.error).toBeNull();
expect(body.data).toEqual({
devices: [],
notificationChannels: [],
recentPushAttempts: [],
});
});
});
describe("GET /api/admin/notifications/diagnostic — device masking", () => {
it("returns hasApnsToken=true and 8/8-char prefix+suffix when device has an APNs token", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
const fullToken =
"abcdef1234567890aabbccddeeff0011223344556677889900112233445566778899aabb";
// Trim to a typical 64-char APNs hex token to keep the assertion
// realistic. The masker doesn't care about length beyond `>= 16`.
const token64 = fullToken.slice(0, 64);
vi.mocked(prisma.device.findMany).mockResolvedValue([
{
id: "dev-1",
platform: "ios",
apnsToken: token64,
apnsEnvironment: "production",
lastSeen: new Date("2026-05-22T10:00:00.000Z"),
},
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(res.status).toBe(200);
expect(body.data.devices).toHaveLength(1);
const device = body.data.devices[0];
expect(device.hasApnsToken).toBe(true);
expect(device.apnsTokenPrefix).toBe(token64.slice(0, 8));
expect(device.apnsTokenSuffix).toBe(token64.slice(-8));
expect(device.apnsEnvironment).toBe("production");
expect(device.lastSeenAt).toBe("2026-05-22T10:00:00.000Z");
// Belt-and-braces: the full token must not appear anywhere in the
// serialised response.
const serialised = JSON.stringify(body);
expect(serialised).not.toContain(token64);
});
it("returns hasApnsToken=false and null prefix/suffix when the device row has no APNs token", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.device.findMany).mockResolvedValue([
{
id: "dev-2",
platform: "ios",
apnsToken: null,
apnsEnvironment: null,
lastSeen: new Date("2026-05-22T11:00:00.000Z"),
},
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(res.status).toBe(200);
const device = body.data.devices[0];
expect(device.hasApnsToken).toBe(false);
expect(device.apnsTokenPrefix).toBeNull();
expect(device.apnsTokenSuffix).toBeNull();
});
});
describe("GET /api/admin/notifications/diagnostic — channel configPresent", () => {
it("returns configPresent=true for TELEGRAM when the decrypted blob has both botToken and chatId", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
{
type: "TELEGRAM",
enabled: true,
config: 'enc({"botToken":"bot:12345","chatId":"678"})',
},
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(res.status).toBe(200);
expect(body.data.notificationChannels).toEqual([
{ type: "TELEGRAM", enabled: true, configPresent: true },
]);
});
it("returns configPresent=false for TELEGRAM when chatId is empty", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
{
type: "TELEGRAM",
enabled: false,
config: 'enc({"botToken":"bot:12345","chatId":""})',
},
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(body.data.notificationChannels).toEqual([
{ type: "TELEGRAM", enabled: false, configPresent: false },
]);
});
it("returns configPresent=true for NTFY when topic is non-empty", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
{
type: "NTFY",
enabled: true,
config: 'enc({"serverUrl":"https://ntfy.sh","topic":"hl-test"})',
},
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(body.data.notificationChannels).toEqual([
{ type: "NTFY", enabled: true, configPresent: true },
]);
});
it("returns configPresent=false for NTFY when topic is missing", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
{
type: "NTFY",
enabled: true,
config: 'enc({"serverUrl":"https://ntfy.sh"})',
},
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(body.data.notificationChannels).toEqual([
{ type: "NTFY", enabled: true, configPresent: false },
]);
});
it("treats APNS and WEB_PUSH rows as configPresent=true (delivery target lives on sibling tables)", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
{ type: "APNS", enabled: true, config: "enc({})" },
{ type: "WEB_PUSH", enabled: false, config: "enc({})" },
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(body.data.notificationChannels).toEqual([
{ type: "APNS", enabled: true, configPresent: true },
{ type: "WEB_PUSH", enabled: false, configPresent: true },
]);
});
it("returns configPresent=false when the encrypted blob is malformed (decryption throws)", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
// Our crypto mock unwraps `enc(...)`; pass a value that yields
// non-JSON after the unwrap so JSON.parse throws and the helper's
// try/catch returns false.
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
{ type: "TELEGRAM", enabled: true, config: "not-encrypted-junk" },
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(res.status).toBe(200);
expect(body.data.notificationChannels).toEqual([
{ type: "TELEGRAM", enabled: true, configPresent: false },
]);
});
});
describe("GET /api/admin/notifications/diagnostic — recentPushAttempts", () => {
it("returns recentPushAttempts as an empty array when the user has no rows", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(body.data.recentPushAttempts).toEqual([]);
});
it("maps push_attempts rows to the documented response shape (createdAt → at ISO string)", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
// The route's findMany select returns `createdAt` as a Date; the
// mapping step normalises to an ISO string under `at`.
vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([
{
eventType: "MEDICATION_REMINDER",
channel: "APNS",
result: "ok",
reason: null,
createdAt: new Date("2026-05-22T09:00:00.000Z"),
},
{
eventType: "MOOD_REMINDER",
channel: "TELEGRAM",
result: "error",
reason: "telegram_blocked_by_user",
createdAt: new Date("2026-05-22T08:30:00.000Z"),
},
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(res.status).toBe(200);
expect(body.data.recentPushAttempts).toEqual([
{
eventType: "MEDICATION_REMINDER",
channel: "APNS",
result: "ok",
reason: null,
at: "2026-05-22T09:00:00.000Z",
},
{
eventType: "MOOD_REMINDER",
channel: "TELEGRAM",
result: "error",
reason: "telegram_blocked_by_user",
at: "2026-05-22T08:30:00.000Z",
},
]);
});
it("scopes the push-attempt query to the calling admin's userId, ordered desc by recency, capped at 20", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([] as never);
await GET();
expect(vi.mocked(prisma.pushAttempt.findMany)).toHaveBeenCalledTimes(1);
const args = vi.mocked(prisma.pushAttempt.findMany).mock.calls[0]?.[0] as {
where: { userId: string };
orderBy: { createdAt: "desc" };
take: number;
};
expect(args.where.userId).toBe(ADMIN_USER_ID);
expect(args.orderBy).toEqual({ createdAt: "desc" });
expect(args.take).toBe(20);
});
});
@@ -0,0 +1,219 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { decrypt } from "@/lib/crypto";
import type { ChannelType } from "@/lib/notifications/types";
export const dynamic = "force-dynamic";
/**
* v1.4.48 H-APNs-1 — admin-only diagnostic endpoint for the
* notification subsystem. Surfaces what the dispatcher would see when
* targeting the calling admin's account so an operator can debug an
* iOS / Web Push / Telegram / ntfy issue without needing DB shell
* access. This fills the gap that bottlenecked the v1.4.47 APNs hunt
* — every "is the token there?" / "is the channel enabled?" question
* was a Coolify exec round-trip.
*
* Response shape:
* {
* devices: Device[], — APNs token presence + masked
* prefix/suffix only; never the
* full token.
* notificationChannels: Channel[] — per-channel enabled + whether
* the channel-specific config
* blob is present.
* recentPushAttempts: PushAttempt[] — last 20 push deliveries for
* the calling user, ordered by
* recency. Backed by the
* `push_attempts` table — every
* sender (APNS, WEB_PUSH,
* TELEGRAM, NTFY) writes one
* fire-and-forget row per
* dispatch. v1.4.49 closed the
* empty-array stub by adding
* the table + the write path.
* }
*
* Security: admin-only (cookie auth, never Bearer). The route never
* returns the full APNs token — only an 8-char hex prefix + 8-char
* suffix so the operator can correlate with iOS-side diagnostics.
*/
interface DiagnosticDevice {
id: string;
platform: string;
hasApnsToken: boolean;
apnsTokenPrefix: string | null;
apnsTokenSuffix: string | null;
apnsEnvironment: string | null;
lastSeenAt: string;
}
interface DiagnosticChannel {
type: ChannelType | string;
enabled: boolean;
configPresent: boolean;
}
interface DiagnosticPushAttempt {
eventType: string;
channel: string;
result: string;
reason: string | null;
at: string;
}
interface DiagnosticPayload {
devices: DiagnosticDevice[];
notificationChannels: DiagnosticChannel[];
recentPushAttempts: DiagnosticPushAttempt[];
}
/**
* Map an APNs hex token to its 8-char prefix + 8-char suffix. APNs
* device tokens are 64-char hex strings; we surface 8 + 8 = 16 chars
* total (25 % of the token), which is enough for the operator to
* cross-check against an iOS-side log line without disclosing the
* delivery target. Tokens shorter than 16 chars are treated as opaque
* (prefix / suffix both null) — that shape never appears in
* production, but defensive against partial inserts during testing.
*/
function maskApnsToken(
token: string | null,
): { prefix: string | null; suffix: string | null } {
if (!token || token.length < 16) return { prefix: null, suffix: null };
return {
prefix: token.slice(0, 8),
suffix: token.slice(-8),
};
}
/**
* Decide whether the channel's encrypted-JSON config blob carries the
* key the corresponding sender will actually read. We decrypt + parse
* inside a try/catch so a corrupted ciphertext row (or an upstream
* encryption-key rotation) cannot 500 the diagnostic endpoint — the
* worst case is `configPresent: false` and the operator investigates
* why the row is unreadable.
*
* The required field per channel type:
* - TELEGRAM → `chatId` + `botToken`
* - NTFY → `topic`
* - WEB_PUSH → no per-channel blob (subscriptions live in the
* `push_subscriptions` table); treat the row's mere
* existence as `configPresent: true`.
* - APNS → no per-channel blob (device tokens live on the
* `devices` table); treat the row's mere existence as
* `configPresent: true`.
*/
function isChannelConfigPresent(
type: string,
encryptedConfig: string,
): boolean {
if (type === "WEB_PUSH" || type === "APNS") {
// These channels store their delivery target on sibling tables
// (`push_subscriptions` / `devices`), not in the channel-row
// config blob. The row's existence is the signal.
return true;
}
try {
const parsed = JSON.parse(decrypt(encryptedConfig)) as Record<
string,
unknown
>;
if (type === "TELEGRAM") {
return (
typeof parsed.chatId === "string" &&
parsed.chatId.length > 0 &&
typeof parsed.botToken === "string" &&
parsed.botToken.length > 0
);
}
if (type === "NTFY") {
return typeof parsed.topic === "string" && parsed.topic.length > 0;
}
return false;
} catch {
return false;
}
}
export const GET = apiHandler(async () => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.notifications.diagnostic" } });
const [devices, channels, pushAttempts] = await Promise.all([
prisma.device.findMany({
where: { userId: user.id },
select: {
id: true,
platform: true,
apnsToken: true,
apnsEnvironment: true,
lastSeen: true,
},
orderBy: { lastSeen: "desc" },
}),
prisma.notificationChannel.findMany({
where: { userId: user.id },
select: { type: true, enabled: true, config: true },
}),
// v1.4.49 — trailing 20 push attempts for the calling admin's own
// account. The `(user_id, created_at DESC)` index on `push_attempts`
// turns the orderBy + take into a single index scan, so the cost is
// bounded regardless of the user's lifetime push volume.
prisma.pushAttempt.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
take: 20,
select: {
eventType: true,
channel: true,
result: true,
reason: true,
createdAt: true,
},
}),
]);
const diagnosticDevices: DiagnosticDevice[] = devices.map((d) => {
const { prefix, suffix } = maskApnsToken(d.apnsToken);
return {
id: d.id,
platform: d.platform,
hasApnsToken: d.apnsToken !== null && d.apnsToken !== "",
apnsTokenPrefix: prefix,
apnsTokenSuffix: suffix,
apnsEnvironment: d.apnsEnvironment,
lastSeenAt: d.lastSeen.toISOString(),
};
});
const diagnosticChannels: DiagnosticChannel[] = channels.map((c) => ({
type: c.type,
enabled: c.enabled,
configPresent: isChannelConfigPresent(c.type, c.config),
}));
// v1.4.49 — backed by the `push_attempts` table. Map `createdAt`
// → `at` ISO string so the response shape matches the
// `DiagnosticPushAttempt` interface above (the existing consumer
// contract uses `at`, not `createdAt`).
const recentPushAttempts: DiagnosticPushAttempt[] = pushAttempts.map((a) => ({
eventType: a.eventType,
channel: a.channel,
result: a.result,
reason: a.reason,
at: a.createdAt.toISOString(),
}));
const payload: DiagnosticPayload = {
devices: diagnosticDevices,
notificationChannels: diagnosticChannels,
recentPushAttempts,
};
return apiSuccess(payload);
});
+11 -4
View File
@@ -305,10 +305,12 @@ describe("GET /api/analytics", () => {
// contract the dashboard tile strip reads.
it("returns the slim summaries slice when ?slice=summaries is set", async () => {
const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000);
// v1.4.36 — slim slice now opens with a cheap `SELECT COUNT(*) FROM
// measurement_rollups` probe. Force `n: 0` so the route takes the
// legacy heavy-aggregate fallback path that this test's fixtures
// expect (aggregate + latest, two `$queryRaw` passes).
// v1.4.36 — slim slice opens with a cheap `SELECT COUNT(*) FROM
// measurement_rollups` probe; `n: 0` forces the cold-fallback
// path. v1.4.48 M0 split that path's single aggregate query into
// two parallel ones (all-time + windowed), so the cold fixture
// now mocks 4 `$queryRaw` calls: coverage + allTime + windowed +
// latest.
vi.mocked(prisma.$queryRaw)
.mockResolvedValueOnce([{ n: BigInt(0) }] as never)
.mockResolvedValueOnce([
@@ -318,6 +320,11 @@ describe("GET /api/analytics", () => {
min_value: 80.0,
max_value: 84.5,
mean_value: 82.1,
},
] as never)
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 82.0,
avg30: 82.2,
slope7: -0.05,
+15 -12
View File
@@ -9,7 +9,7 @@
*
* The toggle is reachable from Settings → Insights as a `<Switch>`
* labelled "Hide Coach" / "Coach ausblenden". Default `false` (Coach
* visible) — see migration `0076_v1447_user_disable_coach` for the
* visible) — see migration `0078_v1447_user_disable_coach` for the
* column-level documentation.
*
* Idempotent. The endpoint always returns the resolved next-state
@@ -19,13 +19,22 @@
* batch settings round-trips after a passkey re-pair and a tight
* 5/min would surface as a 429 on a normal onboarding flow.
*/
import { z } from "zod";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
const patchBodySchema = z.object({ disableCoach: z.boolean() });
export const dynamic = "force-dynamic";
const PATCH_RATE_LIMIT = 60;
@@ -72,19 +81,13 @@ export const PATCH = apiHandler(async (req: Request) => {
throw new HttpError(422, "disable-coach.body.invalid_json");
}
// Hand-rolled validation — single boolean field. Keeps the
// prompt-injection surface visibly empty in code review (no string
// ever reaches an LLM).
if (
!body ||
typeof body !== "object" ||
typeof (body as { disableCoach?: unknown }).disableCoach !== "boolean"
) {
const parsed = patchBodySchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "auth.me.disable-coach.patch.invalid_shape" } });
throw new HttpError(422, "disable-coach.body.invalid_shape");
return returnAllZodIssues(parsed.error, 422);
}
const next = (body as { disableCoach: boolean }).disableCoach;
const next = parsed.data.disableCoach;
const previous = await prisma.user.findUnique({
where: { id: user.id },
@@ -0,0 +1,270 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
toJson: <T,>(v: T) => v,
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function mkPatch(body: unknown): Request {
return new Request("http://localhost/api/auth/me/notification-prefs", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/notification-prefs", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/notification-prefs"),
);
expect(res.status).toBe(401);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("returns the documented defaults for a fresh user (null row)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: null,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/notification-prefs"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({ medication: { clientManaged: false } });
});
it("returns the resolved prefs when the row holds a value", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/notification-prefs"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({ medication: { clientManaged: true } });
});
it("returns defaults when the persisted shape has drifted", async () => {
// Forward-compat: an admin hand-edit / future-rename should not
// crash the GET.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { unknownCategory: { foo: "bar" } },
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/notification-prefs"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({ medication: { clientManaged: false } });
});
});
describe("PATCH /api/auth/me/notification-prefs", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: true } }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("flips medication.clientManaged on and writes the audit row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: true } }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({ medication: { clientManaged: true } });
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
notificationPrefs: { medication: { clientManaged: true } },
},
});
expect(auditLog).toHaveBeenCalledWith(
"user.notification-prefs.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: { medication: { clientManaged: false } },
next: { medication: { clientManaged: true } },
changed: ["medication"],
}),
}),
);
});
it("rejects malformed JSON with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const req = new Request(
"http://localhost/api/auth/me/notification-prefs",
{
method: "PATCH",
body: "{ not valid json",
headers: { "Content-Type": "application/json" },
},
);
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects a non-boolean clientManaged with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: "yes" } }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("deep-merges over existing siblings without overwriting them", async () => {
// Forward-compat: a row that already carries an unknown sibling
// category (e.g. a future "mood" key persisted by a newer client)
// must survive a PATCH that only touches `medication`. The route
// parses with the current zod schema first, then deep-merges the
// input over the parsed (defaulted) base. Today, "unknown sibling"
// resolves back to defaults; the test pins that the medication
// PATCH still lands and that future schema growth (adding new
// siblings to the zod shape) will preserve them.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: false } },
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: true } }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
notificationPrefs: { medication: { clientManaged: true } },
},
});
});
it("returns the merged shape unchanged on an empty PATCH body", async () => {
// Idempotent — an empty body keeps the row as-is and still writes
// the audit trail (mirrors the disable-coach posture).
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({}),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({ medication: { clientManaged: true } });
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
notificationPrefs: { medication: { clientManaged: true } },
},
});
});
it("returns 429 when the per-user rate-limit fires", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 30_000,
});
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: true } }),
);
expect(res.status).toBe(429);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,133 @@
/**
* v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences endpoint.
*
* GET /api/auth/me/notification-prefs — current resolved prefs.
* PATCH /api/auth/me/notification-prefs — body `{ medication?: { clientManaged?: boolean } }`.
* Deep-merges the supplied
* shape over the persisted
* row so future categories
* slot in next to
* `medication` without
* overwriting siblings.
*
* Auth is shared via `requireAuth()` so cookie sessions + Bearer
* tokens both work (the iOS app uses Bearer). Default shape lives in
* `parseNotificationPrefs` — a null DB row resolves to
* `{ medication: { clientManaged: false } }`, preserving the legacy
* server-side reminder behaviour for any user the iOS client has not
* yet opted in.
*
* Idempotent. The endpoint always returns the fully-resolved next
* state so the client can hard-set the optimistic update without an
* extra round-trip. Rate-limit is intentionally generous (60/min) —
* matches the disable-coach route which is the closest analogue.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma, toJson } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
notificationPrefsSchema,
parseNotificationPrefs,
resolveNotificationPrefs,
} from "@/lib/validations/notification-prefs";
export const dynamic = "force-dynamic";
const PATCH_RATE_LIMIT = 60;
const PATCH_WINDOW_MS = 60_000;
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.notification-prefs.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { notificationPrefs: true },
});
return apiSuccess(parseNotificationPrefs(row?.notificationPrefs ?? null));
});
export const PATCH = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`notification-prefs:patch:${user.id}`,
PATCH_RATE_LIMIT,
PATCH_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(422, "notification-prefs.body.invalid_json");
}
const parsed = notificationPrefsSchema.safeParse(body ?? {});
if (!parsed.success) {
annotate({
action: { name: "auth.me.notification-prefs.patch.invalid_shape" },
meta: { issues: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422);
}
// Deep-merge the partial input over the current row so a PATCH that
// only touches `medication` does not overwrite future sibling
// categories (mood, personal_records, ...). The full resolved shape
// is persisted so the column layout stays stable across schema
// additions.
const current = await prisma.user.findUnique({
where: { id: user.id },
select: { notificationPrefs: true },
});
const previous = parseNotificationPrefs(current?.notificationPrefs ?? null);
const merged = resolveNotificationPrefs(
current?.notificationPrefs ?? null,
parsed.data,
);
await prisma.user.update({
where: { id: user.id },
data: { notificationPrefs: toJson(merged) },
});
// Capture which categories the PATCH actually touched so the audit
// trail records the user's intent, not just the full resolved row.
const changedCategories = Object.keys(parsed.data);
await auditLog("user.notification-prefs.update", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
previous,
next: merged,
changed: changedCategories,
},
});
annotate({
action: { name: "user.notification_prefs.update" },
meta: {
changed: changedCategories,
medication_client_managed: merged.medication.clientManaged,
},
});
return apiSuccess(merged);
});
+1 -1
View File
@@ -34,7 +34,7 @@ export const GET = apiHandler(async () => {
lastReportPracticeName: user.lastReportPracticeName ?? null,
// v1.4.47 W3 — per-user Coach opt-out. Default `false` if the
// column is absent (partial-deploy rollback safety, see migration
// 0076 commentary). Every Coach mount point on the client checks
// 0078 commentary). Every Coach mount point on the client checks
// `user.disableCoach` BELOW the operator-level `flags.coach`
// short-circuit; both gates must agree to paint the affordance.
disableCoach: user.disableCoach ?? false,
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
@@ -94,6 +94,14 @@ beforeEach(() => {
} as never);
});
// v1.4.48 L13 — restore real timers no matter how a test exits. A failed
// assertion inside an `it` that opted into fake timers would otherwise
// leak the fake clock into the next test in the file (or, worse, the
// next file in the same vitest worker).
afterEach(() => {
vi.useRealTimers();
});
describe("GET /api/dashboard/summary", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
@@ -653,7 +661,7 @@ describe("GET /api/dashboard/summary", () => {
data: { compliance: { scheduledToday: number; takenToday: number } };
};
expect(body.data.compliance.scheduledToday).toBe(1);
vi.useRealTimers();
// v1.4.48 L13 — suite-level `afterEach` restores real timers.
});
it("computes intake compliance for today", async () => {
@@ -36,6 +36,15 @@ vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/logging/context")>();
return {
...actual,
annotate: vi.fn(),
};
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -48,6 +57,7 @@ vi.mock("next/headers", () => ({
import { PUT, __resetAuditDedupMemoForTests } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { annotate } from "@/lib/logging/context";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const SESSION_OK = {
@@ -135,13 +145,14 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("dashboard.widgets.validation-failed");
const details = JSON.parse(call.data.details) as {
issues: Array<{ path: string; code: string; message: string }>;
issues: Array<{ path: string; code: string }>;
};
expect(details.issues.length).toBe(2);
for (const issue of details.issues) {
// Audit row carries the same sanitised shape — issue.params never
// hits the ledger either.
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
// v1.4.49 — audit row is the persisted surface. `message`
// strips here so a future Zod code that embeds the offending
// value cannot leak into the ledger.
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
}
});
@@ -165,6 +176,58 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
expect(body2.details.issues.length).toBe(2);
});
it("surfaces received_keys + received_shape_excerpt + zod_issues in the wide-event meta (v1.4.48 H-iOS-1)", async () => {
const payload = { version: 2, widgets: [], extraGarbage: "from-ios" };
const res = await callPut(makeReq(payload));
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"dashboard.widgets.validation-failed",
);
expect(annotated, "validation-failed annotate call").toBeTruthy();
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
// Top-level keys mirror the iOS-sent payload (NOT the schema keys).
expect(meta.received_keys).toEqual(
expect.arrayContaining(["version", "widgets", "extraGarbage"]),
);
// Excerpt is a JSON-stringified prefix, hard-capped at 256 chars.
expect(typeof meta.received_shape_excerpt).toBe("string");
expect((meta.received_shape_excerpt as string).length).toBeLessThanOrEqual(
256,
);
expect(meta.received_shape_excerpt as string).toContain("\"version\":2");
// zod_issues is the same sanitised array surfaced under details.issues.
const issues = meta.zod_issues as Array<{ path: string; code: string }>;
expect(Array.isArray(issues)).toBe(true);
expect(issues.length).toBeGreaterThanOrEqual(2);
for (const issue of issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("caps received_shape_excerpt at 256 chars even for a large iOS payload", async () => {
const widgets = Array.from({ length: 30 }, (_, i) => ({
id: `widget-${i}-${"x".repeat(20)}`,
visible: true,
order: i,
}));
const res = await callPut(makeReq({ version: 99, widgets }));
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"dashboard.widgets.validation-failed",
);
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
expect((meta.received_shape_excerpt as string).length).toBe(256);
});
it("does not block the 422 response when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
@@ -178,4 +241,65 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
};
expect(body.details.issues.length).toBe(2);
});
it("redacts sensitive keys before writing the wide-event received_shape_excerpt (v1.4.49)", async () => {
// Caller adds a credential-shaped field — must not land in the
// wide-event excerpt verbatim. The denylist also covers nested
// members so `payload.apiKey` is redacted, while `version` /
// `widgets` stay readable for operator debug.
const payload = {
version: 2,
widgets: [],
apnsToken: "ff".repeat(32),
authorization: "Bearer leaked",
payload: { apiKey: "sk_live_xxx" },
};
const res = await callPut(makeReq(payload));
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"dashboard.widgets.validation-failed",
);
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
const excerpt = meta.received_shape_excerpt as string;
// Sensitive values are never written into the excerpt.
expect(excerpt).not.toContain("ff".repeat(32));
expect(excerpt).not.toContain("Bearer leaked");
expect(excerpt).not.toContain("sk_live_xxx");
// The redactor leaves the literal sentinel behind so an operator
// can still see the shape of the rejected payload.
expect(excerpt).toContain("[redacted]");
// Non-sensitive keys still surface for debugging.
expect(meta.received_keys).toEqual(
expect.arrayContaining([
"version",
"widgets",
"apnsToken",
"authorization",
"payload",
]),
);
});
it("strips `message` from the audit-ledger issues row (v1.4.49)", async () => {
// The wide-event meta keeps full message for operator debugging,
// but the persisted auditLog row must only carry `path` + `code`
// so a future Zod code that embeds the offending value cannot
// leak into the ledger.
await callPut(makeReq({ version: 2, widgets: [] }));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { details: string };
};
const parsed = JSON.parse(call.data.details) as {
issues: Array<Record<string, unknown>>;
};
for (const issue of parsed.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
}
});
});
+28 -2
View File
@@ -8,6 +8,7 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
buildPayloadDiagnostic,
safeJson,
returnAllZodIssues,
sanitiseZodIssues,
@@ -28,6 +29,7 @@ import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
import { invalidateUserDashboardWidgets } from "@/lib/cache/invalidate";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import type { NextRequest } from "next/server";
// Single source of truth — every widget id rendered by the Settings →
@@ -145,9 +147,25 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// `/api/admin/audit` for these without combing the iOS dev
// console.
const issues = sanitiseZodIssues(parsed.error.issues);
// v1.4.48 H-iOS-1 — surface the iOS-sent payload shape alongside
// the Zod rejection so a single wide-event line carries enough
// detail to diagnose serialiser drift in `HealthLog-iOS`. We log
// ONLY the top-level keys plus a hard 256-char JSON excerpt — never
// the full body — so PII / token-like fields cannot leak. The
// diagnostic shape is built by the shared `buildPayloadDiagnostic`
// helper (v1.4.49) so the widget + series routes can't drift; the
// body is routed through `redactSensitiveFields` first so any
// future field matching the denylist (password / token / secret /
// apiKey / authorization / csrfState / nonce) lands as the literal
// `"[redacted]"` instead of its raw value.
const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(body));
annotate({
action: { name: "dashboard.widgets.validation-failed" },
meta: { issue_count: issues.length },
meta: {
issue_count: issues.length,
...payloadDiagnostic,
zod_issues: issues,
},
});
// v1.4.43 B2 — gate the breadcrumb behind a 60 s (userId, action) dedup
// so a misbehaving iOS client retrying every second cannot pump
@@ -160,12 +178,20 @@ export const PUT = apiHandler(async (request: NextRequest) => {
)
) {
// Best-effort breadcrumb — never block the 422 on a write miss.
// v1.4.49 — strip `message` from the audit-ledger row so Zod
// codes that embed the offending value in their default message
// (`invalid_enum_value` and similar) cannot leak user content
// through the audit surface. The wide-event excerpt above
// already carries the shape signal for operator debugging.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "dashboard.widgets.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+9 -1
View File
@@ -85,12 +85,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "devices.register.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row. Device
// registration carries `token` + `apnsToken`; the default Zod
// message for `invalid_format` echoes the offending value, which
// would persist an APNs token (or a forged near-miss) into the
// audit ledger.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "devices.register.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+7 -1
View File
@@ -103,12 +103,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "ingest.medication.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; external
// ingest carries free-text `medicationName` + opaque
// `idempotencyKey`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: apiToken.userId,
action: "ingest.medication.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+7 -1
View File
@@ -152,12 +152,18 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
action: { name: "integrations.healthkit.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// HealthKit patch payload carries caller-provided `id` + `kind`
// strings that Zod can echo.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "integrations.healthkit.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+9 -1
View File
@@ -70,12 +70,20 @@ export const PUT = apiHandler(
action: { name: "measurements.update.validation-failed" },
meta: { issue_count: issues.length, measurement_id: id },
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// update schema carries free-text `notes`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.update.validation-failed",
details: JSON.stringify({ issues, measurementId: id }),
details: JSON.stringify({
issues: auditIssues,
measurementId: id,
}),
},
})
.catch(() => {
+19 -3
View File
@@ -56,12 +56,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
action: { name: "measurements.list.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row to keep
// any free-text query slice (custom `dayKey`, attacker-shaped
// `type=`) out of the persisted breadcrumb.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.list.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -583,12 +589,17 @@ async function postMeasurement(request: NextRequest) {
action: { name: "measurements.create.batch.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; batch
// rows carry free-text `notes` per measurement.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.create.batch.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -671,12 +682,17 @@ async function postMeasurement(request: NextRequest) {
action: { name: "measurements.create.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; single
// measurement carries free-text `notes`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.create.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -16,6 +16,15 @@ vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/logging/context")>();
return {
...actual,
annotate: vi.fn(),
};
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -28,6 +37,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { annotate } from "@/lib/logging/context";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -157,6 +167,50 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
expect(call.data.action).toBe("measurements.series.validation-failed");
});
it("surfaces received_keys + received_shape_excerpt + zod_issues in the wide-event meta (v1.4.48 H-iOS-2)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(req("kind=garbage&days=0&extraGarbage=fromIos"));
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"measurements.series.validation-failed",
);
expect(annotated, "validation-failed annotate call").toBeTruthy();
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
expect(meta.received_keys).toEqual(
expect.arrayContaining(["kind", "days", "extraGarbage"]),
);
expect(typeof meta.received_shape_excerpt).toBe("string");
expect((meta.received_shape_excerpt as string).length).toBeLessThanOrEqual(
256,
);
expect(meta.received_shape_excerpt as string).toContain("\"kind\":\"garbage\"");
const issues = meta.zod_issues as Array<{ path: string; code: string }>;
expect(Array.isArray(issues)).toBe(true);
expect(issues.length).toBeGreaterThanOrEqual(2);
for (const issue of issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("caps received_shape_excerpt at 256 chars even for a long iOS query", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const long = "x".repeat(500);
const res = await GET(req(`kind=garbage&days=0&junk=${long}`));
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"measurements.series.validation-failed",
);
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
expect((meta.received_shape_excerpt as string).length).toBe(256);
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
@@ -165,4 +219,47 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
const res = await GET(req("kind=garbage&days=0"));
expect(res.status).toBe(422);
});
it("redacts sensitive query keys before writing the wide-event received_shape_excerpt (v1.4.49)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// An attacker-shaped query that mixes a valid `kind` with a
// credential-shaped `token` param. The excerpt must keep `kind`
// readable for operator debug but redact the token verbatim.
const res = await GET(
req("kind=garbage&days=0&token=hlk_secret_value&apiKey=sk_live_xxx"),
);
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"measurements.series.validation-failed",
);
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
const excerpt = meta.received_shape_excerpt as string;
expect(excerpt).not.toContain("hlk_secret_value");
expect(excerpt).not.toContain("sk_live_xxx");
expect(excerpt).toContain("[redacted]");
// The key inventory still surfaces so operators see the shape.
expect(meta.received_keys).toEqual(
expect.arrayContaining(["kind", "days", "token", "apiKey"]),
);
});
it("strips `message` from the audit-ledger issues row (v1.4.49)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await GET(req("kind=garbage&days=0"));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { details: string };
};
const parsed = JSON.parse(call.data.details) as {
issues: Array<Record<string, unknown>>;
};
for (const issue of parsed.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
}
});
});
+26 -5
View File
@@ -14,11 +14,13 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
buildPayloadDiagnostic,
returnAllZodIssues,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import type { MeasurementType } from "@/generated/prisma/client";
const kindEnum = z.enum([
@@ -70,23 +72,42 @@ function stdDev(values: number[]): number {
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const parsed = querySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams),
);
const rawQuery = Object.fromEntries(request.nextUrl.searchParams);
const parsed = querySchema.safeParse(rawQuery);
if (!parsed.success) {
// v1.4.43 W6 — iOS chart loader hot path; multi-issue 422 +
// audit breadcrumb keyed `measurements.series.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
// v1.4.48 H-iOS-2 — surface the iOS-sent query shape alongside
// the Zod rejection. Top-level keys + a hard 256-char JSON excerpt
// only; never the full payload, so token-shaped query params (we
// do not currently accept any but the truncation keeps that
// invariant cheap) cannot leak. Shared helper via v1.4.49 so the
// widget + series routes can't drift on the diagnostic shape; the
// query map is routed through `redactSensitiveFields` first so any
// future credential-shaped query string (`token`, `apiKey`,
// `csrfState`) redacts to `"[redacted]"` before the truncate.
const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(rawQuery));
annotate({
action: { name: "measurements.series.validation-failed" },
meta: { issue_count: issues.length },
meta: {
issue_count: issues.length,
...payloadDiagnostic,
zod_issues: issues,
},
});
// v1.4.49 — strip `message` from the audit-ledger row so Zod
// codes that embed the offending value (`invalid_enum_value` etc.)
// cannot leak user content through the audit surface.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.series.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -62,12 +62,20 @@ export const POST = apiHandler(
action: { name: "medications.intake.import.validation-failed" },
meta: { issue_count: issues.length, medication_id: id },
});
// v1.4.49 — strip `message` from the audit-ledger row; CSV
// imports carry caller-provided strings that Zod may echo.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.import.validation-failed",
details: JSON.stringify({ issues, medicationId: id }),
details: JSON.stringify({
issues: auditIssues,
medicationId: id,
}),
},
})
.catch(() => {
+17 -2
View File
@@ -49,12 +49,20 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
action: { name: "medications.intake.create.validation-failed" },
meta: { issue_count: issues.length, medication_id: id },
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// intake payload carries `idempotencyKey` (opaque caller string).
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.create.validation-failed",
details: JSON.stringify({ issues, medicationId: id }),
details: JSON.stringify({
issues: auditIssues,
medicationId: id,
}),
},
})
.catch(() => {
@@ -211,12 +219,19 @@ export const GET = apiHandler(
action: { name: "medications.intake.list.validation-failed" },
meta: { issue_count: issues.length, medication_id: id },
});
// v1.4.49 — strip `message` from the audit-ledger row.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.list.validation-failed",
details: JSON.stringify({ issues, medicationId: id }),
details: JSON.stringify({
issues: auditIssues,
medicationId: id,
}),
},
})
.catch(() => {
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
@@ -103,6 +103,14 @@ beforeEach(() => {
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
// v1.4.48 L13 — restore real timers no matter how a test exits. A failed
// assertion inside an `it` that opted into fake timers would otherwise
// leak the fake clock into the next test in the file (or, worse, the
// next file in the same vitest worker).
afterEach(() => {
vi.useRealTimers();
});
describe("GET /api/medications/intake", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
@@ -262,7 +270,7 @@ describe("GET /api/medications/intake", () => {
);
expect(res.status).toBe(200);
expect(prisma.medicationIntakeEvent.createMany).not.toHaveBeenCalled();
vi.useRealTimers();
// v1.4.48 L13 — suite-level `afterEach` restores real timers.
});
it("returns compliance buckets for the last N days", async () => {
+5 -1
View File
@@ -120,12 +120,16 @@ async function postBulk(request: NextRequest): Promise<Response> {
action: { name: "medications.intake.bulk.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.bulk.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+11 -2
View File
@@ -82,12 +82,17 @@ export const GET = apiHandler(async (request: NextRequest) => {
action: { name: "medications.intake.list.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; iOS-sent
// query strings can flow into Zod issue messages.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.list.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -288,12 +293,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "medications.intake.update.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.update.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+6 -1
View File
@@ -75,12 +75,17 @@ export const PUT = apiHandler(
action: { name: "mood-entries.update.validation-failed" },
meta: { issue_count: issues.length, moodEntryId: id },
});
// v1.4.49 — strip `message` from the audit-ledger row; mood
// update carries free-text `note` + `tags`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood-entries.update.validation-failed",
details: JSON.stringify({ issues, moodEntryId: id }),
details: JSON.stringify({ issues: auditIssues, moodEntryId: id }),
},
})
.catch(() => {
+6 -1
View File
@@ -122,12 +122,17 @@ async function postBulk(request: NextRequest): Promise<Response> {
action: { name: "mood.bulk.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; bulk mood
// entries carry free-text `note` + `tags` per row.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood.bulk.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+16 -2
View File
@@ -44,12 +44,20 @@ export const GET = apiHandler(async (request: NextRequest) => {
action: { name: "mood-entries.list.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row so Zod
// codes that embed the offending value (`invalid_enum_value` etc.)
// cannot leak user-typed content. Mood-entries carry free-text
// `note` + `tags`; defensive strip everywhere mood content can
// reach a Zod issue.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood-entries.list.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -116,12 +124,18 @@ async function postMoodEntry(request: NextRequest) {
action: { name: "mood-entries.create.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// mood-create schema's free-text `note` + `tags` could land in a
// Zod issue message verbatim.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood-entries.create.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+9 -1
View File
@@ -57,10 +57,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
: defaultLocale;
const t = getServerTranslator(locale).t;
// Send as MEDICATION_REMINDER so the dispatcher's time-sensitive /
// priority-10 branch fires. Otherwise the test runs at the default
// `active` interruption-level and iOS may summarise it into the
// Notification Center rather than presenting a lock-screen banner —
// leaving the user with no signal whether real medication reminders
// (which DO use time-sensitive) will surface on the lock screen.
// The title/body strings still read "Test notification" so the user
// doesn't mistake this for an actual scheduled dose.
const result = await sendViaApns(user.id, {
title: t("notifications.admin.testNotificationTitle"),
message: t("notifications.admin.testNotificationBody"),
eventType: "SYSTEM_ALERT",
eventType: "MEDICATION_REMINDER",
});
annotate({
+6 -1
View File
@@ -65,12 +65,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "tokens.create.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// mint payload carries a free-text `name`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "tokens.create.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -0,0 +1,308 @@
/**
* v1.4.48 M3 — unit-level coverage for the atomic nonce-consumption
* branch.
*
* The integration tests in `tests/integration/withings-oauth*.test.ts`
* already pin the full handshake against real Postgres (happy path,
* replay, expired, cross-user). This file specifically targets the
* delete-first race window the M3 refactor closed: two concurrent
* callbacks with the same nonce — the first wins, the second hits
* Prisma P2025 and lands on the replay branch.
*
* Co-tests M7 — non-P2025 delete failures (DB-connection drop /
* integrity violation) surface via Wide-Event `addWarning` instead
* of being silently swallowed.
*
* v1.4.49 — assertions updated for the four-way reason-tag split
* (`csrf1`, `replay`, `expired`, `cross_user`) and the QA-2
* err.name-only warning template.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({
user: { id: "user-1" },
session: { id: "sess-1" },
})),
HttpError: class HttpError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
},
}));
vi.mock("@/lib/db", () => ({
prisma: {
withingsOAuthState: {
delete: vi.fn(),
},
withingsConnection: {
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((s: string) => `enc(${s})`),
}));
vi.mock("@/lib/withings/client", () => ({
exchangeCode: vi.fn(),
WITHINGS_OAUTH_SCOPE: "user.metrics,user.activity",
}));
vi.mock("@/lib/withings/credentials", () => ({
getUserWithingsCredentials: vi.fn(),
}));
vi.mock("@/lib/withings/sync", () => ({
setupWebhook: vi.fn(() => Promise.resolve()),
}));
vi.mock("@/lib/integrations/status", () => ({
markReconnected: vi.fn(),
}));
const addWarning = vi.fn();
const annotateMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/logging/context", () => ({
annotate: annotateMock,
getEvent: vi.fn(() => ({ addWarning, setError: vi.fn() })),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
const NONCE = "abcdefghijklmnopqrstuv"; // 22-char base64url
const APP_URL = "http://localhost:3000";
function callbackRequest(state: string, cookieState: string): NextRequest {
return new NextRequest(
`http://localhost/api/withings/callback?code=auth-code-1&state=${encodeURIComponent(state)}`,
{
method: "GET",
headers: {
cookie: `withings_state=${cookieState}`,
},
},
);
}
beforeEach(() => {
vi.clearAllMocks();
process.env.NEXT_PUBLIC_APP_URL = APP_URL;
});
describe("withings/callback atomic nonce consumption (M3)", () => {
it("concurrent callbacks with the same nonce — only one succeeds, the second hits the P2025 replay branch", async () => {
// First callback: delete returns the row (win the race).
// Second callback: delete throws P2025 (row already consumed).
const winningRow = {
nonce: NONCE,
userId: "user-1",
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
createdAt: new Date(),
};
const p2025 = new Prisma.PrismaClientKnownRequestError(
"An operation failed because it depends on one or more records that were required but not found.",
{ code: "P2025", clientVersion: "x" },
);
const deleteMock = vi.mocked(prisma.withingsOAuthState.delete);
deleteMock
.mockResolvedValueOnce(winningRow as never)
.mockRejectedValueOnce(p2025);
// Stub the rest of the happy path for the winning request — code
// exchange returns plausible tokens so the upsert + redirect run.
const { getUserWithingsCredentials } = await import(
"@/lib/withings/credentials"
);
vi.mocked(getUserWithingsCredentials).mockResolvedValue({
clientId: "cid",
clientSecret: "csec",
} as never);
const { exchangeCode } = await import("@/lib/withings/client");
vi.mocked(exchangeCode).mockResolvedValue({
userid: "wuid",
access_token: "atok",
refresh_token: "rtok",
expires_in: 3600,
} as never);
// Fire both legs concurrently. The mock's call ordering is FIFO,
// so whichever `await` hits the Prisma mock first gets the
// success result. Both legs use the same nonce + cookie value.
const reqA = callbackRequest(NONCE, NONCE);
const reqB = callbackRequest(NONCE, NONCE);
const [resA, resB] = await Promise.all([GET(reqA), GET(reqB)]);
// Both responses are 307 redirects.
expect(resA.status).toBe(307);
expect(resB.status).toBe(307);
// Exactly one delete-attempt per leg — the atomic shape collapses
// findUnique + delete into a single round-trip.
expect(deleteMock).toHaveBeenCalledTimes(2);
// Exactly one leg landed on `withings=connected`, exactly one
// landed on the replay branch (`withings=error&reason=replay`).
const locations = [resA, resB]
.map((r) => r.headers.get("location"))
.sort();
expect(locations).toHaveLength(2);
expect(
locations.some((l) => l?.includes("withings=connected")),
).toBe(true);
expect(
locations.some((l) => l?.includes("withings=error&reason=replay")),
).toBe(true);
// P2025 must NOT surface as a warning — it's the expected
// replay-branch signal, not a real infra problem.
expect(addWarning).not.toHaveBeenCalled();
// Wide-Event annotation carries the same reason so operators can
// grep without DB-shell.
expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "replay" } });
});
it("non-P2025 delete failure (e.g. connection drop) surfaces a Wide-Event warning carrying err.name only, and bounces to the error page", async () => {
// M7 — a real infra failure on the consume-delete must not be
// silently swallowed.
//
// v1.4.49 QA-2 — the warning template now interpolates `err.name`
// rather than `${err}`. A Prisma error whose `message` echoes the
// offending value (e.g. the raw nonce) into the wide-event log
// would otherwise leak it.
class TimeoutError extends Error {
constructor() {
super("connection terminated unexpectedly nonce=abc123");
this.name = "TimeoutError";
}
}
vi.mocked(prisma.withingsOAuthState.delete).mockRejectedValueOnce(
new TimeoutError(),
);
const req = callbackRequest(NONCE, NONCE);
const res = await GET(req);
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain(
"withings=error&reason=state",
);
// The warning carries the error NAME so the audit trail can
// distinguish "row was gone" (P2025, no warning) from "DB unhappy"
// (warning, infra signal) without ever interpolating the message
// body.
expect(addWarning).toHaveBeenCalledTimes(1);
expect(addWarning).toHaveBeenCalledWith(
"oauth-state-delete failed: TimeoutError",
);
// Defensive: the message body (which Prisma may use to echo the
// offending value) must not appear in the warning.
expect(addWarning).not.toHaveBeenCalledWith(
expect.stringContaining("nonce=abc123"),
);
expect(addWarning).not.toHaveBeenCalledWith(
expect.stringContaining("connection terminated"),
);
});
it("delete succeeds but row has expired — validity check trips with reason=expired, no token exchange", async () => {
const expiredRow = {
nonce: NONCE,
userId: "user-1",
expiresAt: new Date(Date.now() - 60_000),
createdAt: new Date(),
};
vi.mocked(prisma.withingsOAuthState.delete).mockResolvedValueOnce(
expiredRow as never,
);
const { exchangeCode } = await import("@/lib/withings/client");
const req = callbackRequest(NONCE, NONCE);
const res = await GET(req);
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain(
"withings=error&reason=expired",
);
// Single atomic round-trip consumed the row; no token exchange.
expect(exchangeCode).not.toHaveBeenCalled();
expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "expired" } });
});
it("delete succeeds but row's userId mismatches session — validity check trips with reason=cross_user, no token exchange", async () => {
const crossUserRow = {
nonce: NONCE,
userId: "user-OTHER",
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
createdAt: new Date(),
};
vi.mocked(prisma.withingsOAuthState.delete).mockResolvedValueOnce(
crossUserRow as never,
);
const { exchangeCode } = await import("@/lib/withings/client");
const req = callbackRequest(NONCE, NONCE);
const res = await GET(req);
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain(
"withings=error&reason=cross_user",
);
expect(exchangeCode).not.toHaveBeenCalled();
expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "cross_user" } });
});
it("non-P2025 infra error branch annotates reason=state on the Wide Event (legacy fallback for unclassified DB failures)", async () => {
// v1.4.49 — the four named reasons (csrf1, replay, expired,
// cross_user) cover every "we know what went wrong" branch. A
// raw infra failure is intentionally still tagged `state` —
// operators reading the wide event will see the err.name in the
// warning and the `reason=state` annotation, which is enough to
// distinguish it from the four classified branches.
class TimeoutError extends Error {
constructor() {
super("connection terminated");
this.name = "TimeoutError";
}
}
vi.mocked(prisma.withingsOAuthState.delete).mockRejectedValueOnce(
new TimeoutError(),
);
const req = callbackRequest(NONCE, NONCE);
await GET(req);
expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "state" } });
});
it("URL state vs cookie state mismatch short-circuits with reason=csrf1 BEFORE the atomic delete fires", async () => {
// CSRF leg 1 — must reject without touching the ledger so a
// probe can't grief legitimate rows by issuing deletes for
// nonces it doesn't legitimately hold the cookie for.
const req = callbackRequest("aaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbb");
const res = await GET(req);
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain(
"withings=error&reason=csrf1",
);
expect(prisma.withingsOAuthState.delete).not.toHaveBeenCalled();
expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "csrf1" } });
});
});
+88 -45
View File
@@ -8,6 +8,7 @@ import { getUserWithingsCredentials } from "@/lib/withings/credentials";
import { WITHINGS_OAUTH_STATE_COOKIE } from "@/lib/withings/oauth-state";
import { setupWebhook } from "@/lib/withings/sync";
import { markReconnected } from "@/lib/integrations/status";
import { Prisma } from "@/generated/prisma/client";
import { NextRequest, NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";
@@ -23,6 +24,17 @@ import { timingSafeEqual } from "node:crypto";
* runs. The row is consumed (deleted) on every exit branch so a
* replay of the same nonce fails the second time.
*
* v1.4.48 M3 — the row is now consumed via a single delete-first
* round-trip. Prisma `delete` returns the row on success, so the
* post-delete validity checks (`expiresAt`, `userId`) still run
* against the consumed payload. Two concurrent callbacks with the
* same nonce now race at the Postgres row level — only one delete
* wins, the other hits P2025 ("record not found") and is treated as
* a replay, bounced to the error page. Previously the `findUnique +
* separate delete` shape left a tiny RTT window where both legs
* could pass the lookup before either issued the delete, contrary
* to the docstring's atomicity promise.
*
* Closes the v1.4.43 security audit L-1 finding: the cookie now
* carries only the opaque nonce, so a future refactor flipping it to
* non-httpOnly cannot leak the user id into request logs / network
@@ -39,13 +51,72 @@ export const GET = apiHandler(async (request: NextRequest) => {
// CSRF check, leg 1: URL state must match the cookie state byte-for-byte.
// Timing-safe comparison so a probe can't recover the cookie bytes via
// response-time analysis.
// response-time analysis. This runs BEFORE the atomic delete so a
// probe with arbitrary state values can't grief legitimate ledger
// rows by triggering deletes for nonces it doesn't legitimately
// hold the cookie for.
//
// v1.4.49 — reason tags now distinguish the four post-delete branches
// so operators reading the audit trail can tell `csrf1` (URL/cookie
// mismatch, short-circuit before delete), `replay` (P2025 — nonce
// already consumed), `expired` (valid row but TTL elapsed), and
// `cross_user` (valid row but session userId mismatch) apart. A
// matching `meta.reason` Wide-Event annotation carries the same so
// operators can grep without DB-shell.
if (
!state ||
!storedState ||
state.length !== storedState.length ||
!timingSafeEqual(Buffer.from(state), Buffer.from(storedState))
) {
annotate({ meta: { reason: "csrf1" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=csrf1",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
// CSRF check, leg 2: atomically consume the ledger row. Prisma
// `delete` returns the row on success so `expiresAt` + `userId`
// checks still run against the consumed payload. P2025 ("record
// not found") means the nonce was already consumed by a prior
// callback (a replay) or never existed — both land on the same
// error redirect. Atomic at the Postgres row level: two concurrent
// callbacks with the same nonce can no longer both pass the
// lookup before either issues the delete.
let stateRow: { userId: string; expiresAt: Date } | null = null;
try {
stateRow = await prisma.withingsOAuthState.delete({
where: { nonce: state },
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2025"
) {
// Row already consumed (replay) or never existed.
annotate({ meta: { reason: "replay" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=replay",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
// A real infra problem (connection drop, integrity violation)
// shouldn't be silently swallowed. Surface it to the Wide Event
// so the audit trail captures it, then bounce to the error
// page. Re-throwing would land on the apiHandler's 500 path and
// strand the user without a clean redirect.
//
// v1.4.49 QA-2 — the warning interpolates `err.name` rather than
// `${err}` so a Prisma error message echoing the offending value
// (e.g. the raw nonce) into the wide-event log cannot leak it.
const errName = err instanceof Error ? err.name : "unknown";
getEvent()?.addWarning(`oauth-state-delete failed: ${errName}`);
annotate({ meta: { reason: "state" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=state",
@@ -54,42 +125,30 @@ export const GET = apiHandler(async (request: NextRequest) => {
);
}
// CSRF check, leg 2: the nonce must resolve to a live, unexpired
// ledger row whose `userId` matches the authenticated session.
// Resolving the user from the row (rather than parsing the cookie
// value) is the v1.4.43 audit recommendation — a future refactor
// flipping the cookie to non-httpOnly cannot leak the user id this
// way.
const stateRow = await prisma.withingsOAuthState.findUnique({
where: { nonce: state },
});
if (
!stateRow ||
stateRow.expiresAt <= new Date() ||
stateRow.userId !== user.id
) {
// Single-use: stamp out the row whether the expiry tripped, the
// user mismatched, or we found nothing. The user will retry from
// `withings/connect` with a fresh nonce.
if (stateRow) {
await prisma.withingsOAuthState
.delete({ where: { nonce: state } })
.catch(() => {});
}
// Validity checks against the consumed row. The row is already
// gone from the ledger by this point — single-use semantics —
// whether the expiry tripped, the userId mismatched, or
// everything looked good.
if (stateRow.expiresAt <= new Date()) {
annotate({ meta: { reason: "expired" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=state",
"/settings/integrations?withings=error&reason=expired",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
if (stateRow.userId !== user.id) {
annotate({ meta: { reason: "cross_user" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=cross_user",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
if (!code) {
// Consume the row even on the no-code path so the nonce can't
// be replayed by an attacker who scraped the redirect URL.
await prisma.withingsOAuthState
.delete({ where: { nonce: state } })
.catch(() => {});
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=nocode",
@@ -101,9 +160,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
try {
const creds = await getUserWithingsCredentials(user.id);
if (!creds) {
await prisma.withingsOAuthState
.delete({ where: { nonce: state } })
.catch(() => {});
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=nocreds",
@@ -156,13 +212,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
// don't write a fresh success-time — that's the next sync's job.
await markReconnected(user.id, "withings");
// Consume the ledger row — single-use semantics. From this point
// on a replay of the same `state` lands on the not-found branch
// above and redirects to the error page.
await prisma.withingsOAuthState
.delete({ where: { nonce: state } })
.catch(() => {});
const response = NextResponse.redirect(
new URL(
"/settings/integrations?withings=connected",
@@ -173,12 +222,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
return response;
} catch (err) {
getEvent()?.setError(err);
// Consume the row on the failure path too — the user is going to
// retry from `withings/connect` which mints a fresh nonce, and a
// stranded row buys us nothing.
await prisma.withingsOAuthState
.delete({ where: { nonce: state } })
.catch(() => {});
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=token",
@@ -0,0 +1,136 @@
/**
* v1.4.48 L5 + L14 — GET /api/withings/connect
*
* Pins:
* 1. Happy path: mints a nonce, creates a `WithingsOAuthState` row,
* redirects to the Withings authorize URL with the state cookie
* attached.
* 2. Rate-limit: the 11th call in a 60 s window redirects to
* `/settings/integrations?withings=error&reason=rate_limited`
* and does not call `prisma.withingsOAuthState.create`.
* 3. Row-create failure: a Prisma error inside the try/catch
* redirects to
* `/settings/integrations?withings=error&reason=connect`
* instead of bubbling a 500.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
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: {
withingsOAuthState: {
create: vi.fn(),
},
},
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/withings/credentials", () => ({
getUserWithingsCredentials: vi.fn(),
}));
vi.mock("@/lib/withings/client", () => ({
getAuthorizationUrl: vi.fn(
() => "https://account.withings.com/oauth2_user/authorize2?state=nonce-stub",
),
}));
vi.mock("@/lib/withings/oauth-state", () => ({
WITHINGS_OAUTH_STATE_COOKIE: "withings_oauth_state",
WITHINGS_OAUTH_STATE_TTL_MS: 10 * 60 * 1000,
mintWithingsOAuthStateNonce: vi.fn(() => "nonce-stub"),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => ({
setError: vi.fn(),
addWarning: vi.fn(),
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
function connectRequest(): NextRequest {
return new NextRequest("http://localhost/api/withings/connect", {
method: "GET",
});
}
beforeEach(() => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(prisma.withingsOAuthState.create).mockReset();
vi.mocked(prisma.withingsOAuthState.create).mockResolvedValue(
{} as never,
);
vi.mocked(getUserWithingsCredentials).mockResolvedValue({
clientId: "client-id",
clientSecret: "client-secret",
} as never);
});
describe("GET /api/withings/connect", () => {
it("redirects to the Withings authorize URL on the happy path", async () => {
const response = await GET(connectRequest());
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain(
"account.withings.com/oauth2_user/authorize2",
);
expect(prisma.withingsOAuthState.create).toHaveBeenCalledTimes(1);
expect(checkRateLimit).toHaveBeenCalledWith(
"withings:connect:u-1",
10,
60_000,
);
});
it("redirects to reason=rate_limited when the 11th call within 60 s is rejected", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const response = await GET(connectRequest());
expect(response.status).toBe(307);
const location = response.headers.get("location") ?? "";
expect(location).toContain("/settings/integrations");
expect(location).toContain("withings=error");
expect(location).toContain("reason=rate_limited");
expect(prisma.withingsOAuthState.create).not.toHaveBeenCalled();
});
it("redirects to reason=connect when the row create throws", async () => {
vi.mocked(prisma.withingsOAuthState.create).mockRejectedValueOnce(
new Error("Prisma transient failure"),
);
const response = await GET(connectRequest());
expect(response.status).toBe(307);
const location = response.headers.get("location") ?? "";
expect(location).toContain("/settings/integrations");
expect(location).toContain("withings=error");
expect(location).toContain("reason=connect");
});
});
+50 -10
View File
@@ -1,7 +1,8 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAuthorizationUrl } from "@/lib/withings/client";
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
import {
@@ -9,7 +10,7 @@ import {
WITHINGS_OAUTH_STATE_TTL_MS,
mintWithingsOAuthStateNonce,
} from "@/lib/withings/oauth-state";
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
/**
* Redirects the user to Withings OAuth authorization page.
@@ -21,11 +22,39 @@ import { NextResponse } from "next/server";
* nonce, and the callback resolves the user via the row's `userId`
* column. Closes the v1.4.43 security audit L-1 finding where the
* user id was recoverable from log entries / network captures.
*
* v1.4.48 L5 + L14 — endpoint is now rate-limited per-user (10 calls
* per 60 s) so a logged-in user with valid creds can't spam ledger
* rows for the full 10-min TTL window. The row-create call is wrapped
* in try/catch; a transient DB glitch now lands on the existing
* `/settings/integrations?withings=error&reason=connect` surface
* instead of bubbling a 500. Both rate-limit and create-failure paths
* redirect rather than returning JSON because the entry point is a
* browser navigation — a 429 envelope would surface as a blank page.
*/
export const GET = apiHandler(async () => {
const CONNECT_RATE_LIMIT = 10;
const CONNECT_WINDOW_MS = 60_000;
export const GET = apiHandler(async (req: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "withings.connect" } });
const rl = await checkRateLimit(
`withings:connect:${user.id}`,
CONNECT_RATE_LIMIT,
CONNECT_WINDOW_MS,
);
if (!rl.allowed) {
annotate({ action: { name: "withings.connect.rate_limited" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=rate_limited",
req.url,
),
);
}
const creds = await getUserWithingsCredentials(user.id);
if (!creds) {
return apiError(
@@ -35,13 +64,24 @@ export const GET = apiHandler(async () => {
}
const nonce = mintWithingsOAuthStateNonce();
await prisma.withingsOAuthState.create({
data: {
nonce,
userId: user.id,
expiresAt: new Date(Date.now() + WITHINGS_OAUTH_STATE_TTL_MS),
},
});
try {
await prisma.withingsOAuthState.create({
data: {
nonce,
userId: user.id,
expiresAt: new Date(Date.now() + WITHINGS_OAUTH_STATE_TTL_MS),
},
});
} catch (err) {
getEvent()?.setError(err);
annotate({ action: { name: "withings.connect.create_failed" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=connect",
req.url,
),
);
}
const url = getAuthorizationUrl(nonce, creds);
+1 -1
View File
@@ -45,7 +45,7 @@ export function LayoutCoachFab() {
// FAB returns null when the master OR the coach sub-flag is off.
if (!flags.coach) return null;
// v1.4.47 W3 — per-user opt-out beats the operator's "on" default.
// A privacy-leaning user who toggles "Hide Coach" in Settings → AI
// A privacy-leaning user who toggles "Hide Coach" in Settings → Insights
// gets the FAB short-circuited here, identical to the global-off
// branch above. The hook defaults to `false` in the absence of a
// QueryClient (legacy SSR-only snapshot tests).
@@ -130,8 +130,10 @@ describe("<DashboardLayoutSection> — drag-and-drop reorder", () => {
it("hint string localises to German", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />, "de");
// Anchor on a stable German fragment from the hint copy.
expect(html).toContain("Ziehe den Griff");
// v1.4.48 L9 — copy trimmed to ~12 words so screen readers do
// not spend ~6 s reading the hint on focus. Anchor on the
// shortened phrase.
expect(html).toContain("Pfeiltasten Hoch/Runter");
});
});
@@ -61,7 +61,7 @@ function buildUser(disableCoach: boolean): AuthUser {
import { AiSection } from "../ai-section";
function makeFetch(ok: boolean = true) {
function makeFetch() {
return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
const body = init?.body ? JSON.parse(init.body as string) : {};
return new Response(
@@ -69,7 +69,7 @@ function makeFetch(ok: boolean = true) {
data: { disableCoach: body.disableCoach ?? false },
}),
{
status: ok ? 200 : 500,
status: 200,
headers: { "Content-Type": "application/json" },
},
);
@@ -125,31 +125,6 @@ describe("Settings — DisableCoachCard", () => {
expect(html).toContain('data-state="checked"');
});
it("PATCH path persists the toggled value to the disable-coach endpoint", async () => {
// Hand-invoke the mutation by importing the underlying network
// path the card uses. Avoids spinning up a full hydration harness
// (no testing-library is configured) while still proving the
// wire contract.
const fetchSpy = makeFetch();
vi.stubGlobal("fetch", fetchSpy);
const res = await fetch("/api/auth/me/disable-coach", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ disableCoach: true }),
});
expect(res.status).toBe(200);
expect(fetchSpy).toHaveBeenCalledWith(
"/api/auth/me/disable-coach",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({ disableCoach: true }),
}),
);
const json = (await res.json()) as { data: { disableCoach: boolean } };
expect(json.data.disableCoach).toBe(true);
});
it("Switch description string matches the QoL audit (M2) copy", () => {
const html = render();
// The Marc-voice contract: "Hides the Coach button and drawer
@@ -0,0 +1,77 @@
/**
* v1.4.49 — `<MoodReminderCard>` status auto-clear parity.
*
* The v1.4.48 W-DISABLE-COACH refactor added a 3 s `setTimeout` to
* `<DisableCoachCard>` and claimed in the docstring that the pattern
* mirrored `<MoodReminderCard>`. It did not — the mood-reminder card
* had no auto-clear. v1.4.49 backfills the parity. This suite pins:
*
* 1. The card SSRs cleanly with no in-flight status banner.
* 2. The component module exposes the auto-clear plumbing
* (`scheduleClear` + `clearTimerRef`) so a future refactor can't
* silently regress the docstring claim.
*
* Project convention is SSR-only tests (no `@testing-library/react`);
* the actual timer-tick behaviour is verified through manual QA + the
* shared pattern with `<DisableCoachCard>` which is already audit-grade.
*/
import { describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/settings/notifications",
useSearchParams: () => new URLSearchParams(""),
}));
import { MoodReminderCard } from "../mood-reminder-card";
function render(): string {
const client = new QueryClient({
defaultOptions: { queries: { retry: 0 } },
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<MoodReminderCard isAuthenticated={true} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<MoodReminderCard> — v1.4.49 auto-clear parity", () => {
it("SSRs the card scaffolding with no in-flight status banner", () => {
const html = render();
// The Switch primitive surfaces `data-state="unchecked"` for the
// default `moodReminderEnabled: false` profile shape.
expect(html).toContain('data-state="unchecked"');
// No status message visible by default — the `<p role="status">`
// line is gated on `msg !== null`.
expect(html).not.toContain('role="status"');
});
it("contains the auto-clear plumbing introduced for v1.4.48 docstring parity", () => {
// Structural assertion: the source file ships the `scheduleClear`
// helper + `clearTimerRef` (mirroring `<DisableCoachCard>` from
// v1.4.48 M2). Reading the file rather than a runtime mount keeps
// the test deterministic in the SSR-only test environment.
const src = readFileSync(
resolve(__dirname, "../mood-reminder-card.tsx"),
"utf8",
);
expect(src).toContain("clearTimerRef");
expect(src).toContain("scheduleClear");
// 3 s timeout — must match the `<DisableCoachCard>` value so the
// two Settings cards age at the same rate.
expect(src).toMatch(/setTimeout\([^,]+,\s*3000\)/);
// Cleanup-on-unmount branch — without this a navigate-away during
// the 3 s window would leave a stray timer pointing at unmounted
// state.
expect(src).toContain("clearTimeout(clearTimerRef.current)");
});
});
+25 -46
View File
@@ -14,9 +14,9 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { setTourForceLaunch } from "@/components/onboarding/tour-launcher";
import { useAuth } from "@/hooks/use-auth";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { restartOnboardingTour } from "@/lib/onboarding/tour-restart";
import { queryKeys } from "@/lib/query-keys";
interface VersionPayload {
@@ -148,53 +148,32 @@ export function AboutSection() {
// sources/docs links. Settings → Account still has its own
// "Restart onboarding tour" — both buttons share the same flow.
const [replayingTour, setReplayingTour] = useState(false);
const [tourMessage, setTourMessage] = useState<string | null>(null);
const [tourMessageType, setTourMessageType] = useState<
"success" | "error" | null
>(null);
// v1.4.48 M6c — text + type collapsed into one discriminated state so
// the two values can never drift (a "success" message rendered in the
// destructive colour was the latent bug class).
const [tourFeedback, setTourFeedback] = useState<{
text: string;
type: "success" | "error";
} | null>(null);
async function handleReplayTour() {
setReplayingTour(true);
setTourMessage(null);
setTourMessageType(null);
try {
const res = await fetch("/api/onboarding/tour", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: false }),
setTourFeedback(null);
// v1.4.48 M6b — both Settings → Account and Settings → About now
// delegate to the shared `restartOnboardingTour()` worker so the
// server flip + force-launch marker + window event live in one
// place. The helper returns a discriminated result; this surface
// only owns the translation + feedback rendering.
const result = await restartOnboardingTour(user?.id);
if (result.ok) {
setTourFeedback({
text: t("onboarding.tour.restartConfirmation"),
type: "success",
});
if (!res.ok) {
setTourMessage(t("settings.savingError"));
setTourMessageType("error");
return;
}
// v1.4.47 W5 — drop a per-user force-launch marker into
// sessionStorage so the next dashboard mount opens the tour
// even when the 24 h auto-launch gate would otherwise suppress
// it (e.g. a first-day user who clicked "Replay the tour"
// from About 30 min after finishing the wizard). The marker is
// session-scoped — a page reload clears it — and consumed
// exactly once by the launcher on its next mount.
if (user?.id) setTourForceLaunch(user.id);
// Fire the same window event Settings → Account uses; a
// dashboard already mounted in another tab / background reopens
// the spotlight immediately. When the user lands on the
// dashboard next, the launcher reads the freshly-flipped
// `onboardingTourCompleted = false` from `/api/auth/me` and the
// force-launch marker set above; either path lands on the tour.
try {
window.dispatchEvent(new CustomEvent("healthlog:tour-restart"));
} catch {
/* ignore — only matters if a dashboard is already mounted */
}
setTourMessage(t("onboarding.tour.restartConfirmation"));
setTourMessageType("success");
} catch {
setTourMessage(t("common.networkError"));
setTourMessageType("error");
} finally {
setReplayingTour(false);
} else {
setTourFeedback({ text: t(result.messageKey), type: "error" });
}
setReplayingTour(false);
}
// v1.4.36 W4f — the explicit "Check for updates" button is gone;
@@ -423,16 +402,16 @@ export function AboutSection() {
{t("settings.about.tourReplay")}
</Button>
</div>
{tourMessage && (
{tourFeedback && (
<p
role="alert"
className={`mt-2 text-xs ${
tourMessageType === "success"
tourFeedback.type === "success"
? "text-dracula-green"
: "text-destructive"
}`}
>
{tourMessage}
{tourFeedback.text}
</p>
)}
</div>
+29 -44
View File
@@ -39,11 +39,11 @@ import { DateInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { PasswordInput } from "@/components/ui/password-input";
import { setTourForceLaunch } from "@/components/onboarding/tour-launcher";
import { useAuth } from "@/hooks/use-auth";
import { formatDate } from "@/lib/format";
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
import { useTranslations } from "@/lib/i18n/context";
import { restartOnboardingTour } from "@/lib/onboarding/tour-restart";
import { describePasskeyError } from "@/lib/passkey-errors";
import { queryKeys } from "@/lib/query-keys";
import { TimezonePicker } from "@/components/settings/timezone-picker";
@@ -135,12 +135,15 @@ export function AccountSection() {
// dispatches a `healthlog:tour-restart` window event so the dashboard's
// <TourLauncher> picks it up immediately on the user's next nav. The
// confirmation message goes through the same announce channel as
// every other settings save (status: "success" | "error" | null).
// every other settings save.
// v1.4.48 M6c — text + type collapsed into one discriminated state so
// the two values can never drift (same shape as `<AboutSection>`'s
// replay button so the surface stays mirrored).
const [tourRestarting, setTourRestarting] = useState(false);
const [tourMsg, setTourMsg] = useState<string | null>(null);
const [tourMsgType, setTourMsgType] = useState<"success" | "error" | null>(
null,
);
const [tourFeedback, setTourFeedback] = useState<{
text: string;
type: "success" | "error";
} | null>(null);
// Passkey registration state.
const [passkeyLoading, setPasskeyLoading] = useState(false);
@@ -280,43 +283,25 @@ export function AccountSection() {
async function handleRestartTour() {
setTourRestarting(true);
setTourMsg(null);
setTourMsgType(null);
try {
const res = await fetch("/api/onboarding/tour", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: false }),
});
if (!res.ok) {
setTourMsg(t("settings.savingError"));
setTourMsgType("error");
return;
}
// v1.4.47 W5 — drop a per-user force-launch marker in
// sessionStorage so the next dashboard mount launches the tour
// even when the new 24 h auto-launch gate would otherwise
// suppress it (e.g. user just finished the wizard 5 min ago).
// The launcher reads-and-clears the marker on mount.
if (user?.id) setTourForceLaunch(user.id);
// Refetch the auth payload so `<TourLauncher>` re-evaluates
// with the new flag, AND fire a window event so a launcher
// already mounted on the dashboard reopens immediately
// without waiting for a navigation.
setTourFeedback(null);
// v1.4.48 M6b — both Settings → Account and Settings → About now
// delegate to the shared `restartOnboardingTour()` worker so the
// server flip + force-launch marker + window event live in one
// place. Account additionally refetches the auth payload so the
// `onboardingTourCompleted` flag the launcher reads matches the
// server flip immediately — About has no auth handle to refetch
// and relies on the next navigation re-running `/api/auth/me`.
const result = await restartOnboardingTour(user?.id);
if (result.ok) {
await refetch();
try {
window.dispatchEvent(new CustomEvent("healthlog:tour-restart"));
} catch {
/* ignore — only matters if the dashboard is already mounted */
}
setTourMsg(t("onboarding.tour.restartConfirmation"));
setTourMsgType("success");
} catch {
setTourMsg(t("common.networkError"));
setTourMsgType("error");
} finally {
setTourRestarting(false);
setTourFeedback({
text: t("onboarding.tour.restartConfirmation"),
type: "success",
});
} else {
setTourFeedback({ text: t(result.messageKey), type: "error" });
}
setTourRestarting(false);
}
async function handleChangePassword(e: React.FormEvent) {
@@ -631,16 +616,16 @@ export function AccountSection() {
{t("onboarding.tour.restart")}
</Button>
</div>
{tourMsg && (
{tourFeedback && (
<p
role="alert"
className={`mt-2 text-xs ${
tourMsgType === "success"
tourFeedback.type === "success"
? "text-dracula-green"
: "text-destructive"
}`}
>
{tourMsg}
{tourFeedback.text}
</p>
)}
</div>
+35 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -198,6 +198,34 @@ function DisableCoachCard({ isAuthenticated }: { isAuthenticated: boolean }) {
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
// v1.4.48 M2 — auto-clear the inline `<p role="status">` line 3 s
// after the mutation settles so a Settings card the user scrolled
// past minutes earlier doesn't keep echoing a stale "Coach hidden"
// banner. The ref tracks the in-flight timer so we can clear it on
// unmount + on a follow-up toggle (otherwise a rapid double-tap
// could leave a stray timer pointing at a stale message).
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
};
}, []);
function scheduleClear() {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
}
clearTimerRef.current = setTimeout(() => {
clearTimerRef.current = null;
setMsg(null);
setMsgType(null);
}, 3000);
}
const checked = optimistic ?? user?.disableCoach ?? false;
const mutation = useMutation({
@@ -227,6 +255,7 @@ function DisableCoachCard({ isAuthenticated }: { isAuthenticated: boolean }) {
// re-fetches /api/auth/me with the updated `disableCoach` field.
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
setOptimistic(null);
scheduleClear();
},
onError: (err) => {
setOptimistic(null);
@@ -236,6 +265,7 @@ function DisableCoachCard({ isAuthenticated }: { isAuthenticated: boolean }) {
: t("settings.ai.disableCoach.saveError"),
);
setMsgType("error");
scheduleClear();
},
});
@@ -243,6 +273,10 @@ function DisableCoachCard({ isAuthenticated }: { isAuthenticated: boolean }) {
setOptimistic(next);
setMsg(null);
setMsgType(null);
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
mutation.mutate(next);
}
@@ -29,6 +29,7 @@ import {
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
@@ -52,6 +53,10 @@ import {
* shape (`order: 0..n-1`) so the existing PUT contract stays untouched
* and either input mode flushes via the same Save mutation. Exported so
* the unit test can pin the contract without spinning up a DndContext.
*
* v1.4.48 M6a — also drives the arrow-button `move()` handler below;
* the previous in-file swap-and-renumber implementation was a second
* copy of the same logic.
*/
export function reorderWidgets(
widgets: readonly { id: string; order: number }[],
@@ -70,6 +75,43 @@ export function reorderWidgets(
return next.map((w, i) => ({ ...w, order: i }));
}
/**
* v1.4.48 M6a — merge the `{ id, order }` shape returned by
* `reorderWidgets()` back into the full `DashboardWidgetConfig[]` so
* the section's draft state keeps every per-row flag (`visible`,
* `tileVisible`) while the order is rewritten. The arrow buttons and
* the @dnd-kit drag-end handler both flow through this helper so
* neither surface can silently drop a flag.
*/
function mergeReorderIntoLayout(
widgets: DashboardLayout["widgets"],
reordered: readonly { id: string; order: number }[],
): DashboardLayout["widgets"] {
const byId = new Map(widgets.map((w) => [w.id, w]));
return reordered.map((r, i) => {
const original = byId.get(r.id as DashboardWidgetId);
if (!original) {
// v1.4.49 — defence-in-depth dev warning for the orphan branch.
// Today this branch is statically unreachable: every id in
// `reordered` is sourced from `layout.widgets`, so `byId.get`
// always hits. The upcoming per-tile Suspense refactor will
// introduce dynamic widgets where this invariant could break;
// the warning fires in dev only so a regression surfaces in the
// console instead of silently dropping the row via the cast.
if (
typeof window !== "undefined" &&
process.env.NODE_ENV === "development"
) {
console.warn(
`mergeReorderIntoLayout: orphan widget id "${r.id}" dropped`,
);
}
return { ...r, order: i } as never;
}
return { ...original, order: i };
});
}
const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
weight: "dashboard.weight",
bp: "dashboard.bloodPressure",
@@ -199,16 +241,25 @@ export function DashboardLayoutSection({ id }: { id: string }) {
setDraft({ ...layout, comparisonBaseline: value });
}
/**
* v1.4.48 M6a — the arrow buttons now delegate to `reorderWidgets()`
* so the swap-and-renumber logic lives in exactly one place. The
* neighbour id is derived from the sorted layout index + delta;
* out-of-bounds clicks (top row + ArrowUp, bottom row + ArrowDown)
* short-circuit before the helper sees them so the button disabled
* state stays the single source of truth for the boundary.
*/
function move(widgetId: DashboardWidgetId, delta: -1 | 1) {
if (!layout) return;
const sorted = [...layout.widgets].sort((a, b) => a.order - b.order);
const idx = sorted.findIndex((w) => w.id === widgetId);
const targetIdx = idx + delta;
if (idx < 0 || targetIdx < 0 || targetIdx >= sorted.length) return;
[sorted[idx], sorted[targetIdx]] = [sorted[targetIdx], sorted[idx]];
const neighbourId = sorted[targetIdx].id;
const reordered = reorderWidgets(layout.widgets, widgetId, neighbourId);
setDraft({
...layout,
widgets: sorted.map((w, i) => ({ ...w, order: i })),
widgets: mergeReorderIntoLayout(layout.widgets, reordered),
});
}
@@ -229,17 +280,9 @@ export function DashboardLayoutSection({ id }: { id: string }) {
String(active.id),
String(over.id),
);
// `reorderWidgets` operates on the pure { id, order } shape; map back
// to the full DashboardWidgetConfig so `visible` / `tileVisible` are
// preserved on every row.
const byId = new Map(layout.widgets.map((w) => [w.id, w]));
setDraft({
...layout,
widgets: reordered.map((r, i) => {
const original = byId.get(r.id as DashboardWidgetId);
if (!original) return { ...r, order: i } as never;
return { ...original, order: i };
}),
widgets: mergeReorderIntoLayout(layout.widgets, reordered),
});
}
@@ -410,10 +453,14 @@ export function DashboardLayoutSection({ id }: { id: string }) {
{/* v1.4.47 W4 — single shared aria-describedby target for all
drag handles. Screen readers read this once per focused
handle; sighted users see it in the native browser tooltip
via the matching `title` attribute on each handle. */}
<p id={dragHintId} className="text-muted-foreground sr-only">
{t("dashboard.dragHandleHint")}
</p>
via the matching `title` attribute on each handle.
v1.4.48 L8 — gate on widget count so an empty layout never
orphans the paragraph (no handles to describe). */}
{layout.widgets.length > 0 && (
<p id={dragHintId} className="text-muted-foreground sr-only">
{t("dashboard.dragHandleHint")}
</p>
)}
</div>
)}
@@ -509,9 +556,15 @@ function SortableWidgetRow({
isDragging,
} = useSortable({ id: widget.id });
// v1.4.48 L7 — honour the OS `prefers-reduced-motion` preference. The
// rest of HealthLog pairs every transition with a `motion-reduce`
// companion; dnd-kit's default `transform 250ms ease` was the lone
// surface that ignored it. Short-circuit to `none` when reduced
// motion is requested so dragged rows snap to place instead of
// sliding through the list.
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
transition: prefersReducedMotion() ? "none" : transition,
};
const tileChecked =
+38 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { SmilePlus } from "lucide-react";
@@ -25,6 +25,37 @@ export function MoodReminderCard({
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
// v1.4.49 — auto-clear the inline `<p role="status">` line 3 s after
// the mutation settles so a Settings card the user scrolled past
// minutes earlier doesn't keep echoing a stale "reminder enabled"
// banner. Mirrors the `<DisableCoachCard>` pattern landed in v1.4.48
// M2; the v1.4.48 docstring claimed this card already auto-cleared,
// this commit backfills the actual behaviour. The ref tracks the
// in-flight timer so we can clear it on unmount + on a follow-up
// toggle (otherwise a rapid double-tap could leave a stray timer
// pointing at a stale message).
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
};
}, []);
function scheduleClear() {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
}
clearTimerRef.current = setTimeout(() => {
clearTimerRef.current = null;
setMsg(null);
setMsgType(null);
}, 3000);
}
const { data: profile } = useQuery({
queryKey: queryKeys.userProfile(),
queryFn: async () => {
@@ -42,6 +73,10 @@ export function MoodReminderCard({
setSaving(true);
setMsg(null);
setMsgType(null);
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
const res = await fetch("/api/user/profile", {
method: "PATCH",
@@ -58,10 +93,12 @@ export function MoodReminderCard({
setMsgType("success");
await queryClient.invalidateQueries({ queryKey: queryKeys.userProfile() });
setOptimistic(null);
scheduleClear();
} else {
setOptimistic(null);
setMsg(t("notifications.moodReminder.saveError"));
setMsgType("error");
scheduleClear();
}
setSaving(false);
}
@@ -63,20 +63,6 @@ describe("<Textarea>", () => {
expect(html).toContain("sm:text-sm");
});
it("is wrapped in React.forwardRef so refs reach the <textarea>", () => {
// React tags forwardRef components with a sentinel symbol on
// `$$typeof`. Verifying the sentinel keeps the assertion
// dependency-free (no testing-library / DOM env needed) while
// still catching a regression that strips the wrapper.
const forwardRefSymbol = Symbol.for("react.forward_ref");
const componentMeta = Textarea as unknown as {
$$typeof: symbol;
render?: unknown;
};
expect(componentMeta.$$typeof).toBe(forwardRefSymbol);
expect(typeof componentMeta.render).toBe("function");
});
it("forwards rows / placeholder / maxLength to the underlying element", () => {
const html = renderToStaticMarkup(
<Textarea
@@ -0,0 +1,33 @@
"use client";
import { useContext } from "react";
import { QueryClientContext } from "@tanstack/react-query";
/**
* v1.4.48 M4 — shared SSR-safety helper for hooks that delegate to
* React Query under a `<QueryClientProvider>`.
*
* A long tail of legacy presentational components (`<HeroStrip>`,
* `<SuggestedPrompts>`, `<LayoutCoachFab>`, `<InsightStatusCard>`,
* `<HealthScoreDeltaExplainer>`, …) ships with unit tests that render
* the component in isolation without wrapping it in
* `<QueryClientProvider>`. Hooks that call `useQuery` directly crash
* in that environment.
*
* `useQueryClientMounted()` returns `true` when a `QueryClientContext`
* value is present in the React tree, `false` otherwise. The branch is
* stable across the component's lifetime — the provider never
* appears and disappears between renders — so a conditional hook call
* gated on this value remains safe per the Rules of Hooks (the same
* posture the React docs document for "different environments where
* the same component renders").
*/
export function useQueryClientMounted(): boolean {
// The cast strips the generic so callers don't have to learn the
// internal `QueryClient | undefined` shape — they only need to know
// whether the context is mounted.
const ctx = useContext(
QueryClientContext as unknown as React.Context<unknown>,
);
return ctx != null;
}
+9 -11
View File
@@ -1,9 +1,7 @@
"use client";
import { useContext } from "react";
import { QueryClientContext } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe";
/**
* v1.4.47 W3 — SSR-safe accessor for the per-user "Hide Coach" toggle.
@@ -15,16 +13,16 @@ import { useAuth } from "@/hooks/use-auth";
* long-tail SSR snapshot tests (`<SuggestedPrompts>`, `<LayoutCoachFab>`,
* `<HeroStrip>` …) render the component in isolation and don't.
*
* Mirrors the defensive pattern in `useFeatureFlags()`: when no
* `QueryClientContext` is mounted we short-circuit to the "no opt-out"
* default so the legacy tests keep rendering the surface, and the gate
* stays correct for the production app where the provider is always
* mounted.
* v1.4.48 M4 — share the `<QueryClientProvider>`-mount detection with
* `useFeatureFlags()` via the `_internal/use-query-client-safe`
* helper. When no client is mounted we short-circuit to the "no
* opt-out" default; the inner hook is only invoked once the provider
* is known to be in the tree, so the conditional call is safe per the
* Rules of Hooks (the branch is stable across the component's
* lifetime).
*/
export function useDisableCoach(): boolean {
const hasClient = useContext(
QueryClientContext as unknown as React.Context<unknown>,
);
const hasClient = useQueryClientMounted();
if (!hasClient) return false;
// eslint-disable-next-line react-hooks/rules-of-hooks
return useDisableCoachInner();
+7 -4
View File
@@ -1,9 +1,9 @@
"use client";
import { useContext } from "react";
import { QueryClientContext, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe";
/**
* v1.4.31 — Client-side accessor for the operator's assistant
@@ -77,10 +77,13 @@ async function fetchFeatureFlags(): Promise<FeatureFlagsPayload> {
* renders — so the conditional hook call is safe per the Rules of
* Hooks (same posture the React docs document for "different
* environments where the same component renders").
*
* v1.4.48 M4 — the SSR-mount probe is shared with `useDisableCoach`
* via `_internal/use-query-client-safe` so the eslint-disable line
* stays in exactly one place per consumer.
*/
export function useFeatureFlags(): AssistantFlagSet {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hasClient = useContext(QueryClientContext as unknown as React.Context<any>);
const hasClient = useQueryClientMounted();
if (!hasClient) return DEFAULT_ASSISTANT_FLAGS;
// eslint-disable-next-line react-hooks/rules-of-hooks
return useFeatureFlagsQuery();
@@ -123,4 +123,45 @@ describe("returnAllZodIssues", () => {
// from the JSON envelope, matching apiError's contract.
expect(body.meta?.headers).toBeUndefined();
});
// v1.4.49 — opt-in `stripValuesFromMessage` mode for audit-ledger
// writes. The default Zod message for `invalid_enum_value` and a
// handful of other codes embeds the offending value verbatim; an
// audit-row write on a free-text route would persist user content.
// The opt-in keeps the additive multi-issue envelope shape but
// drops `message` entirely so only `path` + `code` survive.
describe("sanitiseZodIssues with stripValuesFromMessage", () => {
it("returns { path, code } only when the option is set", () => {
const enumSchema = z.object({
kind: z.enum(["weight", "pulse"]),
});
const parsed = enumSchema.safeParse({ kind: "garbage-value-xxx" });
if (parsed.success) throw new Error("expected failure");
const stripped = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
expect(stripped.length).toBe(1);
for (const issue of stripped) {
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
// The offending value must not survive on any returned key.
expect(JSON.stringify(issue)).not.toContain("garbage-value-xxx");
}
});
it("default behaviour (no option) keeps message intact", () => {
const enumSchema = z.object({
kind: z.enum(["weight", "pulse"]),
});
const parsed = enumSchema.safeParse({ kind: "garbage" });
if (parsed.success) throw new Error("expected failure");
const kept = sanitiseZodIssues(parsed.error.issues);
expect(kept[0]).toMatchObject({
path: "kind",
code: expect.any(String),
message: expect.any(String),
});
});
});
});
@@ -54,10 +54,15 @@ afterEach(() => {
describe("computeSummariesSlice", () => {
describe("cold fallback — empty rollup table", () => {
it("returns the empty-summary skeleton when the user has no rows", async () => {
// v1.4.48 M0 — cold path now issues two aggregate queries
// (`allTime` + `windowed`) instead of one fat aggregate, so the
// $queryRaw call count is 4 instead of 3:
// 1. per-type coverage probe — empty ⇒ cold path.
// 2. heavy aggregate ($queryRaw) — empty.
// 3. latests ($queryRaw) — empty.
// 2. all-time aggregate ($queryRaw) — empty.
// 3. windowed aggregate ($queryRaw, 90-day cap) — empty.
// 4. latests ($queryRaw) — empty.
RAW.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
@@ -79,11 +84,16 @@ describe("computeSummariesSlice", () => {
avg30LastYear: null,
});
expect(result.bmi).toBeNull();
expect(RAW).toHaveBeenCalledTimes(3);
expect(RAW).toHaveBeenCalledTimes(4);
});
it("maps a populated heavy aggregate row into the DataSummary shape on cold path", async () => {
// Coverage probe returns WEIGHT with no buckets → cold path.
// v1.4.48 M0 — cold path now splits the heavy aggregate into
// all-time + 90-day-capped windowed queries. Mock order:
// 1. coverage probe (WEIGHT uncovered → cold path)
// 2. all-time aggregate (count / min / max / mean)
// 3. windowed aggregate (avg7/30 + slope/r²)
// 4. latests
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: false }])
.mockResolvedValueOnce([
{
@@ -92,6 +102,11 @@ describe("computeSummariesSlice", () => {
min_value: 79.2,
max_value: 84.1,
mean_value: 82.05,
},
])
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 81.9,
avg30: 82.1,
slope7: -0.014,
@@ -145,6 +160,11 @@ describe("computeSummariesSlice", () => {
min_value: 72,
max_value: 72,
mean_value: 72,
},
])
.mockResolvedValueOnce([
{
type: "PULSE",
avg7: 72,
avg30: 72,
slope7: null,
@@ -175,6 +195,11 @@ describe("computeSummariesSlice", () => {
min_value: 80,
max_value: 84,
mean_value: 82,
},
])
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: null,
avg30: 82,
slope7: null,
@@ -207,6 +232,11 @@ describe("computeSummariesSlice", () => {
min_value: 60,
max_value: 95,
mean_value: 77,
},
])
.mockResolvedValueOnce([
{
type: "PULSE",
avg7: 77,
avg30: 77,
slope7: 0,
@@ -436,8 +466,11 @@ describe("computeSummariesSlice", () => {
});
it("only probes types that actually have data in the current window", async () => {
// Coverage probe + rollup GROUP BY both report zero rows → no
// types-with-data, so the year-ago probe must NOT fan out.
// v1.4.48 M0 — empty coverage map ⇒ `isFullyCovered` returns
// false ⇒ cold-fallback path. Cold path now issues 4 raw
// queries (coverage probe + all-time + windowed + latests),
// all returning empty arrays here ⇒ no types-with-data ⇒ the
// year-ago probe must NOT fan out.
RAW.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
@@ -448,5 +481,48 @@ describe("computeSummariesSlice", () => {
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
});
});
/**
* v1.4.48 M0 — pins the 90-day outer `measured_at` cap on the two
* windowed measurements scans: the `narrows` query inside the
* rollup-fresh happy path and the `windowed` query inside the
* cold-fallback path. Both must constrain the outer WHERE to the
* 90-day suffix so the planner does an index range scan on
* `(user_id, type, measured_at)` instead of a full-partition scan.
* If a future refactor drops the cap, this assertion fails before
* the perf regression reaches main.
*/
describe("90-day outer measured_at cap (v1.4.48 M0)", () => {
it("applies the 90-day cap to narrows (rollup-fresh path) and windowed (cold-fallback path)", async () => {
const queries: string[] = [];
RAW.mockImplementation((strings: TemplateStringsArray) => {
queries.push(strings.join("?"));
return Promise.resolve([]);
});
// Trigger the rollup-fresh path so we capture the `narrows` SQL.
// Coverage probe returns one covered type ⇒ `isFullyCovered`
// is true ⇒ `computeFromRollups` runs.
RAW.mockImplementationOnce((strings: TemplateStringsArray) => {
queries.push(strings.join("?"));
return Promise.resolve([{ type: "WEIGHT", has_buckets: true }]);
});
await computeSummariesSlice("user-rollup-pin");
// Trigger the cold-fallback path so we capture the `windowed`
// SQL. Empty coverage map ⇒ `isFullyCovered` is false ⇒
// `computeFromLiveAggregate` runs.
await computeSummariesSlice("user-cold-pin");
const joined = queries.join("\n---\n");
// Both windowed scans must carry the outer 90-day cap.
const capMatches = joined.match(
/AND m\."measured_at" >= NOW\(\) - INTERVAL '90 days'/g,
);
expect(capMatches).not.toBeNull();
// narrows (rollup-fresh) + windowed (cold-fallback) = 2 caps.
expect(capMatches?.length).toBeGreaterThanOrEqual(2);
});
});
});
+67 -34
View File
@@ -71,24 +71,17 @@ import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Heavy aggregate row — used on the cold-mount fallback path where the
* rollup table is empty and we need every per-type column out of one
* SQL pass for the very first request.
* v1.4.48 — all-time aggregate row used on the cold-mount fallback
* path. Holds the linearly composable columns (`count / min / max /
* mean`) that must reflect every row the user has ever logged for
* that type, so this query intentionally has no `measured_at` cap.
*/
interface HeavyAggregateRow {
interface AllTimeAggregateRow {
type: string;
count: bigint;
min_value: number | null;
max_value: number | null;
mean_value: number | null;
avg7: number | null;
avg30: number | null;
slope7: number | null;
r2_7: number | null;
slope30: number | null;
r2_30: number | null;
slope90: number | null;
r2_90: number | null;
}
/**
@@ -100,8 +93,9 @@ interface HeavyAggregateRow {
/**
* v1.4.37.2 — shape returned by the per-type GROUP BY over
* `measurement_rollups`. Replaces the v1.4.35 row-per-bucket transfer
* that materialised ~306k rows on a power-user account; the SQL now
* does the aggregation server-side and hands back one row per type.
* that materialised a six-figure row count on tenants with large
* measurement partitions; the SQL now does the aggregation
* server-side and hands back one row per type.
*/
interface RollupAggregateRow {
type: string;
@@ -201,8 +195,8 @@ export async function computeSummariesSlice(
): Promise<SummariesSlice> {
// v1.4.37.1 hotfix — fire-and-forget. See `src/app/api/analytics/route.ts`
// for the full rationale: awaiting this on the read path can stall
// the event loop for 30–60 s on power-user accounts whose iOS step
// samples keep the 90-day window slightly stale. The downstream
// the event loop for tens of seconds on tenants with large iOS step
// sample sets that keep the 90-day window slightly stale. The downstream
// coverage probe falls back to live SQL for uncovered types, so
// correctness is preserved; the read just doesn't block waiting
// for the background refresh.
@@ -257,9 +251,9 @@ export async function computeSummariesSlice(
* `(user_id, type, measured_at)` instead of reading every row in the
* user's measurements partition. Every FILTER expression inside the
* SELECT already restricts to 7/30/90 days, so the cap excludes only
* rows that were already being aggregated to NULL. On a power-user
* account with ~450k all-time rows this lifts the slim slice from
* ~9 s cold to ~0.5-1 s; output is bit-identical.
* rows that were already being aggregated to NULL. On tenants with
* large measurement partitions this lifts the slim slice from
* multi-second cold to sub-second; output is bit-identical.
*/
async function computeFromRollups(userId: string): Promise<SummariesSlice> {
const [narrows, latests, dayBuckets] = await Promise.all([
@@ -327,11 +321,12 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
// v1.4.37.2 hotfix — the v1.4.35 implementation read EVERY DAY
// rollup bucket for the user (`findMany` without a `bucketStart`
// window) and then composed `count / min / max / mean` in JS.
// On a power-user account that materialised as a 306k-row
// transfer + a JS loop = ~3.85 s per cache miss, even with the
// rollup table hot. The slim slice's contract is the all-time
// count / min / max / mean per type — exactly what a SQL
// `GROUP BY type` returns in a single round-trip. Returns 8 rows
// On tenants with large measurement partitions that materialised
// as a six-figure row transfer + a JS loop = multi-second per
// cache miss, even with the rollup table hot. The slim slice's
// contract is the all-time count / min / max / mean per type —
// exactly what a SQL `GROUP BY type` returns in a single
// round-trip. Returns 8 rows
// instead of 306k, brings the cache-miss cost into the < 100 ms
// budget. The downstream `aggregateBuckets` call is bypassed
// because the per-type aggregate is already shaped server-side.
@@ -461,18 +456,44 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
* Cold fallback — runs the legacy heavy aggregate when the rollup
* table is empty for this user. Subsequent reads pick up the populated
* rollup on `computeFromRollups`.
*
* v1.4.48 — split into two parallel queries:
*
* 1. `allTime` — the linearly composable columns (`count / min /
* max / mean`) keep the full-partition scan because they must
* reflect every row the user has ever logged. No `measured_at`
* cap.
*
* 2. `windowed` — `avg7 / avg30` and the slope/r² tuples are all
* already filtered to a 7/30/90 day window inside the SELECT.
* Adding an outer `measured_at >= NOW() - INTERVAL '90 days'`
* cap lets the planner do an index range scan on
* `(user_id, type, measured_at)` instead of a full-partition
* sequential scan and discarding 95 % of rows inside FILTER
* clauses. On tenants with large measurement partitions the
* cold fallback drops from multi-second to sub-second. Output is
* bit-identical because every row excluded by the new outer cap
* was already aggregating to NULL inside its FILTER clause.
*/
async function computeFromLiveAggregate(
userId: string,
): Promise<SummariesSlice> {
const [aggregates, latests] = await Promise.all([
prisma.$queryRaw<HeavyAggregateRow[]>`
const [allTime, windowed, latests] = await Promise.all([
prisma.$queryRaw<AllTimeAggregateRow[]>`
SELECT
m."type"::text AS type,
COUNT(*) AS count,
MIN(m."value")::double precision AS min_value,
MAX(m."value")::double precision AS max_value,
AVG(m."value")::double precision AS mean_value,
AVG(m."value")::double precision AS mean_value
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
GROUP BY m."type"
`,
prisma.$queryRaw<NarrowAggregateRow[]>`
SELECT
m."type"::text AS type,
AVG(m."value") FILTER (
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
)::double precision AS avg7,
@@ -518,6 +539,7 @@ async function computeFromLiveAggregate(
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
AND m."measured_at" >= NOW() - INTERVAL '90 days'
GROUP BY m."type"
`,
prisma.$queryRaw<LatestRow[]>`
@@ -562,24 +584,35 @@ async function computeFromLiveAggregate(
};
}
// v1.4.48 — windowed columns are keyed by type alongside the
// all-time aggregate. Types that exist in `allTime` but have no
// measurements in the 90-day window leave their windowed columns at
// `null` (the same shape the pre-split query produced via FILTER
// returning NULL on an empty set).
const windowedByType = new Map<string, NarrowAggregateRow>();
for (const row of windowed) {
windowedByType.set(row.type, row);
}
let totalRows = 0;
const typesWithData: string[] = [];
for (const row of aggregates) {
for (const row of allTime) {
const count = Number(row.count);
totalRows += count;
typesWithData.push(row.type);
const latest = latestByType.get(row.type) ?? null;
const win = windowedByType.get(row.type);
summaries[row.type] = {
count,
latest,
min: round2(row.min_value),
max: round2(row.max_value),
mean: round2(row.mean_value),
avg7: round2(row.avg7),
avg30: round2(row.avg30),
slope7: buildSlope(row.slope7, row.r2_7),
slope30: buildSlope(row.slope30, row.r2_30),
slope90: buildSlope(row.slope90, row.r2_90),
avg7: round2(win?.avg7 ?? null),
avg30: round2(win?.avg30 ?? null),
slope7: buildSlope(win?.slope7 ?? null, win?.r2_7 ?? null),
slope30: buildSlope(win?.slope30 ?? null, win?.r2_30 ?? null),
slope90: buildSlope(win?.slope90 ?? null, win?.r2_90 ?? null),
anomalyCount: 0,
avg30LastMonth: null,
avg30LastYear: null,
@@ -609,7 +642,7 @@ async function computeFromLiveAggregate(
analytics: {
slim_summaries: {
row_count: totalRows,
type_count: aggregates.length,
type_count: allTime.length,
path: "live",
year_over_year_types: yearOverYearTypeCount,
},
+84 -1
View File
@@ -18,6 +18,25 @@ export interface SanitisedZodIssue {
message: string;
}
/**
* Variant used when the issues land in an audit-ledger row whose
* source field is free-text. `invalid_enum_value` and a handful of
* other Zod codes embed the offending value verbatim in
* `issue.message`, so a route that JSON-stringifies the sanitised
* array into `details` can leak user content. Callers opt in via
* `sanitiseZodIssues(error.issues, { stripValuesFromMessage: true })`.
*/
export interface SanitiseZodIssueOptions {
/**
* When true, drop `issue.message` from the returned shape entirely.
* Only `path` + `code` survive — enough for an operator to triage
* the rejection without persisting user-typed content. Default is
* `false` so the additive multi-issue envelope keeps its existing
* client contract.
*/
stripValuesFromMessage?: boolean;
}
/**
* v1.4.42 W2 — multi-issue Zod error envelope.
*
@@ -33,10 +52,32 @@ export interface SanitisedZodIssue {
* Privacy: only `path`, `code` and `message` are echoed. `issue.params`
* (which can carry the raw rejected value for some Zod issue codes)
* stays server-side.
*
* v1.4.49 — `stripValuesFromMessage` removes `message` for the
* auditLog-emission sites whose input fields are free-text. Some Zod
* codes (e.g. `invalid_enum_value`) embed the offending value in the
* default message string; a free-text route writing the message into
* the audit ledger would leak user content. The opt-in is additive —
* the default behaviour is unchanged so existing clients reading
* `details.issues[*].message` over the wire keep working.
*/
export function sanitiseZodIssues(
issues: readonly ZodIssue[],
): SanitisedZodIssue[] {
): SanitisedZodIssue[];
export function sanitiseZodIssues(
issues: readonly ZodIssue[],
options: { stripValuesFromMessage: true },
): Array<Omit<SanitisedZodIssue, "message">>;
export function sanitiseZodIssues(
issues: readonly ZodIssue[],
options?: SanitiseZodIssueOptions,
): SanitisedZodIssue[] | Array<Omit<SanitisedZodIssue, "message">> {
if (options?.stripValuesFromMessage) {
return issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
}));
}
return issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
@@ -44,6 +85,48 @@ export function sanitiseZodIssues(
}));
}
/**
* v1.4.49 — diagnostic shape echoed into a wide-event meta when an iOS
* (or any other) caller fails Zod validation on a JSON payload. Pairs
* with the per-route `annotate({ action, meta: { ...payloadDiagnostic,
* zod_issues, issue_count } })` call. Each field is bounded:
*
* - `received_keys`: top-level keys only; never values
* - `received_shape_excerpt`: hard 256-char `JSON.stringify` slice
*
* Defensive shape: caller passes the raw parsed body (object | array |
* primitive | undefined). Non-object input collapses to an empty key
* list and an empty excerpt — the helper never throws.
*
* A redaction layer (PII / token-shape removal) is expected to be
* applied as a follow-up composable step by a parallel
* `W-OBSERV-PII-V1449` audit; this helper builds the unredacted shape
* so the redactor can post-process via a second helper call. Keeping
* the two concerns separate lets the unit test for this helper stay
* stable while the redaction policy evolves.
*/
export interface PayloadDiagnostic {
received_keys: string[];
received_shape_excerpt: string;
}
export function buildPayloadDiagnostic(body: unknown): PayloadDiagnostic {
const received_keys =
body && typeof body === "object" && !Array.isArray(body)
? Object.keys(body as Record<string, unknown>)
: [];
let excerpt: string;
try {
excerpt = JSON.stringify(body) ?? "";
} catch {
excerpt = "";
}
return {
received_keys,
received_shape_excerpt: excerpt.slice(0, 256),
};
}
type ErrorMeta = {
errorCode?: string;
headers?: Record<string, string>;
@@ -1,3 +1,6 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -16,6 +19,9 @@ import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
import { SuggestedPrompts } from "@/components/insights/suggested-prompts";
import { HeroStrip } from "@/components/insights/hero-strip";
import { TargetCard, type TargetCardData } from "@/components/targets/target-card";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
/**
* v1.4.47 W3 — per-user "Hide Coach" opt-out invariant.
@@ -171,8 +177,61 @@ const DISABLE_COACH_SURFACES: DisableCoachSurface[] = [
),
proofWhenVisible: 'data-slot="insights-hero-strip-action-coach"',
},
{
// v1.4.48 L2 — `<TargetCard>` carries the per-card "Ask the coach"
// CTA on the targets page. The card itself only consults
// `flags.coach`; the per-user disable gate is computed by the parent
// (`src/app/targets/page.tsx`) which does
// `aiEnabled = flags.coach && !user?.disableCoach && providerActive`.
// The driver below mirrors that wiring so the negative assertion
// (CTA absent when `disableCoach` is true) pins the chain end-to-end
// and trips a regression in either the card-level gate or the
// page-level computation.
name: "TargetCard per-card 'Ask the coach' CTA",
mount: () => <TargetCardDriver />,
proofWhenVisible: 'data-slot="target-coach-cta"',
},
];
/**
* Inline driver that mirrors the `src/app/targets/page.tsx` wiring of
* `aiEnabled` to the per-card CTA. Reads the same hooks the page does,
* threads the resolved boolean into `<TargetCard>`. Keeps the user-
* disable fixture honest about the cascade — a future contributor who
* "simplifies" the page's `coachEnabled` computation and drops the
* `user?.disableCoach` branch fails this surface's negative assertion.
*/
const TARGET_CARD_FIXTURE: TargetCardData = {
type: "WEIGHT",
label: "Weight",
current: 78.2,
average30: 79.1,
trend: "down",
unit: "kg",
range: { min: 60, max: 80 },
classification: { category: "Normal", color: "#50fa7b" },
source: "WHO BMI",
daysInRange7d: 5,
daysLogged7d: 7,
lastMetGoalAt: "2026-05-10",
streakDays: 4,
insufficientData: false,
consistency7d: ["in", "in", "near", "in", "out", "in", "in"],
};
function TargetCardDriver() {
const flags = useFeatureFlags();
const disableCoach = useDisableCoach();
const aiEnabled = flags.coach && !disableCoach;
return (
<TargetCard
target={TARGET_CARD_FIXTURE}
aiEnabled={aiEnabled}
onAskCoach={() => undefined}
/>
);
}
describe("Coach per-user disableCoach invariant", () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
@@ -236,4 +295,148 @@ describe("Coach per-user disableCoach invariant", () => {
).toHaveBeenCalled();
}
});
// ──────────────────────────────────────────────────────────────────
// v1.4.48 H2 — grep-based discovery of every `useDisableCoach` /
// `user.disableCoach` call site in the codebase.
//
// Mirrors the `flags.coach` discovery walk in `coach-cascade.test.tsx`:
// the surface fixture above pins the surfaces this file mounts
// directly. Cross-cut gates on sub-pages (`/targets` page parent
// computation, Settings → Insights toggle, `/api/auth/me` response
// shape, the disable-coach PATCH route) sit OUTSIDE the fixture and
// are owned by sibling invariants or by route-level tests.
//
// The walk below scans `src/` for every `useDisableCoach` /
// `user.disableCoach` / `user?.disableCoach` occurrence and requires
// each path to appear in the explicit `KNOWN_DISABLE_COACH_GATE_SITES`
// allowlist. Add a new entry every time you intentionally read the
// per-user disable flag in a new path; the failure message names the
// unaccounted-for paths so the fix is one search-and-add.
// ──────────────────────────────────────────────────────────────────
/**
* Every file in `src/` that legitimately reads `useDisableCoach` or
* `user.disableCoach`. Lives next to the disable-cascade fixture so a
* contributor extending the fixture sees the allowlist in the same
* review.
*
* Paths are POSIX-style relative to the repo root.
*/
const KNOWN_DISABLE_COACH_GATE_SITES: ReadonlyArray<string> = [
// Coach-bearing surfaces mounted directly by the disable-cascade
// fixture above; each one calls `useDisableCoach()` and short-
// circuits to `null` when the per-user flag is on.
"src/components/insights/coach-launch-button.tsx",
"src/components/insights/hero-strip.tsx",
"src/components/insights/layout-coach-fab.tsx",
"src/components/insights/layout-coach-mount.tsx",
"src/components/insights/suggested-prompts.tsx",
// The hook itself + its `useAuth`-backed reader.
"src/hooks/use-disable-coach.ts",
// Cross-cut gates owned by sibling invariants / route tests.
// - `/api/auth/me` ships the flag in the session payload so the
// client hooks can read it (`useAuth → useDisableCoach`). The
// sibling `/api/auth/me/disable-coach` PATCH endpoint that
// writes the flag does NOT read `user.disableCoach` (it reads
// `body.disableCoach`) and is intentionally not listed here.
"src/app/api/auth/me/route.ts",
// - The targets page's parent-level cascade: computes
// `coachEnabled = flags.coach && !user?.disableCoach` and threads
// `aiEnabled` down to `<TargetCard>`. The disable-cascade fixture
// covers the end-to-end chain via `<TargetCardDriver>`.
"src/app/targets/page.tsx",
// - Settings → Insights toggle reads + writes the flag.
"src/components/settings/ai-section.tsx",
];
/**
* Walk every TS / TSX file under `src/`, excluding test trees + the
* Prisma client output, and collect the relative paths that
* legitimately reference `useDisableCoach` or `user.disableCoach`.
* Any literal occurrence on a non-comment line counts so a
* contributor who paraphrases the read still trips this test.
*/
function findDisableCoachSites(): string[] {
const srcRoot = resolve(__dirname, "..", "..", "..");
const repoRoot = resolve(srcRoot, "..");
const hits: string[] = [];
function isRealGateLine(line: string): boolean {
// Trim once so leading whitespace doesn't fool the comment check.
const trimmed = line.trim();
if (trimmed.startsWith("//")) return false;
if (trimmed.startsWith("*")) return false;
// `useDisableCoach` covers both the hook import + the hook call.
if (line.includes("useDisableCoach")) return true;
// `user.disableCoach` / `user?.disableCoach` — the canonical
// per-user gate read on the auth user object. Strict match on
// `user` so we don't grab unrelated `disableCoach` strings (route
// body keys, i18n message ids, …).
if (/\buser\??\.disableCoach\b/.test(line)) return true;
return false;
}
function walk(dir: string): void {
const entries = readdirSync(dir);
for (const entry of entries) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
if (entry === "node_modules" || entry === ".next") continue;
if (entry === "__tests__") continue;
// Skip the Prisma client output — the generator embeds the
// entire `prisma/schema.prisma` source as a JSON-escaped
// `inlineSchema` string and any schema doc-comment that
// mentions `disableCoach` would otherwise leak through here.
if (entry === "generated") continue;
walk(full);
continue;
}
if (!/\.(tsx|ts)$/.test(entry)) continue;
if (/\.test\.(tsx|ts)$/.test(entry)) continue;
const text = readFileSync(full, "utf8");
if (text.split("\n").some(isRealGateLine)) {
hits.push(relative(repoRoot, full).split(/[\\/]/).join("/"));
}
}
}
walk(srcRoot);
return hits.sort();
}
it("grep-based discovery pins every `useDisableCoach` / `user.disableCoach` call site", () => {
const discovered = findDisableCoachSites();
const allowlist = new Set(KNOWN_DISABLE_COACH_GATE_SITES);
const orphans = discovered.filter((path) => !allowlist.has(path));
expect(
orphans,
[
"Found `useDisableCoach` / `user.disableCoach` in file(s) not on",
"the KNOWN_DISABLE_COACH_GATE_SITES allowlist",
"(src/lib/feature-flags/__tests__/coach-user-disable.test.tsx).",
"Add a fixture entry (or a sibling invariant) for the new gate,",
"then add the path to the allowlist. Orphans:",
...orphans.map((p) => ` - ${p}`),
].join("\n"),
).toEqual([]);
// Sanity check the other direction — an allowlist entry that no
// longer references the gate is dead documentation and should be
// pruned in the same PR that removes the read.
const discoveredSet = new Set(discovered);
const stale = KNOWN_DISABLE_COACH_GATE_SITES.filter(
(path) => !discoveredSet.has(path),
);
expect(
stale,
[
"KNOWN_DISABLE_COACH_GATE_SITES references file(s) that no longer",
"use `useDisableCoach` / `user.disableCoach`. Remove the stale",
"allowlist entry:",
...stale.map((p) => ` - ${p}`),
].join("\n"),
).toEqual([]);
});
});
@@ -219,7 +219,7 @@ describe("parkIntegrationAtReauth — silent scope-skip park (v1.4.27 F20)", ()
});
// The whole point of the helper: counter is not in the update set.
// v1.4.47 W1 — the legacy `consecutiveFailures` column was dropped
// (migration 0076); the per-kind bucket is the live counter now.
// (migration 0077); the per-kind bucket is the live counter now.
// Neither field appears in the update payload — the existing
// bucket values are preserved exactly.
expect(upsertArgs.update).not.toHaveProperty("consecutiveFailures");
@@ -279,7 +279,7 @@ describe("parkIntegrationAtReauth — silent scope-skip park (v1.4.27 F20)", ()
// genuine transient burst still has the full 3-strike runway.
// v1.4.47 W1 — counter is now the per-kind bucket payload (the
// legacy `consecutiveFailures` column was dropped in migration
// 0076); a zero envelope is the equivalent seed.
// 0077); a zero envelope is the equivalent seed.
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce(null);
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
{} as never,
@@ -98,7 +98,7 @@ describe("recordSyncSuccess", () => {
// v1.4.43 W14 — success clears every per-kind bucket so the
// next failure starts from zero in its own bucket.
// v1.4.47 W1 — the legacy `consecutiveFailures` column was
// dropped (migration 0076); the bucket reset is the only
// dropped (migration 0077); the bucket reset is the only
// counter write now.
consecutiveFailuresByKind: {
transient: 0,
@@ -482,7 +482,7 @@ describe("markReauthRequired / markDisconnected / markReconnected", () => {
lastError: null,
// v1.4.43 W14 — disconnect must clear the per-kind buckets too.
// v1.4.47 W1 — legacy `consecutiveFailures` column dropped
// (migration 0076); the bucket reset is the only counter write.
// (migration 0077); the bucket reset is the only counter write.
consecutiveFailuresByKind: {
transient: 0,
reauth_required: 0,
@@ -505,7 +505,7 @@ describe("markReauthRequired / markDisconnected / markReconnected", () => {
// v1.4.43 W14 — reconnect clears the per-kind buckets so the
// next sync starts on a clean slate.
// v1.4.47 W1 — legacy `consecutiveFailures` column dropped
// (migration 0076); the bucket reset is the only counter write.
// (migration 0077); the bucket reset is the only counter write.
consecutiveFailuresByKind: {
transient: 0,
reauth_required: 0,
@@ -687,7 +687,7 @@ describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => {
it("seeds a fresh zero envelope when the row has no bucket payload yet", async () => {
// v1.4.47 W1 — the legacy `consecutiveFailures` column was dropped
// (migration 0076). A row that has never written a bucket payload
// (migration 0077). A row that has never written a bucket payload
// (`consecutiveFailuresByKind: null`) starts from a zero envelope
// on the next failure; the matching bucket ticks to 1 in a single
// write.
@@ -725,9 +725,9 @@ describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => {
});
});
// ── v1.4.47 W1 — migration 0076 down-script reversibility ──────────────
// ── v1.4.47 W1 — migration 0077 down-script reversibility ──────────────
//
// Migration 0076 drops `consecutive_failures` and documents a down-script
// Migration 0077 drops `consecutive_failures` and documents a down-script
// that restores the legacy column via
//
// SET "consecutive_failures" = COALESCE(
@@ -818,7 +818,7 @@ describe("resumeIntegrationFromPark", () => {
state: "connected",
lastError: null,
// v1.4.47 W1 — legacy `consecutiveFailures` column dropped
// (migration 0076); the bucket reset is the only counter write.
// (migration 0077); the bucket reset is the only counter write.
consecutiveFailuresByKind: {
transient: 0,
reauth_required: 0,
+4 -4
View File
@@ -250,7 +250,7 @@ export async function recordSyncSuccess(
// v1.4.43 W14 — a success resets ALL per-kind buckets back to zero
// and clears the persistent-streak start timestamp.
// v1.4.47 W1 — the legacy `consecutiveFailures` column was dropped
// (migration 0076), so the bucket reset is the only counter write.
// (migration 0077), so the bucket reset is the only counter write.
await prisma.integrationStatus.upsert({
where: { userId_integration: { userId, integration } },
create: {
@@ -304,7 +304,7 @@ export interface RecordSyncFailureInput {
* ONLY its own bucket; a transient hiccup followed by a persistent
* failure no longer masks the persistent streak's true age.
* v1.4.47 W1 — the legacy single-column `consecutiveFailures` integer
* was dropped (migration 0076); the bucket is now the sole counter
* was dropped (migration 0077); the bucket is now the sole counter
* and the back-fill branch is gone.
*/
export async function recordSyncFailure(
@@ -448,7 +448,7 @@ export async function recordSyncFailure(
// a row with a 3-deep persistent streak still pages even when the
// transient bucket sat at 0.
// v1.4.47 W1 — the legacy `consecutiveFailures` column was dropped
// (migration 0076); the bucket max is now the sole alert signal.
// (migration 0077); the bucket max is now the sole alert signal.
const threshold = getPersistentFailureThreshold();
const alertSignal = Math.max(...Object.values(buckets));
if (alertSignal >= threshold) {
@@ -635,7 +635,7 @@ export async function markReauthRequired(
message: string,
): Promise<void> {
// v1.4.47 W1 — the legacy `consecutiveFailures` column was dropped
// (migration 0076). Out-of-band reauth detection (proactive token
// (migration 0077). Out-of-band reauth detection (proactive token
// refresh) seeds the `reauth_required` bucket at 1 on a first-ever
// row so subsequent reauth detections accumulate against the same
// bucket the alert ladder reads.
@@ -0,0 +1,75 @@
/**
* Unit coverage for the v1.4.48 M1 worker-boot probe that flags
* legacy `integration_statuses` rows whose JSON failure bucket is
* still NULL.
*
* The contract under test:
* - emits a single `prisma.integrationStatus.count` against the
* `consecutiveFailuresByKind: { equals: null }` predicate;
* - when the count is > 0, the wrapping Wide Event is elevated to
* `warn` and the count is annotated under
* `meta.integration_status_null_buckets`;
* - when the count is 0, the event stays at `info` and no warning
* is added.
*/
import { describe, it, expect, vi } from "vitest";
import type { PrismaClient } from "@/generated/prisma/client";
import { WideEventBuilder } from "@/lib/logging/event-builder";
import { eventStorage } from "@/lib/logging/context";
import { probeIntegrationStatusNullBuckets } from "../integration-status-null-probe";
function makePrismaMock(count: number) {
return {
$queryRaw: vi.fn().mockResolvedValue([{ count: BigInt(count) }]),
} as unknown as PrismaClient;
}
async function runWithEvent<T>(
fn: (evt: WideEventBuilder) => Promise<T>,
): Promise<{ evt: WideEventBuilder; value: T }> {
const evt = new WideEventBuilder("background");
const value = await eventStorage.run(evt, () => fn(evt));
return { evt, value };
}
describe("probeIntegrationStatusNullBuckets", () => {
it("queries with the right predicate and returns the count", async () => {
const prisma = makePrismaMock(3);
const { value } = await runWithEvent(() =>
probeIntegrationStatusNullBuckets(prisma),
);
expect(value).toEqual({ count: 3 });
const queryRaw = prisma.$queryRaw as ReturnType<typeof vi.fn>;
expect(queryRaw).toHaveBeenCalledTimes(1);
const sql = (queryRaw.mock.calls[0][0] as TemplateStringsArray).join("");
expect(sql).toMatch(/consecutive_failures_by_kind\s+IS\s+NULL/i);
expect(sql).toMatch(/integration_statuses/);
});
it("elevates the event to warn and annotates the count when > 0", async () => {
const prisma = makePrismaMock(2);
const { evt } = await runWithEvent(() =>
probeIntegrationStatusNullBuckets(prisma),
);
evt.finish();
const json = evt.toJSON();
expect(json.level).toBe("warn");
expect(json.warnings?.[0]).toMatch(/2 row\(s\) still carry consecutiveFailuresByKind=NULL/);
expect(json.meta?.integration_status_null_buckets).toBe(2);
});
it("stays at info and emits no warning when count is 0", async () => {
const prisma = makePrismaMock(0);
const { evt } = await runWithEvent(() =>
probeIntegrationStatusNullBuckets(prisma),
);
evt.finish();
const json = evt.toJSON();
expect(json.level).toBe("info");
expect(json.warnings).toBeUndefined();
expect(json.meta?.integration_status_null_buckets).toBeUndefined();
});
});
@@ -0,0 +1,49 @@
/**
* v1.4.48 M1 — boot-time probe for legacy `IntegrationStatus` rows that
* still carry `consecutiveFailuresByKind: NULL`.
*
* v1.4.47 W1 dropped the single-column `consecutiveFailures` integer in
* favour of the per-kind JSON bucket. The alert-ladder reads
* `Math.max(...Object.values(buckets))` against the threshold — but a
* row that survives with the JSON column still NULL deterministically
* counts as `max([1]) = 1` on its first failure (the fall-back signal
* the writer emits when the row is absent), so the operator notices
* two strikes later than they did pre-v1.4.47 where the legacy column
* back-stopped the maths.
*
* Operator judgement ("every active row has been written under
* v1.4.43+, so the column is non-null") is not a code invariant. This
* probe is the runtime version of that invariant: one count query at
* worker boot, a single Wide-Event warning if any such row survives.
* Fire-and-forget — a probe failure must never block boot. Idempotent
* across reboots.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { annotate, getEvent } from "@/lib/logging/context";
interface IntegrationStatusNullProbeResult {
count: number;
}
type PrismaCountClient = Pick<PrismaClient, "$queryRaw">;
export async function probeIntegrationStatusNullBuckets(
prisma: PrismaCountClient,
): Promise<IntegrationStatusNullProbeResult> {
// Prisma's typed `count` rejects a literal `null` against a `Json?`
// column without going through `Prisma.JsonNullValueFilter`. The raw
// `IS NULL` is the simpler shape and matches the backlog spec.
const rows = await prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(*)::bigint AS count
FROM integration_statuses
WHERE consecutive_failures_by_kind IS NULL
`;
const count = Number(rows[0]?.count ?? 0);
if (count > 0) {
getEvent()?.addWarning(
`[integration-status-null-probe] ${count} row(s) still carry consecutiveFailuresByKind=NULL; alert ladder will trip two strikes late until they are rewritten`,
);
annotate({ meta: { integration_status_null_buckets: count } });
}
return { count };
}
+121
View File
@@ -98,6 +98,7 @@ import {
} from "@/lib/measurements/drain-per-sample-cumulative";
import { expireStaleInUseItems } from "@/lib/medications/inventory/service";
import { rotateLegacyMoodLogSecrets } from "@/lib/moodlog-secret";
import { probeIntegrationStatusNullBuckets } from "@/lib/jobs/integration-status-null-probe";
import { deleteMessage } from "@/lib/telegram";
import { decrypt, encrypt } from "@/lib/crypto";
import { syncMoodLogEntries } from "@/lib/moodlog/sync";
@@ -109,6 +110,7 @@ import {
getPhaseKeyboard,
} from "@/lib/jobs/reminder-phases";
import { runMoodReminderTick } from "@/lib/jobs/mood-reminder";
import { isMedicationReminderClientManaged } from "@/lib/validations/notification-prefs";
import { withBackgroundEvent } from "@/lib/logging/background";
import { assertSubsystemEnabled } from "@/lib/process-type";
import { runOffhostBackup } from "@/lib/jobs/offhost-backup";
@@ -232,6 +234,15 @@ const MOOD_REMINDER_CRON = "*/15 * * * *";
const MOOD_REMINDER_CLEANUP_QUEUE = "mood-reminder-cleanup";
const MOOD_REMINDER_CLEANUP_CRON = "25 3 * * *";
const MOOD_REMINDER_RETENTION_DAYS = 90;
// v1.4.49 — daily prune for the push-attempt ledger. Same 90-day
// retention as the mood-reminder dispatch ledger; both surfaces are
// behavioural footprints we keep long enough to debug a duplicate-push
// report (~one billing cycle) but no longer. Slots at 03:35 between
// mood-reminder cleanup (03:25) and drain-cumulative (03:45) so the
// 03:xx maintenance window stays ordered.
const PUSH_ATTEMPT_CLEANUP_QUEUE = "push-attempt-cleanup";
const PUSH_ATTEMPT_CLEANUP_CRON = "35 3 * * *";
const PUSH_ATTEMPT_RETENTION_DAYS = 90;
// v1.4.38 — the per-sample cutoff hours constant now lives on the
// helper module so the worker, the admin route, and the CLI all read
// the same source of truth. Re-export pulled in alongside
@@ -449,6 +460,12 @@ async function handleReminderCheck(jobs: Job<ReminderCheckPayload>[]) {
// Used to localise the reminder title / message / keyboard
// labels per user. Null falls back to the app default.
locale: true,
// v1.4.49 M-DOUBLE-REMINDER — read the per-user prefs
// blob so the dispatch step can skip APNs sends for users
// whose iOS client has opted in to local SpeziScheduler
// reminders. Null = legacy default (clientManaged: false),
// i.e. the server reminder fires as before.
notificationPrefs: true,
},
},
},
@@ -613,6 +630,42 @@ async function handleReminderCheck(jobs: Job<ReminderCheckPayload>[]) {
// Send notification if enabled
if (med.notificationsEnabled) {
// v1.4.49 M-DOUBLE-REMINDER — opt-in client-managed
// suppression. When the iOS app has confirmed local
// SpeziScheduler banners cover the dose, the server-side
// APNs push is redundant. Skip the dispatch and emit a
// wide-event annotation so the operator can audit the
// skip path. ONLY suppresses MEDICATION_REMINDER — other
// notification kinds (MOOD_REMINDER, PERSONAL_RECORD,
// SYSTEM_ALERT, anomaly alerts) flow unchanged.
if (
isMedicationReminderClientManaged(med.user.notificationPrefs)
) {
const [winH, winM] = schedule.windowStart.split(":").map(Number);
const doseAtIso = localHmAsUtc(
now,
med.user.timezone,
winH,
winM,
).toISOString();
evt.addMeta(
"medication_reminder_suppressed_client_managed",
`${med.name}:${schedule.windowStart}-${schedule.windowEnd}`,
);
evt.addMeta(
"medication_reminder_suppressed_meta",
{
user_id: med.user.id,
medication_id: med.id,
schedule_id: schedule.id,
phase: currentPhase,
dose_at: doseAtIso,
},
);
schedulesProcessed++;
continue;
}
const { title, message } = getPhaseMessage(
currentPhase,
med.name,
@@ -1190,6 +1243,45 @@ async function handleMoodReminderCleanup(
});
}
/**
* v1.4.49 — daily prune for the per-attempt push-delivery ledger.
*
* Every sender (APNS, WEB_PUSH, TELEGRAM, NTFY) writes one
* fire-and-forget row to `push_attempts` per dispatch. The admin
* diagnostic endpoint only ever reads the trailing 20 rows per user,
* so anything older than the 90-day retention window is dead weight
* inflating the table and the `(user_id, created_at DESC)` index.
*
* The DELETE is unbounded by user — the index covers `created_at`
* directly, so a `WHERE created_at < cutoff` scan is bounded by the
* size of the trailing-edge of the table rather than the live working
* set. On a one-million-row table with the documented retention
* window, the daily prune touches ~11k rows (1M / 90d × 1d) and
* completes in milliseconds.
*/
interface PushAttemptCleanupPayload {
triggeredAt: string;
}
async function handlePushAttemptCleanup(
jobs: Job<PushAttemptCleanupPayload>[],
) {
void jobs;
await withBackgroundEvent("job.push_attempt_cleanup", async (evt) => {
const p = getWorkerPrisma();
try {
const cutoff = new Date();
cutoff.setUTCDate(cutoff.getUTCDate() - PUSH_ATTEMPT_RETENTION_DAYS);
const deleted = await p.pushAttempt.deleteMany({
where: { createdAt: { lt: cutoff } },
});
evt.addMeta("push_attempt_cleanup_deleted", deleted.count);
} catch (err) {
evt.addWarning(`push-attempt-cleanup failed: ${err}`);
}
});
}
async function handleMoodReminderCheck(jobs: Job<MoodReminderPayload>[]) {
void jobs;
await withBackgroundEvent("job.mood_reminder", async (evt) => {
@@ -1746,6 +1838,10 @@ export async function startReminderWorker() {
// dispatcher never fires.
MOOD_REMINDER_QUEUE,
MOOD_REMINDER_CLEANUP_QUEUE,
// v1.4.49 — push-attempt ledger cleanup. Same createQueue contract
// as the other cleanup jobs; the daily schedule below would
// silently no-op without this entry.
PUSH_ATTEMPT_CLEANUP_QUEUE,
];
for (const q of allQueues) {
@@ -1762,6 +1858,20 @@ export async function startReminderWorker() {
workerLog("error", "Failed to reconcile orphan ImportJob rows", err);
}
// v1.4.48 M1 — boot probe for legacy `integration_statuses` rows
// that still carry `consecutive_failures_by_kind = NULL`. After
// v1.4.47 dropped the single-column fallback, such rows alert two
// strikes later than they did pre-upgrade. The probe is a single
// count query + Wide-Event warning if any survive; fire-and-forget
// so a probe failure never blocks worker boot.
try {
await withBackgroundEvent("worker.boot.integration_status_null_probe", async () => {
await probeIntegrationStatusNullBuckets(getWorkerPrisma());
});
} catch (err) {
workerLog("error", "integration-status-null-probe failed", err);
}
// Schedule recurring cron jobs
const schedules: [string, string][] = [
[QUEUE_NAME, CHECK_INTERVAL_CRON],
@@ -1814,6 +1924,8 @@ export async function startReminderWorker() {
// per tick for the entire opted-in cohort.
[MOOD_REMINDER_QUEUE, MOOD_REMINDER_CRON],
[MOOD_REMINDER_CLEANUP_QUEUE, MOOD_REMINDER_CLEANUP_CRON],
// v1.4.49 — daily 03:35 Europe/Berlin prune for push_attempts.
[PUSH_ATTEMPT_CLEANUP_QUEUE, PUSH_ATTEMPT_CLEANUP_CRON],
];
for (const [name, cron] of schedules) {
@@ -1935,6 +2047,15 @@ export async function startReminderWorker() {
{ localConcurrency: 1 },
handleMoodReminderCleanup,
);
// v1.4.49 — daily prune of the push-attempt ledger. Single-flight
// matches every other cleanup queue; two ticks racing on the same
// DELETE statement is wasted work and the second tick's payload
// would be a no-op anyway.
await boss.work<PushAttemptCleanupPayload>(
PUSH_ATTEMPT_CLEANUP_QUEUE,
{ localConcurrency: 1 },
handlePushAttemptCleanup,
);
await boss.work<PrDetectionPayload>(
PR_DETECTION_QUEUE,
{ localConcurrency: PR_DETECTION_CONCURRENCY },
@@ -57,6 +57,13 @@ vi.mock("@/lib/db", () => ({
deleteMany: vi.fn(),
update: vi.fn().mockResolvedValue({}),
},
// v1.4.49 — sendViaApns now writes a fire-and-forget row to
// push_attempts on every exit path. The mock returns a resolved
// promise so the swallowed-error helper has nothing to swallow,
// keeping these legacy tests focused on the dispatcher contract.
pushAttempt: {
create: vi.fn().mockResolvedValue({}),
},
},
}));
@@ -0,0 +1,537 @@
/**
* v1.4.49 — fire-and-forget push-attempt ledger writes across the
* four senders (APNS, WEB_PUSH, TELEGRAM, NTFY).
*
* Pins:
* 1. Every sender calls `prisma.pushAttempt.create` with the
* documented row shape (userId / channel / eventType / result /
* reason) on its primary exit paths.
* 2. The write is fire-and-forget: a DB error from
* `pushAttempt.create` MUST NOT surface to the caller. The
* sender's `SendOutcome` carries on as if the ledger write had
* succeeded so the actual push delivery semantics stay intact.
* 3. The ledger writes are scoped to the right channel discriminator
* ("APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY") — no accidental
* cross-channel mislabeling under the shared helper.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// vi.mock factories run before module top-level executes, so anything
// referenced inside a factory must be created via vi.hoisted to share
// the same hoisted scope.
const hoisted = vi.hoisted(() => ({
apnsSendMock: vi.fn(),
apnsShutdownMock: vi.fn(),
webPushSetVapidDetailsMock: vi.fn(),
webPushSendNotificationMock: vi.fn(),
sendTelegramMessageMock: vi.fn(),
deleteTelegramMessageMock: vi.fn(),
}));
vi.mock("@parse/node-apn", () => {
class Provider {
send(...args: unknown[]) {
return hoisted.apnsSendMock(...args);
}
shutdown() {
return hoisted.apnsShutdownMock();
}
}
class Notification {
public topic = "";
public alert: unknown;
public sound: string | undefined;
public badge: number | undefined;
public threadId: string | undefined;
public collapseId: string | undefined;
public payload: unknown;
public category: string | undefined;
public mutableContent: boolean | undefined;
public interruptionLevel: string | undefined;
public priority: number | undefined;
}
return { default: { Provider, Notification }, Provider, Notification };
});
vi.mock("@/lib/db", () => ({
prisma: {
device: {
findMany: vi.fn(),
deleteMany: vi.fn(),
update: vi.fn().mockResolvedValue({}),
},
pushSubscription: {
findMany: vi.fn(),
deleteMany: vi.fn(),
},
pushAttempt: {
create: vi.fn(),
},
telegramReminderMessage: {
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn(),
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/crypto", () => ({
encrypt: (s: string) => `enc(${s})`,
decrypt: (s: string) => s.replace(/^enc\(|\)$/g, ""),
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({
addExternalCall: vi.fn(),
addWarning: vi.fn(),
setError: vi.fn(),
}),
}));
vi.mock("@/lib/notifications/vapid-config", () => ({
getVapidConfig: vi.fn().mockResolvedValue({
subject: "mailto:test@healthlog.local",
publicKey: "vapid-public-key",
privateKey: "vapid-private-key",
}),
}));
vi.mock("web-push", () => ({
default: {
setVapidDetails: hoisted.webPushSetVapidDetailsMock,
sendNotification: hoisted.webPushSendNotificationMock,
},
setVapidDetails: hoisted.webPushSetVapidDetailsMock,
sendNotification: hoisted.webPushSendNotificationMock,
}));
vi.mock("@/lib/telegram", () => ({
sendTelegramMessage: hoisted.sendTelegramMessageMock,
deleteMessage: hoisted.deleteTelegramMessageMock,
}));
import { sendViaApns, resetApnsForTesting } from "@/lib/notifications/senders/apns";
import { sendViaWebPush } from "@/lib/notifications/senders/web-push";
import { sendViaTelegram } from "@/lib/notifications/senders/telegram";
import { sendViaNtfy } from "@/lib/notifications/senders/ntfy";
import { prisma } from "@/lib/db";
const TEST_EC_PEM_LINES = [
"-----BEGIN PRIVATE KEY-----",
"MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgLOXP3Exjr5L5tamN",
"pTxck85Iaum80PdRlWDpc/ezviOgCgYIKoZIzj0DAQehRANCAAT1x8nKRb8KshQU",
"1aPieSCqOY6ilgC959umaFSlhfav8eZ91UHP/xond9aMoZcuQ7lJG/Rsj70SWMvZ",
"bw81BG89",
"-----END PRIVATE KEY-----",
];
const APNS_ENV = {
APNS_KEY_ID: "ABCDE12345",
APNS_TEAM_ID: "TEAM123456",
APNS_BUNDLE_ID: "test.healthlog.ios",
APNS_KEY: TEST_EC_PEM_LINES.join("\\n"),
};
function setApnsEnv(over: Record<string, string | undefined> = {}): void {
for (const k of [
"APNS_KEY_ID",
"APNS_TEAM_ID",
"APNS_BUNDLE_ID",
"APNS_KEY",
"APNS_KEY_B64",
"APNS_KEY_FILE",
"APNS_PRODUCTION",
]) {
delete process.env[k];
}
for (const [k, v] of Object.entries(over)) {
if (v !== undefined) process.env[k] = v;
}
}
// Capture every `create` call without forcing the test to a specific
// argument order. Each test asserts the call it cares about by
// filtering on `channel`.
function pushAttemptCalls(): Array<{
userId: string;
channel: string;
eventType: string;
result: string;
reason: string | null;
}> {
const create = vi.mocked(prisma.pushAttempt.create);
return create.mock.calls.map((c) => {
const args = c[0] as { data: Record<string, unknown> };
return args.data as never;
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.pushAttempt.create).mockResolvedValue({} as never);
vi.mocked(prisma.device.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.device.deleteMany).mockResolvedValue({ count: 0 } as never);
vi.mocked(prisma.pushSubscription.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.pushSubscription.deleteMany).mockResolvedValue({
count: 0,
} as never);
resetApnsForTesting();
setApnsEnv(APNS_ENV);
});
afterEach(() => {
setApnsEnv();
resetApnsForTesting();
});
// Helper — the recordPushAttempt helper is fire-and-forget. Tests
// resolve a microtask tick to give the swallowed promise time to land
// before assertions.
async function flushMicrotasks(): Promise<void> {
await new Promise((resolve) => setImmediate(resolve));
}
describe("recordPushAttempt — APNS sender", () => {
it("writes one ok row when the dispatch succeeds", async () => {
vi.mocked(prisma.device.findMany).mockResolvedValue([
{
id: "dev-1",
apnsToken: "a".repeat(64),
apnsEnvironment: "sandbox",
},
] as never);
hoisted.apnsSendMock.mockResolvedValue({
sent: [{ device: "a".repeat(64) }],
failed: [],
});
const outcome = await sendViaApns("user-1", {
title: "T",
message: "M",
eventType: "MEDICATION_REMINDER",
});
await flushMicrotasks();
expect(outcome.ok).toBe(true);
const apns = pushAttemptCalls().filter((c) => c.channel === "APNS");
expect(apns).toHaveLength(1);
expect(apns[0]).toMatchObject({
userId: "user-1",
channel: "APNS",
eventType: "MEDICATION_REMINDER",
result: "ok",
reason: null,
});
});
it("writes one error row carrying Apple's reason on hard reject", async () => {
vi.mocked(prisma.device.findMany).mockResolvedValue([
{
id: "dev-1",
apnsToken: "a".repeat(64),
apnsEnvironment: "sandbox",
},
] as never);
hoisted.apnsSendMock.mockResolvedValue({
sent: [],
failed: [{ response: { reason: "BadDeviceToken" }, status: "410" }],
});
await sendViaApns("user-2", {
title: "T",
message: "M",
eventType: "MEDICATION_REMINDER",
});
await flushMicrotasks();
const apns = pushAttemptCalls().filter((c) => c.channel === "APNS");
expect(apns).toHaveLength(1);
expect(apns[0]).toMatchObject({
userId: "user-2",
channel: "APNS",
eventType: "MEDICATION_REMINDER",
result: "error",
reason: "BadDeviceToken",
});
});
it("writes a skipped row when no APNs devices are paired", async () => {
vi.mocked(prisma.device.findMany).mockResolvedValue([] as never);
await sendViaApns("user-3", {
title: "T",
message: "M",
eventType: "MOOD_REMINDER",
});
await flushMicrotasks();
const apns = pushAttemptCalls().filter((c) => c.channel === "APNS");
expect(apns).toHaveLength(1);
expect(apns[0]).toMatchObject({
channel: "APNS",
result: "skipped",
reason: "apns_no_devices",
});
});
it("does NOT surface a ledger DB error to the caller", async () => {
vi.mocked(prisma.device.findMany).mockResolvedValue([
{
id: "dev-1",
apnsToken: "a".repeat(64),
apnsEnvironment: "sandbox",
},
] as never);
hoisted.apnsSendMock.mockResolvedValue({
sent: [{ device: "a".repeat(64) }],
failed: [],
});
// Simulate a DB hiccup on the ledger write. The sender contract
// says this must not propagate.
vi.mocked(prisma.pushAttempt.create).mockRejectedValue(
new Error("connection refused"),
);
const outcome = await sendViaApns("user-4", {
title: "T",
message: "M",
eventType: "MEDICATION_REMINDER",
});
await flushMicrotasks();
expect(outcome.ok).toBe(true);
});
});
describe("recordPushAttempt — WEB_PUSH sender", () => {
it("writes one ok row when at least one subscription accepts the push", async () => {
vi.mocked(prisma.pushSubscription.findMany).mockResolvedValue([
{
id: "sub-1",
endpoint: "https://example.test/push",
p256dh: "enc(p)",
auth: "enc(a)",
},
] as never);
hoisted.webPushSendNotificationMock.mockResolvedValue(undefined);
const outcome = await sendViaWebPush("user-1", {
userId: "user-1",
title: "T",
message: "M",
eventType: "MEASUREMENT_ANOMALY",
});
await flushMicrotasks();
expect(outcome.ok).toBe(true);
const rows = pushAttemptCalls().filter((c) => c.channel === "WEB_PUSH");
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
userId: "user-1",
channel: "WEB_PUSH",
eventType: "MEASUREMENT_ANOMALY",
result: "ok",
});
});
it("writes a skipped row when the user has zero subscriptions", async () => {
vi.mocked(prisma.pushSubscription.findMany).mockResolvedValue([] as never);
await sendViaWebPush("user-2", {
userId: "user-2",
title: "T",
message: "M",
eventType: "SYSTEM_ALERT",
});
await flushMicrotasks();
const rows = pushAttemptCalls().filter((c) => c.channel === "WEB_PUSH");
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
result: "skipped",
reason: "web_push_no_subscriptions",
});
});
it("does NOT surface a ledger DB error to the caller", async () => {
vi.mocked(prisma.pushSubscription.findMany).mockResolvedValue([
{
id: "sub-1",
endpoint: "https://example.test/push",
p256dh: "enc(p)",
auth: "enc(a)",
},
] as never);
hoisted.webPushSendNotificationMock.mockResolvedValue(undefined);
vi.mocked(prisma.pushAttempt.create).mockRejectedValue(
new Error("connection refused"),
);
const outcome = await sendViaWebPush("user-3", {
userId: "user-3",
title: "T",
message: "M",
eventType: "MEDICATION_REMINDER",
});
await flushMicrotasks();
expect(outcome.ok).toBe(true);
});
});
describe("recordPushAttempt — TELEGRAM sender", () => {
it("writes one ok row carrying payload.userId when Telegram accepts the message", async () => {
hoisted.sendTelegramMessageMock.mockResolvedValue({
ok: true,
messageId: 42,
});
const outcome = await sendViaTelegram(
{ botToken: "bot:test", chatId: "chat-1" },
{
userId: "user-tg-1",
title: "T",
message: "M",
eventType: "MEDICATION_REMINDER",
},
);
await flushMicrotasks();
expect(outcome.ok).toBe(true);
const rows = pushAttemptCalls().filter((c) => c.channel === "TELEGRAM");
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
userId: "user-tg-1",
channel: "TELEGRAM",
eventType: "MEDICATION_REMINDER",
result: "ok",
});
});
it("writes one error row with the classified reason on Telegram failure", async () => {
hoisted.sendTelegramMessageMock.mockResolvedValue({
ok: false,
errorDescription: "Forbidden: bot was blocked by the user",
});
await sendViaTelegram(
{ botToken: "bot:test", chatId: "chat-1" },
{
userId: "user-tg-2",
title: "T",
message: "M",
eventType: "MOOD_REMINDER",
},
);
await flushMicrotasks();
const rows = pushAttemptCalls().filter((c) => c.channel === "TELEGRAM");
expect(rows).toHaveLength(1);
expect(rows[0].result).toBe("error");
expect(rows[0].reason).toBeTruthy();
});
it("does NOT surface a ledger DB error to the caller", async () => {
hoisted.sendTelegramMessageMock.mockResolvedValue({
ok: true,
messageId: 99,
});
vi.mocked(prisma.pushAttempt.create).mockRejectedValue(
new Error("connection refused"),
);
const outcome = await sendViaTelegram(
{ botToken: "bot:test", chatId: "chat-1" },
{
userId: "user-tg-3",
title: "T",
message: "M",
eventType: "SYSTEM_ALERT",
},
);
await flushMicrotasks();
expect(outcome.ok).toBe(true);
});
});
describe("recordPushAttempt — NTFY sender", () => {
let fetchSpy: ReturnType<typeof vi.spyOn> | undefined;
beforeEach(() => {
fetchSpy = vi.spyOn(global, "fetch") as never;
});
afterEach(() => {
fetchSpy?.mockRestore();
});
it("writes one ok row when ntfy returns 200", async () => {
(fetchSpy as ReturnType<typeof vi.fn>).mockResolvedValue(
new Response("", { status: 200 }),
);
const outcome = await sendViaNtfy(
{ serverUrl: "https://ntfy.test", topic: "hl" },
{
userId: "user-ntfy-1",
title: "T",
message: "M",
eventType: "PERSONAL_RECORD",
},
);
await flushMicrotasks();
expect(outcome.ok).toBe(true);
const rows = pushAttemptCalls().filter((c) => c.channel === "NTFY");
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
userId: "user-ntfy-1",
channel: "NTFY",
eventType: "PERSONAL_RECORD",
result: "ok",
});
});
it("writes one error row when ntfy returns 5xx", async () => {
(fetchSpy as ReturnType<typeof vi.fn>).mockResolvedValue(
new Response("", { status: 503 }),
);
await sendViaNtfy(
{ serverUrl: "https://ntfy.test", topic: "hl" },
{
userId: "user-ntfy-2",
title: "T",
message: "M",
eventType: "SYSTEM_ALERT",
},
);
await flushMicrotasks();
const rows = pushAttemptCalls().filter((c) => c.channel === "NTFY");
expect(rows).toHaveLength(1);
expect(rows[0].result).toBe("error");
});
it("does NOT surface a ledger DB error to the caller", async () => {
(fetchSpy as ReturnType<typeof vi.fn>).mockResolvedValue(
new Response("", { status: 200 }),
);
vi.mocked(prisma.pushAttempt.create).mockRejectedValue(
new Error("connection refused"),
);
const outcome = await sendViaNtfy(
{ serverUrl: "https://ntfy.test", topic: "hl" },
{
userId: "user-ntfy-3",
title: "T",
message: "M",
eventType: "PERSONAL_RECORD",
},
);
await flushMicrotasks();
expect(outcome.ok).toBe(true);
});
});
+39 -2
View File
@@ -29,6 +29,7 @@ import apn from "@parse/node-apn";
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import type { SendOutcome } from "@/lib/notifications/retry-policy";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
export interface ApnsPayload {
/** APNs `aps.alert.title` + `aps.alert.body`. */
@@ -526,6 +527,13 @@ export async function sendViaApns(
): Promise<SendOutcome> {
const config = loadApnsConfig();
if (!config) {
recordPushAttempt({
userId,
channel: "APNS",
eventType: payload.eventType,
result: "skipped",
reason: "apns_not_configured",
});
return {
ok: false,
hardReject: false,
@@ -548,6 +556,13 @@ export async function sendViaApns(
if (devices.length === 0) {
// Same rationale as web-push: a user who hasn't paired an iPhone yet
// shouldn't see the channel auto-disabled. Treat as soft "no recipient".
recordPushAttempt({
userId,
channel: "APNS",
eventType: payload.eventType,
result: "skipped",
reason: "apns_no_devices",
});
return {
ok: false,
hardReject: false,
@@ -667,25 +682,47 @@ export async function sendViaApns(
}
if (anySuccess) {
recordPushAttempt({
userId,
channel: "APNS",
eventType: payload.eventType,
result: "ok",
});
return { ok: true };
}
// Every surviving device returned a permanent reason → channel is dead
// until the user re-registers from a fresh iOS install.
if (deadDeviceIds.length === devices.length) {
const reason = lastFailureReason ?? "apns_all_devices_unregistered";
recordPushAttempt({
userId,
channel: "APNS",
eventType: payload.eventType,
result: "error",
reason,
});
return {
ok: false,
hardReject: true,
statusCode: lastFailureStatus,
reason: lastFailureReason ?? "apns_all_devices_unregistered",
reason,
};
}
const reason = lastFailureReason ?? "apns_send_failed";
recordPushAttempt({
userId,
channel: "APNS",
eventType: payload.eventType,
result: "error",
reason,
});
return {
ok: false,
hardReject: false,
statusCode: lastFailureStatus,
reason: lastFailureReason ?? "apns_send_failed",
reason,
};
}
+21
View File
@@ -5,6 +5,7 @@ import type {
import type { SendOutcome } from "@/lib/notifications/retry-policy";
import { classifyHttpStatus } from "@/lib/notifications/retry-policy";
import { getEvent } from "@/lib/logging/context";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
/**
* Send notification via ntfy (simple HTTP POST).
@@ -52,9 +53,22 @@ export async function sendViaNtfy(
});
if (res.ok) {
recordPushAttempt({
userId: payload.userId,
channel: "NTFY",
eventType: payload.eventType,
result: "ok",
});
return { ok: true, statusCode: res.status };
}
const classified = classifyHttpStatus(res.status, "ntfy");
recordPushAttempt({
userId: payload.userId,
channel: "NTFY",
eventType: payload.eventType,
result: "error",
reason: classified.reason,
});
return {
ok: false,
statusCode: res.status,
@@ -69,6 +83,13 @@ export async function sendViaNtfy(
duration_ms: Math.round(performance.now() - start),
error: message,
});
recordPushAttempt({
userId: payload.userId,
channel: "NTFY",
eventType: payload.eventType,
result: "error",
reason: "ntfy_network_error",
});
return {
ok: false,
hardReject: false,
@@ -0,0 +1,62 @@
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
/**
* v1.4.49 — fire-and-forget push-attempt ledger write.
*
* Every sender (APNS, WEB_PUSH, TELEGRAM, NTFY) calls this helper
* once per dispatch so the admin diagnostic endpoint
* (`/api/admin/notifications/diagnostic`) can surface the trailing
* 20 attempts per user without DB shell access.
*
* Contract:
* * NEVER throws. A DB hiccup on the insert must not break the
* actual push delivery, so we swallow the error and emit a
* wide-event warning the operator can correlate with the
* primary sender's outcome.
* * Single insert per call. No retry, no batching — a missing row
* is a strictly worse outcome than a duplicate one, but we
* accept it rather than amplify a DB blip into a retry storm.
* * Synchronous return is `void`. Callers must use
* `void recordPushAttempt(...)` so the sender's hot path doesn't
* await the ledger write. The pg connection pool is shared with
* the dispatcher's read queries; awaiting here would block the
* next channel in the cascade for no operational benefit.
*
* Result discriminator:
* * `"ok"` — the sender returned a successful `SendOutcome`.
* * `"error"` — the sender returned `ok: false` with a
* classified failure (hard or soft reject).
* * `"skipped"` — soft "no recipient" / "config missing" cases
* the sender contracts surface without contacting
* the upstream provider.
*/
export interface PushAttemptRecord {
userId: string;
channel: "APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY";
eventType: string;
result: "ok" | "error" | "skipped";
reason?: string | null;
}
export function recordPushAttempt(record: PushAttemptRecord): void {
// The `void` here is the doubly-explicit form: we want neither the
// caller nor a linter to wait on this promise. The `.catch` traps
// a rejected promise without surfacing it to the caller.
void prisma.pushAttempt
.create({
data: {
userId: record.userId,
channel: record.channel,
eventType: record.eventType,
result: record.result,
reason: record.reason ?? null,
},
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : "unknown_error";
getEvent()?.addWarning(
`push_attempt_ledger_write_failed channel=${record.channel} reason=${message}`,
);
});
}
+14
View File
@@ -8,6 +8,7 @@ import { classifyTelegramError } from "@/lib/notifications/retry-policy";
import { prisma } from "@/lib/db";
import type { ReminderPhase } from "@/generated/prisma/client";
import { getEvent } from "@/lib/logging/context";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
/**
* Telegram sender result. Inherits from `SendOutcome` so the dispatcher
@@ -154,9 +155,22 @@ export async function sendViaTelegram(
}
if (result.ok) {
recordPushAttempt({
userId: payload.userId,
channel: "TELEGRAM",
eventType: payload.eventType,
result: "ok",
});
return { ok: true, messageId: result.messageId };
}
const classified = classifyTelegramError(result.errorDescription);
recordPushAttempt({
userId: payload.userId,
channel: "TELEGRAM",
eventType: payload.eventType,
result: "error",
reason: classified.reason,
});
return {
ok: false,
hardReject: classified.hardReject,
+42
View File
@@ -5,6 +5,7 @@ import type { SendOutcome } from "@/lib/notifications/retry-policy";
import { classifyHttpStatus } from "@/lib/notifications/retry-policy";
import { getVapidConfig } from "@/lib/notifications/vapid-config";
import { getEvent } from "@/lib/logging/context";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
/**
* Send Web Push notification to all subscribed devices of a user.
@@ -32,6 +33,13 @@ export async function sendViaWebPush(
const config = await getVapidConfig();
if (!config) {
getEvent()?.addWarning("Web Push: VAPID keys not configured");
recordPushAttempt({
userId,
channel: "WEB_PUSH",
eventType: payload.eventType,
result: "skipped",
reason: "web_push_vapid_not_configured",
});
return {
ok: false,
hardReject: false,
@@ -54,6 +62,13 @@ export async function sendViaWebPush(
// may simply not have hit "Subscribe" on this browser. Counting
// this as a give-up signal would lock the user out of the channel
// they just configured. Treat it as a soft "no recipient" instead.
recordPushAttempt({
userId,
channel: "WEB_PUSH",
eventType: payload.eventType,
result: "skipped",
reason: "web_push_no_subscriptions",
});
return {
ok: false,
hardReject: false,
@@ -119,12 +134,25 @@ export async function sendViaWebPush(
}
if (anySuccess) {
recordPushAttempt({
userId,
channel: "WEB_PUSH",
eventType: payload.eventType,
result: "ok",
});
return { ok: true };
}
// Every sub returned a permanent 410/404 → channel is dead until
// the user re-pairs a device. This is a hard reject.
if (allPermanentReject && expiredIds.length === subscriptions.length) {
recordPushAttempt({
userId,
channel: "WEB_PUSH",
eventType: payload.eventType,
result: "error",
reason: "web_push_410_gone",
});
return {
ok: false,
hardReject: true,
@@ -135,6 +163,13 @@ export async function sendViaWebPush(
// Soft failure (5xx / 429 / fetch error) on at least one sub.
const classified = classifyHttpStatus(lastTransientStatus, "web-push");
recordPushAttempt({
userId,
channel: "WEB_PUSH",
eventType: payload.eventType,
result: "error",
reason: classified.reason,
});
return {
ok: false,
hardReject: false,
@@ -147,6 +182,13 @@ export async function sendViaWebPush(
? err
: new Error("[web-push] sendViaWebPush failed"),
);
recordPushAttempt({
userId,
channel: "WEB_PUSH",
eventType: payload.eventType,
result: "error",
reason: "web_push_unexpected_error",
});
return {
ok: false,
hardReject: false,
@@ -0,0 +1,130 @@
import { describe, it, expect } from "vitest";
import {
redactSensitiveFields,
SENSITIVE_KEY_PATTERNS,
} from "../redact-payload";
// v1.4.49 — pinned redactor for the wide-event `received_shape_excerpt`
// surface introduced in v1.4.48 H-iOS-1 / H-iOS-2. The helper must
// strip values for any key matching the SENSITIVE_KEY_PATTERNS denylist
// before the caller's `JSON.stringify` lands in the meta excerpt.
describe("redactSensitiveFields", () => {
it("passes a body with no sensitive keys through unchanged", () => {
const body = {
version: 1,
widgets: [{ id: "weight", visible: true, order: 0 }],
comparisonBaseline: "lastMonth",
};
const result = redactSensitiveFields(body);
expect(result).toEqual(body);
// Returns a clone, not the original reference.
expect(result).not.toBe(body);
});
it("redacts a top-level `password` key", () => {
const body = { username: "alice", password: "hunter2" };
expect(redactSensitiveFields(body)).toEqual({
username: "alice",
password: "[redacted]",
});
});
it("redacts `Authorization` regardless of case (Bearer prefix preserved as redacted)", () => {
const body = { Authorization: "Bearer eyJhbGciOiJIUzI1NiJ9.xxx" };
expect(redactSensitiveFields(body)).toEqual({
Authorization: "[redacted]",
});
});
it("redacts a nested object's sensitive fields recursively", () => {
const body = {
kind: "device.register",
payload: {
deviceId: "abc-123",
apnsToken: "ff".repeat(32),
meta: { secret: "shh", model: "iPhone15" },
},
};
expect(redactSensitiveFields(body)).toEqual({
kind: "device.register",
payload: {
deviceId: "abc-123",
apnsToken: "[redacted]",
meta: { secret: "[redacted]", model: "iPhone15" },
},
});
});
it("redacts inside array members", () => {
const body = {
entries: [
{ id: "1", apiKey: "sk_live_xxx" },
{ id: "2", token: "bearer-xyz" },
{ id: "3", note: "free text" },
],
};
expect(redactSensitiveFields(body)).toEqual({
entries: [
{ id: "1", apiKey: "[redacted]" },
{ id: "2", token: "[redacted]" },
{ id: "3", note: "free text" },
],
});
});
it("matches the token denylist patterns case-insensitively (apnsToken, bearerToken, csrf_state)", () => {
const body = {
apnsToken: "ff".repeat(32),
bearerToken: "abc",
csrf_state: "xyz",
csrfState: "xyz2",
api_key: "sk_xxx",
Nonce: "01234",
randomField: "kept",
};
expect(redactSensitiveFields(body)).toEqual({
apnsToken: "[redacted]",
bearerToken: "[redacted]",
csrf_state: "[redacted]",
csrfState: "[redacted]",
api_key: "[redacted]",
Nonce: "[redacted]",
randomField: "kept",
});
});
it("passes scalars + null + undefined through unchanged", () => {
expect(redactSensitiveFields(null)).toBeNull();
expect(redactSensitiveFields(undefined)).toBeUndefined();
expect(redactSensitiveFields("string")).toBe("string");
expect(redactSensitiveFields(42)).toBe(42);
expect(redactSensitiveFields(true)).toBe(true);
});
it("does not recurse into non-plain objects (Date stays intact)", () => {
const date = new Date("2026-05-22T10:00:00Z");
const body = { measuredAt: date, password: "x" };
const result = redactSensitiveFields(body) as Record<string, unknown>;
expect(result.measuredAt).toBe(date);
expect(result.password).toBe("[redacted]");
});
it("exposes the SENSITIVE_KEY_PATTERNS denylist as a readonly tuple", () => {
// Pin the public surface so a future commit can't silently drop
// one of the documented patterns.
const required = [
"password",
"token",
"secret",
"apikey",
"authorization",
"csrfstate",
"nonce",
];
for (const key of required) {
const matched = SENSITIVE_KEY_PATTERNS.some((re) => re.test(key));
expect(matched, `pattern set should match ${key}`).toBe(true);
}
});
});
+79
View File
@@ -0,0 +1,79 @@
/**
* v1.4.49 — sensitive-field redactor for observability payloads.
*
* The v1.4.48 H-iOS-1 / H-iOS-2 work surfaces a 256-char truncated
* `JSON.stringify` of the request body to the wide-event
* `meta.received_shape_excerpt`. Today's iOS surface (widget layout +
* series query) only carries enum / boolean / int fields so a verbatim
* excerpt is safe, but the pattern is heading for routes that accept
* tokens (APNs registration, Withings re-auth, API tokens) and bearer
* credentials. Pre-empt the leak: every body that flows into a
* wide-event excerpt is first walked through `redactSensitiveFields`,
* which replaces any key matching one of the `SENSITIVE_KEY_PATTERNS`
* regexes with the literal string `"[redacted]"`.
*
* Adding a new sensitive token is a one-line append to
* `SENSITIVE_KEY_PATTERNS`. The match is case-insensitive and runs
* against the literal key name, so `apnsToken`, `bearerToken`,
* `APIKey`, `Authorization` and `csrf_state` all redact without
* extra spellings.
*
* The redactor recurses into nested objects + arrays so deeply-nested
* credentials (e.g. `{ creds: { password: "…" } }`) are caught the
* same way. Non-object scalar inputs pass through unchanged — the
* caller's `JSON.stringify` then truncates as before.
*/
export const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [
/password/i,
/token/i,
/secret/i,
/apikey/i,
/api[_-]key/i,
/authorization/i,
/csrfstate/i,
/csrf[_-]state/i,
/nonce/i,
];
const REDACTED = "[redacted]";
function keyIsSensitive(key: string): boolean {
for (const re of SENSITIVE_KEY_PATTERNS) {
if (re.test(key)) return true;
}
return false;
}
/**
* Returns a deep clone of `body` with any key matching the sensitive
* denylist replaced by the literal `"[redacted]"`. Arrays + plain
* objects are recursed into; scalars, `null`, `undefined`, `Date` and
* other non-plain objects pass through unchanged.
*
* The clone is intentionally shallow on non-plain-object boundaries so
* the helper stays cheap on the hot 422 path; the only goal is to keep
* the credentialed key's value out of the eventual JSON excerpt.
*/
export function redactSensitiveFields(body: unknown): unknown {
if (body === null || body === undefined) return body;
if (Array.isArray(body)) {
return body.map((entry) => redactSensitiveFields(entry));
}
if (typeof body !== "object") return body;
// Skip non-plain objects (Date, Map, Set, Buffer, ...) — they'll
// serialise to a benign form via JSON.stringify and we don't want to
// walk into framework internals.
const proto = Object.getPrototypeOf(body);
if (proto !== Object.prototype && proto !== null) return body;
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(body as Record<string, unknown>)) {
if (keyIsSensitive(key)) {
out[key] = REDACTED;
continue;
}
out[key] = redactSensitiveFields(value);
}
return out;
}
@@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Stub the launcher import so the helper test never has to instantiate
// the @dnd-kit / launcher render tree just to exercise its
// sessionStorage write side-effect. We assert the helper *calls* it on
// the success branch and skips it when no user-id was passed in.
const setTourForceLaunchSpy = vi.fn();
vi.mock("@/components/onboarding/tour-launcher", () => ({
setTourForceLaunch: (userId: string) => setTourForceLaunchSpy(userId),
}));
import { restartOnboardingTour } from "../tour-restart";
describe("restartOnboardingTour()", () => {
beforeEach(() => {
setTourForceLaunchSpy.mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("success branch — flips the server flag, fires the force-launch marker, returns { ok: true }", async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response("{}", { status: 200 }));
const result = await restartOnboardingTour("user-42", { fetchImpl });
expect(result).toEqual({ ok: true });
expect(fetchImpl).toHaveBeenCalledTimes(1);
const [url, init] = fetchImpl.mock.calls[0];
expect(url).toBe("/api/onboarding/tour");
expect(init?.method).toBe("POST");
expect(init?.headers).toEqual({ "Content-Type": "application/json" });
expect(init?.body).toBe(JSON.stringify({ completed: false }));
// Per-user force-launch marker fired on success.
expect(setTourForceLaunchSpy).toHaveBeenCalledWith("user-42");
});
it("success branch — skips the force-launch marker when no userId was supplied", async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response("{}", { status: 200 }));
const result = await restartOnboardingTour(undefined, { fetchImpl });
expect(result).toEqual({ ok: true });
expect(setTourForceLaunchSpy).not.toHaveBeenCalled();
});
it("error-network branch — fetch rejects, returns { ok: false } with common.networkError", async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockRejectedValue(new TypeError("network down"));
const result = await restartOnboardingTour("user-42", { fetchImpl });
expect(result).toEqual({ ok: false, messageKey: "common.networkError" });
// No force-launch marker should fire on the error branch — a failed
// server flip MUST NOT arm the launcher.
expect(setTourForceLaunchSpy).not.toHaveBeenCalled();
});
it("error-server-422 branch — non-ok response returns { ok: false } with settings.savingError", async () => {
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(
new Response(JSON.stringify({ error: "Unprocessable" }), {
status: 422,
}),
);
const result = await restartOnboardingTour("user-42", { fetchImpl });
expect(result).toEqual({ ok: false, messageKey: "settings.savingError" });
expect(setTourForceLaunchSpy).not.toHaveBeenCalled();
});
});
+83
View File
@@ -0,0 +1,83 @@
/**
* v1.4.48 M6b — shared "restart onboarding tour" worker.
*
* `<AccountSection>` and `<AboutSection>` both expose a button that
* resets `users.onboarding_tour_completed = false` and arranges for the
* dashboard's `<TourLauncher>` to re-open the spotlight on the user's
* next navigation. Until v1.4.48 the two handlers were 90 % identical
* copy-paste — a known drift hazard. This helper owns the surface so
* both call sites narrow to a single mutate-then-route step:
*
* const result = await restartOnboardingTour(user?.id);
* if (result.ok) toast(t("onboarding.tour.restartConfirmation"));
* else toast(t(result.messageKey));
*
* Side-effects this helper owns:
*
* - PUT `/api/onboarding/tour` with `{ completed: false }` (the same
* server flip both Account and About already drove).
* - On success — write the per-user `setTourForceLaunch(userId)`
* marker into sessionStorage so the next dashboard mount opens
* the tour even when the 24 h auto-launch gate would suppress it.
* - Fire a `healthlog:tour-restart` window event so a dashboard
* mounted in another tab / background reopens the spotlight
* immediately. The dispatch is best-effort; SSR or sandboxed
* iframes that can't construct CustomEvent are tolerated.
*
* Translation + toast rendering are deliberately NOT owned here — the
* helper returns a discriminated `{ ok }` result and lets the React
* component pick the right `useTranslations()` instance. Keeps the
* helper pure of i18n state and easy to unit-test under the default
* vitest "node" environment.
*/
import { setTourForceLaunch } from "@/components/onboarding/tour-launcher";
export type RestartTourResult =
| { ok: true }
| { ok: false; messageKey: string };
export interface RestartTourOptions {
/**
* Fetch implementation injected for tests. Defaults to the global
* `fetch` so production code paths read exactly as before.
*/
fetchImpl?: typeof fetch;
}
/**
* Persist the tour-replay request server-side and arm the launcher
* for the user's next dashboard navigation.
*
* `userId` may be undefined when the auth payload hasn't landed yet —
* the server flip still fires, and the force-launch marker is simply
* skipped (the launcher's secondary "tour-completed = false" path
* still picks the change up on the next `/api/auth/me` read).
*/
export async function restartOnboardingTour(
userId: string | undefined,
opts: RestartTourOptions = {},
): Promise<RestartTourResult> {
const fetchImpl = opts.fetchImpl ?? fetch;
try {
const res = await fetchImpl("/api/onboarding/tour", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: false }),
});
if (!res.ok) {
return { ok: false, messageKey: "settings.savingError" };
}
if (userId) setTourForceLaunch(userId);
try {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("healthlog:tour-restart"));
}
} catch {
/* ignore — only matters if a dashboard is already mounted */
}
return { ok: true };
} catch {
return { ok: false, messageKey: "common.networkError" };
}
}
+150
View File
@@ -123,6 +123,24 @@ const coachMessageFeedbackBody = z
"Per-message helpful/unhelpful feedback (v1.4.23 H7). Optional `reason` is free-form prose, capped at 200 chars.",
});
// v1.4.49 — single schema for the per-user Coach opt-out flag. Previously
// split into `disableCoachBody` (PATCH request) and `disableCoachData`
// (response payload), both `z.object({ disableCoach: z.boolean() })`. The
// payload was passed through `dataEnvelope(..., "GetDisableCoachResponse")`
// / `dataEnvelope(..., "PatchDisableCoachResponse")` which IDs the
// envelope wrapper — the inner flag carries its own `.meta()` and now
// renders as a single `$ref: "#/components/schemas/DisableCoachFlag"` in
// both the request body and both response envelopes.
const disableCoachFlag = z
.object({
disableCoach: z.boolean(),
})
.meta({
id: "DisableCoachFlag",
description:
"Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.",
});
// ── Sub-schemas owned here (route-specific shapes) ───────────────────
const passkeyLoginVerifyRequest = z
@@ -408,6 +426,68 @@ const measurementResource = z
description: "Server-shaped measurement row returned by GET endpoints.",
});
// v1.4.48 H-APNs-1 — admin diagnostic endpoint for the notification
// subsystem. Mirrors the runtime types in
// `src/app/api/admin/notifications/diagnostic/route.ts`: APNs tokens
// are surfaced only as 8-char hex prefix + suffix (never the full
// token), per-channel `enabled` + `configPresent` booleans, and a
// `recentPushAttempts` array reserved for the v1.4.48+ follow-up that
// adds the dedicated PushAttempt table (currently always `[]` so the
// shape stays stable for iOS consumers).
const adminDiagnosticDevice = z
.object({
id: z.string(),
platform: z.string(),
hasApnsToken: z.boolean(),
apnsTokenPrefix: z.string().nullable(),
apnsTokenSuffix: z.string().nullable(),
apnsEnvironment: z.string().nullable(),
lastSeenAt: z.iso.datetime({ offset: true }),
})
.meta({
id: "AdminDiagnosticDevice",
description:
"Per-device APNs registration snapshot. The APNs token is masked to its 8-char hex prefix + 8-char suffix so an operator can correlate with iOS-side logs without disclosing the delivery target.",
});
const adminDiagnosticChannel = z
.object({
type: z.string(),
enabled: z.boolean(),
configPresent: z.boolean(),
})
.meta({
id: "AdminDiagnosticChannel",
description:
"Per-channel state. `configPresent` is true when the channel's encrypted-JSON config blob carries the field the corresponding sender will read (chatId+botToken for TELEGRAM, topic for NTFY); WEB_PUSH/APNS rely on sibling tables so the row's existence is the signal.",
});
const adminDiagnosticPushAttempt = z
.object({
eventType: z.string(),
channel: z.string(),
result: z.string(),
reason: z.string().nullable(),
at: z.iso.datetime({ offset: true }),
})
.meta({
id: "AdminDiagnosticPushAttempt",
description:
"Single push delivery attempt. Reserved for the v1.4.48+ follow-up that adds the dedicated PushAttempt table — currently always returned as an empty array so the response shape stays stable for iOS consumers.",
});
const adminDiagnosticData = z
.object({
devices: z.array(adminDiagnosticDevice),
notificationChannels: z.array(adminDiagnosticChannel),
recentPushAttempts: z.array(adminDiagnosticPushAttempt),
})
.meta({
id: "AdminDiagnosticData",
description:
"Admin notification diagnostic snapshot for the calling user — what the dispatcher would see when targeting this account. Surfaces device tokens (masked), channel state, and recent push attempts so an operator can debug an iOS / Web Push / Telegram / ntfy issue without DB shell access.",
});
const insightsComprehensiveResponse = z
.object({
summary: z.string(),
@@ -780,6 +860,50 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/auth/me/disable-coach": {
get: {
tags: ["Auth"],
summary: "Read per-user Coach opt-out flag",
description:
"Returns the user's per-account Coach opt-out flag. Default `false` (Coach visible). Powers the Settings → Insights \"Hide Coach\" Switch and the layout FAB short-circuit.",
responses: {
"200": {
description: "Resolved flag.",
content: {
"application/json": {
schema: dataEnvelope(disableCoachFlag, "GetDisableCoachResponse"),
},
},
},
...stdResponses,
},
},
patch: {
tags: ["Auth"],
summary: "Toggle per-user Coach opt-out flag",
description:
"Flips the per-account Coach opt-out flag. Idempotent — the DB write fires even when the value matches so the audit-log row mirrors the API call. Rate-limit 60/min per user.",
requestBody: {
required: true,
content: { "application/json": { schema: disableCoachFlag } },
},
responses: {
"200": {
description:
"Resolved next-state echoed back for optimistic-update consumers.",
content: {
"application/json": {
schema: dataEnvelope(
disableCoachFlag,
"PatchDisableCoachResponse",
),
},
},
},
...stdResponses,
},
},
},
"/api/auth/me/source-priority": {
get: {
tags: ["Auth"],
@@ -865,6 +989,32 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/admin/notifications/diagnostic": {
get: {
tags: ["Admin"],
summary: "Admin notification diagnostic snapshot",
description:
"Admin-only. Returns what the dispatcher would see when targeting the calling admin's account: registered devices (APNs tokens masked to prefix + suffix), per-channel enabled + configPresent flags, and recent push attempts (currently always `[]` pending the v1.4.48+ PushAttempt table). Cookie auth only — never Bearer.",
responses: {
"200": {
description: "Diagnostic snapshot.",
content: {
"application/json": {
schema: dataEnvelope(
adminDiagnosticData,
"AdminDiagnosticResponse",
),
},
},
},
"403": {
description: "Caller is not an admin.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/insights/comprehensive": {
get: {
tags: ["Insights"],
@@ -0,0 +1,146 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_NOTIFICATION_PREFS,
isMedicationReminderClientManaged,
notificationPrefsSchema,
parseNotificationPrefs,
resolveNotificationPrefs,
} from "../notification-prefs";
/**
* v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences blob.
*
* The cron at `src/lib/jobs/reminder-worker.ts` reads
* `user.notificationPrefs` and calls
* `isMedicationReminderClientManaged` to decide whether to skip the
* MEDICATION_REMINDER APNs dispatch. These tests pin the helper's
* contract so the cron skip is deterministic across schema drift.
*/
describe("notificationPrefsSchema", () => {
it("accepts an empty object (every key is optional)", () => {
const out = notificationPrefsSchema.parse({});
expect(out).toEqual({});
});
it("accepts the medication.clientManaged shape", () => {
const input = { medication: { clientManaged: true } };
const out = notificationPrefsSchema.parse(input);
expect(out).toEqual(input);
});
it("rejects a non-boolean clientManaged", () => {
const res = notificationPrefsSchema.safeParse({
medication: { clientManaged: "yes" },
});
expect(res.success).toBe(false);
});
});
describe("parseNotificationPrefs", () => {
it("returns documented defaults for null", () => {
expect(parseNotificationPrefs(null)).toEqual(DEFAULT_NOTIFICATION_PREFS);
});
it("returns documented defaults for undefined", () => {
expect(parseNotificationPrefs(undefined)).toEqual(
DEFAULT_NOTIFICATION_PREFS,
);
});
it("returns defaults when the persisted shape has drifted", () => {
expect(parseNotificationPrefs({ unknown: "shape" })).toEqual(
DEFAULT_NOTIFICATION_PREFS,
);
});
it("returns the persisted shape when valid", () => {
expect(
parseNotificationPrefs({ medication: { clientManaged: true } }),
).toEqual({ medication: { clientManaged: true } });
});
it("fills missing keys from the defaults", () => {
expect(parseNotificationPrefs({ medication: {} })).toEqual({
medication: { clientManaged: false },
});
});
});
describe("resolveNotificationPrefs (deep-merge)", () => {
it("layers the input over the persisted row", () => {
const out = resolveNotificationPrefs(
{ medication: { clientManaged: false } },
{ medication: { clientManaged: true } },
);
expect(out).toEqual({ medication: { clientManaged: true } });
});
it("preserves the persisted medication keys when the input only touches new sub-keys", () => {
// Future-proof: the route hands the merged shape to Prisma, so
// sibling keys inside `medication` must survive a partial PATCH.
const out = resolveNotificationPrefs(
{ medication: { clientManaged: true } },
{ medication: {} },
);
expect(out).toEqual({ medication: { clientManaged: true } });
});
it("falls back to defaults when the persisted row is null", () => {
const out = resolveNotificationPrefs(null, {
medication: { clientManaged: true },
});
expect(out).toEqual({ medication: { clientManaged: true } });
});
it("returns the defaults when neither side carries a value", () => {
const out = resolveNotificationPrefs(null, {});
expect(out).toEqual(DEFAULT_NOTIFICATION_PREFS);
});
});
describe("isMedicationReminderClientManaged — cron-skip gate", () => {
it("returns false for a null row (legacy server-managed default)", () => {
expect(isMedicationReminderClientManaged(null)).toBe(false);
});
it("returns false for undefined (Prisma findUnique miss)", () => {
expect(isMedicationReminderClientManaged(undefined)).toBe(false);
});
it("returns false when the user has not opted in", () => {
expect(
isMedicationReminderClientManaged({
medication: { clientManaged: false },
}),
).toBe(false);
});
it("returns true when the user has opted in via the iOS app", () => {
expect(
isMedicationReminderClientManaged({
medication: { clientManaged: true },
}),
).toBe(true);
});
it("returns false for a drifted persisted shape (forward-compat)", () => {
expect(
isMedicationReminderClientManaged({ notACategory: { foo: "bar" } }),
).toBe(false);
});
it("returns false when only sibling categories are present", () => {
// Future-proof: another (future) category set to true must NOT
// gate MEDICATION_REMINDER — only `medication.clientManaged`
// affects the medication cron.
expect(
isMedicationReminderClientManaged({
// Cast through unknown — the production runtime sees this as
// a JSONB value that bypasses the zod schema, but the helper
// must still resolve to the safe default.
otherCategory: { enabled: true },
} as unknown),
).toBe(false);
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences blob.
*
* Persisted on `User.notificationPrefs` as Postgres jsonb. Null = the
* documented defaults (every category off). The flag is opt-in: the
* iOS app flips it to `true` only after confirming local Spezi
* reminders are wired, so existing users without a working local
* notification path see zero regression.
*
* Shape (additive — future categories slot in next to `medication`):
* {
* "medication": { "clientManaged": boolean }
* }
*
* The schema is intentionally additive — every new category grows the
* schema with a new optional sub-object plus an entry in
* `DEFAULT_NOTIFICATION_PREFS`. Forward-compat: an unknown / drifted
* shape falls back to defaults rather than throwing.
*/
import { z } from "zod/v4";
/**
* Medication category schema. Currently a single boolean
* (`clientManaged`); the sub-object exists so future per-category
* knobs (e.g. quiet-hours overrides) can be added without breaking
* the persisted layout.
*/
const medicationPrefsSchema = z
.object({
clientManaged: z.boolean(),
})
.partial();
/**
* Top-level prefs schema. Every category is optional so a PATCH that
* only touches `medication` doesn't have to re-state future siblings.
* The route layer deep-merges the partial input over the persisted
* row before persisting so the column shape stays stable.
*/
export const notificationPrefsSchema = z
.object({
medication: medicationPrefsSchema,
})
.partial();
export type NotificationPrefsInput = z.infer<typeof notificationPrefsSchema>;
/**
* Fully-resolved prefs. Every key required so consumers (cron
* suppression, GET response) don't need to thread "is this present?"
* checks through their paths.
*/
export interface NotificationPrefs {
medication: {
clientManaged: boolean;
};
}
/**
* Safe defaults. Every category is off so the server reminders flow
* unchanged for any user the iOS app has not explicitly opted in.
* Marc's directive: zero regression for clients without working
* local reminders.
*/
export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
medication: {
clientManaged: false,
},
};
/**
* Parse the persisted `notificationPrefs` JSON blob into a typed
* `NotificationPrefs`, falling back to the defaults when the row is
* null OR the persisted shape has drifted (a forward-compat field
* rename, an admin-side hand-edit, etc.). Missing keys are filled
* from the defaults so callers always get a fully-resolved object.
*/
export function parseNotificationPrefs(raw: unknown): NotificationPrefs {
if (raw == null) return cloneDefaults();
const parsed = notificationPrefsSchema.safeParse(raw);
if (!parsed.success) return cloneDefaults();
return mergeOverDefaults(parsed.data);
}
/**
* Deep-merge a partial input (typically from a PATCH body) over the
* current persisted row, layering each supplied category's keys over
* the existing category sub-object so future siblings are not
* overwritten when the client only PATCHes `medication`.
*/
export function resolveNotificationPrefs(
current: unknown,
incoming: NotificationPrefsInput,
): NotificationPrefs {
const base = parseNotificationPrefs(current);
return {
medication: {
...base.medication,
...(incoming.medication ?? {}),
},
};
}
/**
* Cron-side helper. Returns `true` when the user has opted in to
* client-managed medication reminders and the server-side push
* should be suppressed. Tolerates a null / missing prefs row.
*/
export function isMedicationReminderClientManaged(raw: unknown): boolean {
return parseNotificationPrefs(raw).medication.clientManaged === true;
}
function cloneDefaults(): NotificationPrefs {
return {
medication: { ...DEFAULT_NOTIFICATION_PREFS.medication },
};
}
function mergeOverDefaults(
input: NotificationPrefsInput,
): NotificationPrefs {
return {
medication: {
...DEFAULT_NOTIFICATION_PREFS.medication,
...(input.medication ?? {}),
},
};
}