Slice 6: notifications per §15

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 23:09:04 -07:00
parent 1b0968a9a2
commit f67d0aa0db
21 changed files with 3588 additions and 168 deletions
+97
View File
@@ -399,3 +399,100 @@ export async function streamChatTurn(slug, branch, threadId, { text, quote, mode
return { assistantId, userMsgId }
}
// ---------------------------------------------------------------------------
// §15 / Slice 6: notifications surface
// ---------------------------------------------------------------------------
export async function listNotifications({ unread, rfcSlug, category, actorUserId, bundled } = {}) {
const params = new URLSearchParams()
if (unread) params.set('unread', '1')
if (rfcSlug) params.set('rfc_slug', rfcSlug)
if (category) params.set('category', category)
if (actorUserId) params.set('actor_user_id', actorUserId)
if (bundled) params.set('bundled', '1')
const qs = params.toString()
return jsonOrThrow(await fetch(`/api/notifications${qs ? `?${qs}` : ''}`))
}
export async function markNotificationRead(id) {
return jsonOrThrow(await fetch(`/api/notifications/${id}/read`, { method: 'POST' }))
}
export async function markNotificationsReadByFilter(filter) {
return jsonOrThrow(await fetch('/api/notifications/read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(filter || {}),
}))
}
export async function listWatches() {
return jsonOrThrow(await fetch('/api/watches'))
}
export async function setWatch(slug, state) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/watch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state }),
}))
}
export async function getNotificationPreferences() {
return jsonOrThrow(await fetch('/api/users/me/notification-preferences'))
}
export async function setNotificationPreferences(prefs) {
return jsonOrThrow(await fetch('/api/users/me/notification-preferences', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(prefs),
}))
}
export async function getQuietHours() {
return jsonOrThrow(await fetch('/api/users/me/quiet-hours'))
}
export async function setQuietHours({ start, end, timezone } = {}) {
return jsonOrThrow(await fetch('/api/users/me/quiet-hours', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ start: start || null, end: end || null, timezone: timezone || null }),
}))
}
export async function muteUser(userId) {
return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'POST' }))
}
export async function unmuteUser(userId) {
return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'DELETE' }))
}
export async function advanceChatSeen(slug, branch, lastSeenMessageId) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/branches/${branch}/chat-seen`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ last_seen_message_id: lastSeenMessageId || null }),
}))
}
// SSE subscription helper. Returns a close() function. The handler
// surface mirrors §15.3: a snapshot event on open, then per-notification
// `notification` events, plus `read` events when another tab marks a row.
export function subscribeToNotifications({ onSnapshot, onNotification, onRead, onError } = {}) {
const source = new EventSource('/api/notifications/stream')
source.addEventListener('snapshot', e => {
try { onSnapshot?.(JSON.parse(e.data)) } catch {}
})
source.addEventListener('notification', e => {
try { onNotification?.(JSON.parse(e.data)) } catch {}
})
source.addEventListener('read', e => {
try { onRead?.(JSON.parse(e.data)) } catch {}
})
source.onerror = err => { onError?.(err) }
return () => source.close()
}