mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
`AppSettings.telegramGlobal`, `.ntfyGlobal` and `.webPushGlobal` were read in two places, and neither of them was the one that matters: the setup-card visibility on Settings -> Integrations and an echo in the preferences payload. The dispatcher never looked at them. An operator who switched a channel off instance-wide lost the setup card and kept every existing delivery on that channel, indefinitely. Gate each of the three in the dispatcher, in the shape `isApiGloballyEnabled()` already uses for the twelve API routes. The read sits above the per-user channel row and below the per-event preference, it happens at most once per dispatch, and only when a switchable channel is actually in the cascade. A skip writes a `globally_disabled` row to the push-attempt ledger so the admin diagnostics name the cause, and does not count as an attempt, so `dispatched` keeps telling the truth. The account's own channel row is left enabled: the switch is the operator's, and flipping it back has to restore delivery without every user re-enabling anything. APNs, the generic webhook and email carry no instance-wide switch and are not gated here; they are turned off at their source instead. The unit test drives the real dispatcher over a stubbed settings row; the integration test writes the actual singleton and reads the ledger back out of Postgres, which is the layer that proves the column the admin route writes is the column the dispatcher reads.
140 lines
4.2 KiB
TypeScript
140 lines
4.2 KiB
TypeScript
import { prisma } from "@/lib/db";
|
|
import { getEvent } from "@/lib/logging/context";
|
|
|
|
export interface GlobalServiceAvailability {
|
|
telegramGlobal: boolean;
|
|
ntfyGlobal: boolean;
|
|
webPushGlobal: boolean;
|
|
apiGlobal: boolean;
|
|
}
|
|
|
|
export async function getGlobalServiceAvailability(): Promise<GlobalServiceAvailability> {
|
|
try {
|
|
const settings = await prisma.appSettings.findUnique({
|
|
where: { id: "singleton" },
|
|
select: {
|
|
telegramGlobal: true,
|
|
ntfyGlobal: true,
|
|
webPushGlobal: true,
|
|
apiGlobal: true,
|
|
},
|
|
});
|
|
|
|
return {
|
|
telegramGlobal: settings?.telegramGlobal ?? true,
|
|
ntfyGlobal: settings?.ntfyGlobal ?? true,
|
|
webPushGlobal: settings?.webPushGlobal ?? true,
|
|
apiGlobal: settings?.apiGlobal ?? true,
|
|
};
|
|
} catch {
|
|
getEvent()?.addWarning("Failed to load app settings, using defaults");
|
|
return {
|
|
telegramGlobal: true,
|
|
ntfyGlobal: true,
|
|
webPushGlobal: true,
|
|
apiGlobal: true,
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function isApiGloballyEnabled(): Promise<boolean> {
|
|
const settings = await getGlobalServiceAvailability();
|
|
return settings.apiGlobal;
|
|
}
|
|
|
|
/**
|
|
* The notification channels an operator can switch off instance-wide, mapped
|
|
* to the `AppSettings` column that carries the switch. A channel absent from
|
|
* this map (APNs, generic webhook, email) has no instance-wide switch and is
|
|
* never gated by it — the operator turns those off at their source (the APNs
|
|
* credentials, the SMTP env) instead.
|
|
*/
|
|
const GLOBAL_CHANNEL_SWITCHES = {
|
|
TELEGRAM: "telegramGlobal",
|
|
NTFY: "ntfyGlobal",
|
|
WEB_PUSH: "webPushGlobal",
|
|
} as const;
|
|
|
|
export type GloballySwitchableChannel = keyof typeof GLOBAL_CHANNEL_SWITCHES;
|
|
|
|
export function hasGlobalChannelSwitch(
|
|
channelType: string,
|
|
): channelType is GloballySwitchableChannel {
|
|
return Object.hasOwn(GLOBAL_CHANNEL_SWITCHES, channelType);
|
|
}
|
|
|
|
/**
|
|
* Resolve one channel's instance-wide switch out of an availability snapshot
|
|
* the caller already holds. Split from the async form so a dispatch that
|
|
* walks several channels reads the settings row once.
|
|
*/
|
|
export function resolveChannelGloballyEnabled(
|
|
availability: GlobalServiceAvailability,
|
|
channelType: string,
|
|
): boolean {
|
|
if (!hasGlobalChannelSwitch(channelType)) return true;
|
|
return availability[GLOBAL_CHANNEL_SWITCHES[channelType]];
|
|
}
|
|
|
|
/**
|
|
* Whether a notification channel may deliver on this instance. Same shape and
|
|
* same posture as `isApiGloballyEnabled()`: the switch is the operator's and
|
|
* sits above every per-user channel setting, and an unreadable settings row
|
|
* resolves to "enabled" so a storage blip never silently mutes an instance.
|
|
*/
|
|
export async function isChannelGloballyEnabled(
|
|
channelType: string,
|
|
): Promise<boolean> {
|
|
if (!hasGlobalChannelSwitch(channelType)) return true;
|
|
return resolveChannelGloballyEnabled(
|
|
await getGlobalServiceAvailability(),
|
|
channelType,
|
|
);
|
|
}
|
|
|
|
export interface ReminderThresholds {
|
|
lateMinutes: number;
|
|
missedMinutes: number;
|
|
}
|
|
|
|
export async function getReminderThresholds(): Promise<ReminderThresholds> {
|
|
try {
|
|
const settings = await prisma.appSettings.findUnique({
|
|
where: { id: "singleton" },
|
|
select: {
|
|
reminderLateMinutes: true,
|
|
reminderMissedMinutes: true,
|
|
},
|
|
});
|
|
|
|
return {
|
|
lateMinutes: settings?.reminderLateMinutes ?? 120,
|
|
missedMinutes: settings?.reminderMissedMinutes ?? 240,
|
|
};
|
|
} catch {
|
|
getEvent()?.addWarning(
|
|
"Failed to load reminder thresholds, using defaults",
|
|
);
|
|
return { lateMinutes: 120, missedMinutes: 240 };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The operator's configured default locale (`AppSettings.defaultLocale`), or
|
|
* `null` when the settings row is absent or unreadable. Validation against
|
|
* the shipped locale list is the caller's job (`resolveJobLocale`); this only
|
|
* reads the column.
|
|
*/
|
|
export async function getOperatorDefaultLocale(): Promise<string | null> {
|
|
try {
|
|
const settings = await prisma.appSettings.findUnique({
|
|
where: { id: "singleton" },
|
|
select: { defaultLocale: true },
|
|
});
|
|
return settings?.defaultLocale ?? null;
|
|
} catch {
|
|
getEvent()?.addWarning("Failed to load the operator default locale");
|
|
return null;
|
|
}
|
|
}
|