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);
});