mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +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
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user