Add notification channels and a provider abstraction

Users get a notification_channels table (replacing users.ntfy_topic); pages fan
out to every enabled channel through a notify.Provider registry, with ntfy as
the first provider. Fallbacks are typed {type, address}. Schema edited in place:
existing databases must be recreated.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
Niklas Ye
2026-10-12 21:00:40 +02:00
parent 6292ad6708
commit b114251f45
42 changed files with 2200 additions and 611 deletions
+38 -16
View File
@@ -213,7 +213,7 @@ function overview() {
owners === 1 ? '1 owner, full access.' : `${owners} owners, full access.`),
menuCard('/team/escalation', 'Escalation', levels || null,
levels
? `${levels} escalation level${levels === 1 ? '' : 's'}${data.escalation.fallback_topic ? ', then a fallback topic.' : '.'}`
? `${levels} escalation level${levels === 1 ? '' : 's'}${data.escalation.fallback?.address ? ', then a fallback.' : '.'}`
: 'No ladder — nobody but the first person is woken.'),
menuCard('/team/sources', 'Alert sources', keys || null,
keys
@@ -630,16 +630,16 @@ function escalationCard() {
if (levels.length) {
steps.push(h('li', { class: 'step' },
h('div', { class: 'step-rail' }, h('span', { class: `step-node end${esc.fallback_topic ? '' : ' warn'}` },
icon(esc.fallback_topic ? 'bell' : 'flag', 'icon'))),
h('div', { class: 'step-rail' }, h('span', { class: `step-node end${esc.fallback?.address ? '' : ' warn'}` },
icon(esc.fallback?.address ? 'bell' : 'flag', 'icon'))),
h('div', { class: 'step-body' },
h('div', { class: 'step-top' },
h('strong', { text: esc.fallback_topic ? 'Fallback topic' : 'End of the chain' })),
esc.fallback_topic
? h('div', { class: 'step-pages' }, h('code', { text: esc.fallback_topic }), ' is paged once.')
h('strong', { text: esc.fallback?.address ? 'Fallback' : 'End of the chain' })),
esc.fallback?.address
? h('div', { class: 'step-pages' }, `${esc.fallback.type}: `, h('code', { text: esc.fallback.address }), ' is paged once.')
: h('div', { class: 'callout', role: 'status' }, icon('alertTriangle', 'icon'),
h('div', {},
h('strong', { text: 'No fallback topic' }),
h('strong', { text: 'No fallback' }),
h('p', { text: 'After the last level the chain just ends and nobody else is woken.' }),
isOwner() && h('button', {
class: 'btn btn-sm', type: 'button', onclick: () => openLadderEditor({ focusFallback: true }),
@@ -682,11 +682,17 @@ function escalationCard() {
// it wholesale: the levels are an order, and patching one rung would leave the
// numbering of the others undecided. The draft lives in the sheet, so a poll of
// the page underneath cannot throw away half an edit.
function openLadderEditor({ focusFallback = false } = {}) {
async function openLadderEditor({ focusFallback = false } = {}) {
const esc = data.escalation || {};
// Which kinds of fallback this server can deliver. Failing to ask leaves the
// fallback uneditable rather than the whole ladder.
const providers = await api.notifyProviders().catch(() => []);
const draft = {
repeat_count: esc.repeat_count || 0,
fallback_topic: esc.fallback_topic || '',
fallback: {
type: esc.fallback?.type || providers[0]?.type || '',
address: esc.fallback?.address || '',
},
levels: (esc.levels || []).map((l) => ({
timeout_seconds: l.timeout_seconds,
targets: (l.targets || []).map((t) => ({ kind: t.kind, user_id: t.user_id })),
@@ -734,12 +740,23 @@ function openLadderEditor({ focusFallback = false } = {}) {
value: String(draft.repeat_count),
oninput: (e) => { draft.repeat_count = Number(e.target.value); },
});
const fallback = fallbackInput = h('input', {
type: 'text', value: draft.fallback_topic, placeholder: 'terdut-oncall-all',
oninput: (e) => { draft.fallback_topic = e.target.value; },
});
parts.push(h('label', {}, 'Repeat the whole ladder ', repeat, ' more times'));
parts.push(h('label', {}, 'Then page this ntfy topic once ', fallback));
if (providers.length) {
const fallbackKind = h('select', {
'aria-label': 'Kind of fallback',
onchange: (e) => { draft.fallback.type = e.target.value; },
}, ...providers.map((p) => h('option', {
value: p.type, text: p.label, selected: p.type === draft.fallback.type,
})));
const fallback = fallbackInput = h('input', {
type: 'text', value: draft.fallback.address, placeholder: 'terdut-oncall-all',
autocomplete: 'off', autocapitalize: 'none', spellcheck: false,
oninput: (e) => { draft.fallback.address = e.target.value; },
});
parts.push(h('label', {}, 'Then page ',
providers.length > 1 ? fallbackKind : providers[0].label, ' ', fallback, ' once'));
parts.push(h('p', { class: 'muted small', text: 'Leave it empty for no fallback. Whoever knows this address is paged in place of a person.' }));
}
clear(body, ...parts);
};
paint();
@@ -747,7 +764,12 @@ function openLadderEditor({ focusFallback = false } = {}) {
const save = h('button', { class: 'btn btn-primary', type: 'button', text: 'Save ladder' });
save.addEventListener('click', async () => {
try {
await api.setEscalation(teamID, draft);
// An address with no type, or a type with no address, is refused; an empty
// address means "no fallback", so send it as such.
const fallback = draft.fallback.address.trim()
? { type: draft.fallback.type, address: draft.fallback.address.trim() }
: { type: '', address: '' };
await api.setEscalation(teamID, { ...draft, fallback });
} catch (err) {
problem.textContent = err.message;
problem.hidden = false;
@@ -1164,7 +1186,7 @@ function openSwitchDetail(sw) {
const MEMBER_STATUS = {
oncall: { label: 'On call', hint: 'The rota has them today.' },
reachable: { label: 'Reachable', hint: 'Has an ntfy topic, so a page would reach them.' },
reachable: { label: 'Reachable', hint: 'Has a notification channel, so a page would reach them.' },
unpageable: { label: 'Can’t be paged', hint: 'A page to them would go nowhere.' },
};