mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
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);
|
||||
});
|
||||
Reference in new issue
Block a user