Files
HealthLog/src/lib/notifications/channel-state.ts
T
Marc-André BombeckandClaude Opus 4.7 cd3b890067 refactor(v1.4.15): apply simplify-review safe suggestions
Drop dead exports + misleading comments + unreachable defaults flagged
by phase-D simplify review (autonomous-yes items only):

- channel-state: drop unused TEST_CONSTANTS re-export
- retry-policy: drop unused RejectKind type alias
- achievements: drop misleading "fallback" comment on exhaustive switch
- notification-status-card: drop unreachable default in stateBadgeFor
- withings: export isWithingsRefreshReauthFailure + dedupe inline
  classifier in /api/withings/status route
- codex-slug-cache: drop test-only inspectCodexSlugCache helper; tests
  read getCachedCodexSlug() instead

Findings 1, 2, 8, 9 deferred to v1.4.16 (require product call).
Finding 11 is an explicit anti-finding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:14:43 +02:00

169 lines
5.2 KiB
TypeScript

/**
* Channel state mutations for the notification dispatcher (v1.4.15 Phase B3).
*
* Centralised so the dispatcher can call ONE function per outcome —
* making the "auto-disable on hard reject" + "exponential backoff on
* soft reject" rules unit-testable and audit-loggable in one place.
*/
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import {
nextRetryAt,
shouldAutoDisableAfterTransient,
type SendOutcome,
} from "@/lib/notifications/retry-policy";
import type { ChannelType } from "@/lib/notifications/types";
interface ChannelRef {
id: string;
userId: string;
type: ChannelType;
}
/**
* Record a successful send. Resets the failure counter and clears any
* pending retry-cooldown so the next dispatch goes out immediately.
*/
export async function recordChannelSuccess(channel: ChannelRef): Promise<void> {
await prisma.notificationChannel.update({
where: { id: channel.id },
data: {
lastSuccessAt: new Date(),
consecutiveFailures: 0,
nextRetryAt: null,
lastFailureReason: null,
},
});
}
/**
* Record a hard reject — the upstream told us this channel is dead.
* Disables the channel, captures the reason for the Settings UI, and
* writes an audit-log entry so the user can trace WHY their phone
* stopped getting reminders.
*/
export async function recordChannelHardReject(
channel: ChannelRef,
outcome: Pick<SendOutcome, "reason" | "message" | "statusCode">,
): Promise<void> {
const reason = outcome.reason ?? "hard_reject";
await prisma.notificationChannel.update({
where: { id: channel.id },
data: {
enabled: false,
disabledReason: reason,
lastFailureAt: new Date(),
lastFailureReason: reason,
// Counter is preserved so the UI can show "5 in a row before we
// gave up", but bump it so /api/notifications/status shows the
// hard-reject as a final failure event.
consecutiveFailures: { increment: 1 },
nextRetryAt: null,
},
});
await auditLog("notification.channel.auto_disabled", {
userId: channel.userId,
details: {
channelType: channel.type,
channelId: channel.id,
reason,
statusCode: outcome.statusCode ?? null,
kind: "hard_reject",
},
});
}
/**
* Record a transient failure. Increments the counter, schedules the
* next retry per the backoff schedule, and — once the counter crosses
* MAX_CONSECUTIVE_FAILURES — auto-disables the channel with reason
* `give_up_after_5_failures` and writes an audit entry.
*/
export async function recordChannelTransientFailure(
channel: ChannelRef,
outcome: Pick<SendOutcome, "reason" | "message" | "statusCode">,
now: Date = new Date(),
): Promise<{ autoDisabled: boolean; nextRetryAt: Date | null }> {
// Re-read current counter inside an atomic update to avoid races
// when multiple dispatch attempts collide.
const updated = await prisma.notificationChannel.update({
where: { id: channel.id },
data: {
consecutiveFailures: { increment: 1 },
lastFailureAt: now,
lastFailureReason: outcome.reason ?? "transient_failure",
},
select: { consecutiveFailures: true },
});
const failureCount = updated.consecutiveFailures;
if (shouldAutoDisableAfterTransient(failureCount)) {
await prisma.notificationChannel.update({
where: { id: channel.id },
data: {
enabled: false,
disabledReason: "give_up_after_5_failures",
nextRetryAt: null,
},
});
await auditLog("notification.channel.auto_disabled", {
userId: channel.userId,
details: {
channelType: channel.type,
channelId: channel.id,
reason: "give_up_after_5_failures",
consecutiveFailures: failureCount,
kind: "transient_give_up",
lastError: outcome.reason ?? null,
statusCode: outcome.statusCode ?? null,
},
});
return { autoDisabled: true, nextRetryAt: null };
}
const retryAt = nextRetryAt(failureCount, now);
await prisma.notificationChannel.update({
where: { id: channel.id },
data: { nextRetryAt: retryAt },
});
return { autoDisabled: false, nextRetryAt: retryAt };
}
/**
* Re-enable a channel that was previously auto-disabled. Clears the
* disabled-reason + counter and the cooldown so the next dispatch
* fires immediately. Audit-logs the manual override.
*/
export async function reEnableChannel(channel: ChannelRef): Promise<void> {
await prisma.notificationChannel.update({
where: { id: channel.id },
data: {
enabled: true,
disabledReason: null,
consecutiveFailures: 0,
nextRetryAt: null,
lastFailureReason: null,
},
});
await auditLog("notification.channel.re_enabled", {
userId: channel.userId,
details: {
channelType: channel.type,
channelId: channel.id,
},
});
}
/**
* Should the dispatcher attempt a send right now? Honours the cooldown
* window written by the backoff scheduler — if `nextRetryAt` is in the
* future, the channel is in "sending paused" mode and should be
* skipped to avoid hammering a flapping upstream.
*/
export function isChannelInCooldown(
channel: { nextRetryAt: Date | null },
now: Date = new Date(),
): boolean {
return channel.nextRetryAt !== null && channel.nextRetryAt > now;
}