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
+16 -5
View File
@@ -76,10 +76,22 @@ export const users = () => call('GET', '/users');
// about anybody else, which is what the admin page's per-user view needs.
export const userTeams = (id) => call('GET', `/users/${id}/teams`);
// Where this user's pages go. An empty topic clears it, which the server
// treats as "no topic of their own" rather than an error.
export const setNotifyTarget = (id, ntfyTopic) =>
call('PUT', `/users/${id}/notify`, { body: { ntfy_topic: ntfyTopic } });
// Where this user's pages go: a list of channels, each a provider type and an
// address that provider understands. Only the owner is sent the addresses.
export const channels = (userID) => call('GET', `/users/${userID}/channels`);
export const addChannel = (userID, type, address) =>
call('POST', `/users/${userID}/channels`, { body: { type, address } });
// patch is { enabled } and/or { address }.
export const updateChannel = (userID, channelID, patch) =>
call('PATCH', `/users/${userID}/channels/${channelID}`, { body: patch });
export const removeChannel = (userID, channelID) =>
call('DELETE', `/users/${userID}/channels/${channelID}`);
export const testChannel = (userID, channelID) =>
call('POST', `/users/${userID}/channels/${channelID}/test`);
// Which kinds of channel this server can deliver: [{ type, label, address_label,
// address_hint }]. Empty when the server has nothing configured.
export const notifyProviders = () => call('GET', '/notify/providers');
// incidents
export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts });
@@ -119,7 +131,6 @@ export const createInvite = (id, role, maxUses) =>
call('POST', `/teams/${id}/invites`, { body: { role, max_uses: maxUses } });
export const revokeInvite = (id, inviteID) => call('DELETE', `/teams/${id}/invites/${inviteID}`);
export const testNotification = () => call('POST', '/me/notify/test');
export const dismissOnboarding = (dismissed) =>
call('PUT', '/me/onboarding', { body: { dismissed } });