Compare commits

...
Author SHA1 Message Date
Claude Code 67209e38e0 fix(notifications): make Materializer.Start race-free single-shot
The previous `if m.unsub != nil` check-and-set was unsynchronized, so concurrent
Start calls could double-subscribe and leak an unsubscribe handle. Use a
sync.Once (mirroring stopOnce) so only the first call subscribes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:58:27 +02:00
Claude Code 35b8b9e44e fix(notifications): address PR #132 review feedback
Security / correctness:
- push: HandleToggleDevice now requires an explicit `enabled` (pointer bool) so
  `{}` can't silently disable a device.
- push: ClaimDue only claims deliveries for enabled devices with a token
  (push_enabled = true AND push_token IS NOT NULL).
- push: WebPushConfig.Configured() also requires Subject (avoids empty VAPID sub).
- notifications: SetPreferences persists the whole validated set in one
  transaction and returns a typed ErrInvalidCategory so the handler can 400 on
  invalid input vs 500 on a store failure (no more partial writes / mis-coded
  errors).
- requests: on a hard submission error after approval, publish request.approved
  so consumers aren't left with only request.submitted for an approved row
  (soft-fail behaviour unchanged).
- migration: add push_deliveries (user_id, profile_id) FK to user_profiles.

Robustness / hygiene:
- presence: log a warning when Redis is unavailable before falling back to the
  in-memory registry.
- router: mount push routes only when both PushStore and PushConfig are present
  (avoids nil-config panics).
- notifications: validate request_id before deriving a request DedupRef; make
  Materializer.Start single-shot.
- push: parseRetryAfter accepts the HTTP-date form of Retry-After.
- requests: populate Quality in published lifecycle payloads.
- docs: use repository-relative example paths in the push plans.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:17:32 +02:00
Claude Code be704c3a06 refactor(web): dedupe notification components from code review
- Extract a shared NotificationStripBar presentational component; AnnouncementBar
  and NotificationStrip become thin data-fetch wrappers.
- Extract partitionAnnouncementsFirst() (used by the inbox and bell) and a
  notificationTimeAgo() helper.
- Revert the shared timeAgo util to upstream (format relative time in the
  notifications module instead of parameterizing the shared util).
- Remove the no-op useInfiniteList wrapper in the inbox page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:32:24 +02:00
Claude Code dcce156d4a refactor(notifications): security, decoupling, and cleanup from code review
Security:
- WS: never deliver another user's notification/request events to admins
  (personal channels drop the admin cross-user monitoring bypass).
- Scope MarkRead/MarkAllRead/Dismiss to the active profile so one profile
  cannot read/dismiss a sibling profile's notifications.
- Bound announcement title/body and cap the request body; cap push token size.

Decoupling / footprint:
- Password-changed notification is now event-driven: the admin handler publishes
  a user.password_changed event (shared EventUserPasswordChanged constant) and a
  new system matcher materializes it, removing the notifications dependency from
  the admin handler.
- Revert a no-op control-flow rewrite in requests reconcileRequest.

Refactors:
- Promote presence.Registry.Refresh onto the interface (no-op for the memory
  registry) and drop the runtime type-assertion in the WS handler.
- Extract a deviceContext helper for the push register/revoke handlers.
- Thread the claim context into the push worker's outcome writes instead of
  context.Background().

Note: per-profile / child-safe live filtering stays client-side and reactive
(the active profile is not sent on the WS, which never reconnects on switch);
the REST inbox remains the authoritative child-safe boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:32:14 +02:00
Claude Code dcb8ce8afe refactor(push): send FCM via HTTP v1 instead of the firebase SDK
Replace firebase.google.com/go/v4 with direct FCM HTTP v1 REST calls
authenticated by golang.org/x/oauth2/google (already a dependency). This drops
firebase + google.golang.org/api and their large transitive tree
(cloud.google.com/go/*, OpenTelemetry, spiffe, envoyproxy) — go.sum shrinks by
~84 lines — with no behaviour change. Result classification (UNREGISTERED /
INVALID_ARGUMENT → dead; QUOTA_EXCEEDED / UNAVAILABLE → backoff) is preserved as
a pure, unit-tested helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:31:55 +02:00
Claude Code 93eb23d2ec fix(notifications): restore admin announcements list refresh on add/delete
Regression from switching the create/delete invalidation to notificationKeys.all
(["notifications"]). The admin announcements list is keyed under
["admin","announcements"], which that prefix does not match — so the admin page
stopped refreshing on publish/delete (only updating once the 2-min staleTime
lapsed). Invalidate both keys: the admin list and the user-facing notification
tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 20b75732ac fix(notifications): home bars always refetch on mount
Relying on cache invalidation alone left the home announcement bar showing
stale (empty) data when landing on the app after publishing — the cached query
could still be within staleTime and not refetch on remount. Give the home
AnnouncementBar and NotificationStrip staleTime:0 + refetchOnMount:"always" via
a new options arg on useNotificationsList, so navigating to "/" (logo / Back to
App) always pulls current announcements without a manual reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code c47c3dc621 fix(notifications): refresh notification views when an announcement is published/deleted
useCreateAnnouncement/useDeleteAnnouncement only invalidated the admin
announcements list, so after publishing an announcement the admin's own app
(home bar, inbox, bell, unread count) kept showing stale cached data and needed
a manual reload to surface it. Invalidate the whole notifications tree instead,
so the publisher's session refreshes without relying on the live WS event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 84704a073c fix(notifications): refresh per-profile data on profile switch and announcement push
Two stale-cache bugs made announcements look account-wide in the UI even though
each profile now has its own row:

- Switching profiles (selectProfile) swapped the active-profile header but never
  reset the React Query cache, so the new profile kept showing the previous
  profile's cached notifications (a dismiss on one profile appeared to clear it
  for all). Clear the cache on switch, mirroring logout.
- The live WS hook invalidated notification lists with refetchType:"none", so a
  newly pushed announcement only moved the unread badge — the home bar/inbox/bell
  didn't update until a manual reload. Refetch active lists when the arriving
  notification is an announcement (routine notifications keep the lighter
  mark-stale behavior to avoid disrupting a list being read).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code e2865c070b feat(notifications): pin announcements to the top of the inbox and bell
System announcements now sort above other notifications in the inbox "All" view
and the bell dropdown, so they're seen first. Sourced from the already-loaded
list (announcements are few and recent, so they're in the loaded window) via a
stable partition — no extra query, no pagination/cursor changes, and mark-on-view
and dismiss are unchanged. The dedicated "Announcements" tab is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code fd6797d907 fix(notifications): clear hidden dismiss X and add bell dropdown dismiss
The home announcement bar / notification strip dismiss X was covered on admin
accounts by the ServerActivity widget (fixed top-5 right-5, z-40), which floats
over the top-right of the page while a scan/stream is active. On the home page
for admins, shift the dismiss button left on large screens so it clears the
widget; the bar still spans full width.

Also add a per-row dismiss X to the bell dropdown (previously only the inbox and
home bars had one) — always visible since the bell is mobile-primary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code fede2a986f feat(notifications): fan out announcements per profile
Announcements were stored as one account-wide row (profile_id NULL) with a
shared read_at, so when any profile on a household account read or dismissed an
announcement it vanished for every profile on that account.

Fan out one row per profile instead — mirroring how content and request
notifications already work — with a profile-scoped dedup ref
(announcement-<id>-<profileID>) so the unique (user_id, type, dedup_ref) index
is satisfied across a single account's profiles. Each profile now reads and
dismisses independently. Accounts with no profiles fall back to a single
account-wide row.

- Add Store.ProfileIDsForUsers; replace DismissUnreadByTypeAndRef with
  DismissAnnouncementNotifications (matches both the per-profile refs and the
  legacy account-wide ref on delete).
- Update both fake stores and the announcement fan-out / delete tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code c2a33c7e35 fix(notifications): announcement audience list overlapping the Expires field
The shadcn/Radix ScrollArea collapses to near-zero layout height when given
only a max-height (its size-full viewport can't resolve against an indefinite
height), so the user/library selection list painted outside its box and the
Expires datetime field rendered on top of it. Replace both lists with a native
max-h-40 overflow-y-auto div, which reserves correct layout height and scrolls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 0506336625 feat(notifications): in-app only — remove push config UI and frontend push code
Notifications are delivered entirely over the WebSocket (bell, inbox, live
toasts, home announcement bar), so the push subsystem — which only matters for
OS banners when the app is closed — isn't needed for the current product.

- Remove the admin Push Notifications settings page + its nav entry.
- Remove the "Push on this device" control and device list from user settings.
- Delete the orphaned frontend push code: usePushDevice, queries/push,
  lib/push, lib/pushSw, useServiceWorkerNavigation, public/sw.js, and the
  service-worker registration in main.tsx.
- Drop the now-unused pushKeys query keys and PushDeviceInfo/VapidKeyPair types.
- Clarify the Announcements admin copy (header, field help, audience, expiry).

The internal/push Go backend is left in place but dormant (no UI calls it,
no provider configured), so push can be re-enabled later if mobile ships.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 19a0e62f0d feat(web): home announcement bar and notification strip
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code af575d7470 docs: home notification bars design spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 3b3e9ed7b9 fix(push): scope delivery claim to (profile,device); never prune on empty token
- Migration: add profile_id column to push_deliveries
- EnqueueDelivery: write d.ProfileID into the new profile_id column
- ClaimDue: SELECT pd.profile_id and JOIN user_devices on profile_id so a
  multi-profile device resolves to the exact token-bearing row, eliminating
  the tokenless-row collision and duplicate-row bugs
- Delivery struct: add ProfileID field to hold the scanned value
- worker handle(): guard against empty token before calling transport —
  skip with reason "empty token" so a data gap never triggers ResultDead
  → PruneTokenTx, which was silently wiping the real device token
- Tests: TestWorker_EmptyTokenSkippedNotDead (worker_test.go),
  TestEnqueuer_ProfileIDPropagated (enqueuer_test.go)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code d9ddfdb879 docs: notifications & push test checklist
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 3c0fd5f897 fix(web): navigate the SPA on notification click from the service worker
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code d2c8be9162 chore(web): lint and format cleanup for push client files
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 9a1b4ca3d7 fix(web): reflect configured status and clear-on-save for all push fields
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 4eb8c06023 feat(web): admin push provider config page
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code dd54e90894 fix(web): fall back to absolute date for old device registrations
When a device registration date is older than 30 days, timeAgo() returns null,
silently dropping the registration date from the UI. Add a fallback to display
an absolute date using toLocaleDateString(), matching the pattern used in
Notifications.tsx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code abb080c6ed feat(web): push on-this-device control and device list in settings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code f8b7cb0605 fix(web): guard all usePushDevice status writes by generation
enable() and disable() now each capture their own generation at the top
(after incrementing genRef) and gate every setStatus call on
`genRef.current === myGen`, matching the pattern already used by
refresh(). This closes the enable-clobbers-enable and disable-vs-enable
races the review flagged — only the most-recent in-flight call may write
a terminal status; superseded calls silently no-op.

Adds a test proving the guard: enable() stalls at the webpush-key fetch
while disable() runs to completion (gen=2); when the stale enable's
key call is then rejected, its catch set("off") is suppressed (gen=1 ≠
gen=2) and the final status remains "off" from disable, not clobbered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code ed1ab977b3 feat(web): usePushDevice subscription lifecycle hook
Implements the browser push subscription state machine (unsupported|blocked|off|on|pending) with enable(), disable(), and refresh() backed by serviceWorker.ready + pushManager. Uses a generation counter to prevent background refresh() from racing with user-initiated enable()/disable(). TDD: 5/5 tests green, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 2fe7c8683c feat(web): push query hooks and types
Add PushDeviceInfo and VapidKeyPair types, pushKeys factory, and
useWebPushPublicKey / usePushDevices / useTogglePushDevice /
usePushStatus / useGenerateVapidKeys / useSendTestPush hooks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 7d0154852d feat(web): web push service worker, registration, and helpers
Adds static sw.js (push/notificationclick/pushsubscriptionchange handlers),
tested pure logic in pushSw.ts (buildNotification/clickUrl), app-side helpers
in push.ts (urlBase64ToUint8Array/cacheVapidKey/pushSupported), and SW
registration in main.tsx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code e70bccceb7 feat(push): admin VAPID keygen and self-test endpoints
Add HandleGenerateVAPIDKeys (POST /admin/push/generate-vapid-keys) and
HandleSendTestPush (POST /admin/push/test) to PushHandler, with a new
pushNotifier interface and nil-tolerant third param on NewPushHandler.
Update both router.go call sites to pass deps.NotificationsService.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 615d35c131 docs: core push web client + admin config implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code bbc865ce53 docs: core push web client + admin config design spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 7649b30f77 fix(push): dedupe device list by device_id; document shared-device limitation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 088250f6ea chore(push): gofmt formatting 2026-06-11 09:45:49 +02:00
Claude Code ea049c3d1f chore(push): lint cleanup (errcheck, goconst, fcm credentials deprecation)
- transport_webpush.go: wrap defer resp.Body.Close() to silence errcheck
- transport_fcm.go: replace deprecated option.WithCredentialsJSON with
  google.CredentialsFromJSONWithType + option.WithCredentials; promotes
  golang.org/x/oauth2 from indirect to direct dependency
- worker_test.go: assert RunOnce errors instead of ignoring return values
  (errcheck); replace "apns"/"fcm" literals with TransportAPNs/TransportFCM
  and "sent"/"skipped"/"failed"/"dead" with Status* consts (goconst)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 0b8b67f6c4 feat(push): wire presence, store, config, enqueuer, transports, worker, task
Wire the full push subsystem in cmd/silo/main.go: presence registry
(Redis when cfg.Redis.URL set, memory fallback), push store/config/
enqueuer/transports/worker, deps.PushStore/PushConfig/PresenceRegistry.
Add PresenceRegistry to api.Dependencies and thread it into
NewEventsHandler in router.go (replaces nil placeholder from Task 4).
Register push.NewPushDeliveryTask in taskMgr and pass pushStore to
NewNotificationsRetentionTask.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code bb620a16ab feat(push): purge terminal deliveries in retention task
Extend NotificationsRetentionTask with an optional PushPurger dependency
that deletes terminal push_deliveries rows older than 7 days. Defines the
PushPurger interface in the tasks package; *push.Store satisfies it.
main.go call site updated to pass nil (real store wired in Task 15).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code f9c5c13a1a fix(push): require profile middleware on token register/revoke routes
PUT /notifications/push/device and DELETE /notifications/push/device call
apimw.GetProfileID which is only populated by RequireProfile middleware, but
the routes were mounted without it, causing every request to 400 with
"profile required". Apply r.With(apimw.RequireProfile) per-route to those
two endpoints only; the three user-scoped routes (devices list, toggle,
webpush-key) are intentionally left without it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 06d11fa062 feat(push): registration, device management, and status endpoints
Adds PushHandler with testable pushRegistry/pushConfigReader interfaces
and wires user push routes under /notifications/push and admin status
at /admin/push/status. All 16 handler tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code addeb47807 feat(push): fcm transport
Add FCMTransport backed by firebase.google.com/go/v4 using service-account
JSON credentials. Maps FCM errors to SendResult via fcmResult (Dead for
unregistered/invalid-argument, SoftFail+30s for quota/unavailable). Includes
unit tests for nil-success path and unconfigured soft-fail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code e7d7fd87d6 feat(push): apns transport
Token-based (.p8) APNs transport with pure apnsResult helper; unit-tested reason→result mapping and unconfigured soft-fail path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 84f027aa07 feat(push): web push transport
Implements WebPushTransport via github.com/SherClockHolmes/webpush-go v1.4.0.
Handles ResultDead on malformed subscriptions and 404/410 responses, soft-fail
with Retry-After on 429, and deduplication via Topic header (base-36 notification ID).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code afa6025a2a test(push): cover parked-transport, unconfigured-skip, intermediate backoff
Add doc comment to txOutcomes.run explaining atomic-batch semantics and
duplicate-send risk. Add three new tests: SkipsUnconfiguredTransport_NoCalls
(asserts Send never called), ParkedTransportFails (two deliveries in one
RunOnce, only one Send, both failed at now+2m), and IntermediateBackoff
(attempts 1→5m, attempts 2→30m via backoff schedule).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 6a5f61dd39 feat(push): delivery worker, store-backed queue, scheduled task
Implements the push delivery outcome state machine (Worker/RunOnce/handle),
store-backed queue adapter (storeQueue/txOutcomes over ClaimDue tx), and
PushDeliveryTask (15 s interval, drains up to 20 batches). TDD: 5 unit tests
cover sent, skip-present-user, skip-unconfigured, soft-fail backoff→dead,
and hard-fail dead-token paths — all passing, no DB required.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 0b96771964 feat(push): encrypted provider config and status
Register push provider credential keys in SensitiveSettingKeys so they
are encrypted at rest via EncryptedSettingsRepo. Add internal/push/Config
that reads per-transport credentials on demand and exposes a Status report
of configured booleans without leaking secrets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 497fd6ee71 feat(push): enqueuer and notifications hook
Adds PushEnqueuer interface as a seam in the notifications package (no
import-cycle: push imports notifications, not the other way around), and
implements Enqueuer in internal/push that fans out per-device delivery
rows with a configurable presence-grace not_before. Service.Create() calls
the enqueuer after a successful insert+publish; nil-safe so existing tests
are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code bde9d563ab feat(push): pgx store for devices and deliveries 2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code f25cbb27aa feat(push): domain types and transport interface 2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code bf9746ce32 feat(presence): track live connections in events websocket
Wire the presence Registry into EventsHandler so each WebSocket
connection registers/releases the user's live presence, and the
ping-loop refreshes the TTL for Redis-backed registries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code 1fa00b784f feat(presence): redis-overlay registry
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code 4d3f366dd2 feat(presence): in-process connection registry
Adds MemoryRegistry, a thread-safe refcount-based Registry that tracks
live realtime connections per user for suppressing push notifications.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code d5791ed541 feat(push): user_devices push columns and push_deliveries table 2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code 0a52b47e4e docs: core push notifications server implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code 418b7347ca docs: core push notifications design spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code 75b4e1c72f chore(web): prettier formatting for notifications files 2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code 6e817706f1 feat(web): admin announcements management page
Adds AdminAnnouncements page with table (title, audience summary, dates,
delete action), create dialog with audience mode selection (Everyone /
Specific users / Libraries), optional expiry, and validation gating;
wires Megaphone nav item in AdminSidebar and /admin/announcements route
in App.tsx. 6 TDD tests covering audience summaries, submit-disabled
states, payload shapes, delete confirm, and empty state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code 404f782ee2 feat(web): optimistic notification preference toggles 2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code ab7c081ecc feat(web): notification preference toggles in settings
Adds NotificationsSettings page with per-category toggle rows wired to
useNotificationPreferences / useSetNotificationPreferences. Admin alerts
row is hidden for non-admin users. Nav entry (Bell icon) placed between
Appearance and Library & Data sections. Route registered in App.tsx.

Tests: NotificationsSettings.test.tsx (jsdom / createRoot style matching
sibling settings pages) — 6 cases covering admin/non-admin row counts,
content toggle mutation payload, digest default-off state, loading
disabled state, and footer note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 01f828a11a fix(web): inbox relative-time copy and per-tab mark-on-view
- Parameterize timeAgo() with optional prefix (defaults to "Added")
  - Call with empty string to omit prefix: timeAgo(date, "")
  - Compose relative-time strings to avoid leading space when prefix=""
  - ItemCard callers use default; Notifications uses "" for cleaner UI

- Reset mark-on-view flag on category tab change
  - Each tab's first page now marks its unread rows independently
  - prevTabRef tracks activeTab; reset markedRef when it changes
  - Test: switch tab with unread item → markRead.mutate called again

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code a79eb85ca0 feat(web): notifications inbox page with TDD
Adds /notifications route and Notifications page with category tabs (All,
Requests, Content, Announcements, System, Admin-for-admins), mark-all-read
header button, mark-on-view (first-page unread ids, ref-guarded), per-row
dismiss button, Load-more pagination, and 7 passing vitest tests (red→green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 5711771c9e test(web): real zero-count assertion for sidebar badge
Replace module-level mock with hoisted mutable-state pattern to properly
test the zero-count branch. The test now sets mockUnreadCount.data = 0
and verifies badge absence (checking for the absence of "rounded-full px-0.5"
class), ensuring the condition {unreadCount > 0 && <badge />} is tested
structurally rather than just semantically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code cdbfcbe9af feat(web): notifications sidebar item with unread badge
Add a Notifications nav item after History in the Your Stuff section of
AppSidebar, with a floating badge showing the unread count from
useUnreadCount. Badge is capped at 99+ and hidden when count is zero.
The bell icon is wrapped in a relative span so the badge stays visible
in collapsed sidebar mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code ea92eb6c69 fix(web): notifications list staleTime; document bell flag constraint
Add staleTime configuration to useNotificationsList to prevent unnecessary refetches when the popover is opened multiple times. Update NotificationBell module comment to clarify that the bell must be a single instance — a second instance would conflict over the module flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code f7866a78d5 feat(web): notification bell with live badge in mobile header
Adds NotificationBell popover component with unread badge, per-item list,
mark-all-read, and dropdown-open toast suppression wired via isNotificationDropdownOpen();
mounts the bell and useNotificationsLive() once in Layout's mobile header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 59cfb01e71 feat(web): live notifications wiring (toasts, unread cache)
Adds useNotificationsLive hook (TDD): subscribes to the notifications WS
channel, toasts on notification.created for the active profile (with child
profile category filtering), bumps the unread-count cache optimistically,
and seeds the count from the channel snapshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 88f0228968 feat(web): notification query hooks
Add notificationKeys factory to keys.ts and create notifications.ts with
all nine TanStack Query hooks: infinite list, unread count (raw shape for
live cache writes), mark-read, dismiss, preferences CRUD, and admin
announcements CRUD.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 4be3f498cf feat(web): notification API types and event channel 2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code ac4a44c62c docs: core notifications web implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code a724ac9c55 chore: gofmt notifications handler tests 2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code abca92eec5 docs: record implementation-time findings in spec future work
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 30f59accd7 chore(notifications): lint cleanup in new files
- Fix gofmt issues in service_test.go and notifications_retention_test.go
- Remove useless type assertion in matcher_content.go (m.content already is ContentResolver)
- Replace "catalog.item.changed" string literal with TypeCatalogItemChanged constant
- Extract "metadata_updated" to const metadataUpdated in matcher_content.go
- Use TypeJobFailed constant in matcher_admin.go
- Extract "scan.failed" and "/admin/tasks" to consts eventScanFailed and adminTasksLink
- All tests pass; no golangci-lint errors in new matcher files

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code c24df8b3bd feat(notifications): wire store, service, materializer, tasks
Constructs notificationsStore/notificationsSvc/notificationsMaterializer
immediately after the deps literal in main.go (where pool and eventsHub
are both in scope), starts the materializer, and sets
deps.NotificationsService so the router registers all notifications
routes.

Calls SetEventsHub on the API-facing requestSvc (router.go) and the
task-manager reconcile service (main.go) so request lifecycle events
reach the materializer.

Registers NewNotificationsRetentionTask and NewNotificationsDigestTask
with the task manager alongside the other system tasks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 1c823aa507 fix(notifications): digest fires daily at fixed time
Changes digest trigger from interval-based (24h) to daily pattern firing at 08:00.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code c7079000a8 feat(notifications): digest and retention scheduled tasks
Add RunDailyDigest service method, DigestSubscribers/AddedItemCountForUser
store methods, and NotificationsRetentionTask/NotificationsDigestTask with
full TDD coverage including dedup, zero-count skip, and per-user error
continuation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 9ce999a61d feat(notifications): system notification on password change
Add CreateSystem helper to notifications.Service that swallows errors
so security notices never break the calling flow. Wire it into
AdminHandler.HandleUpdateUser so a system.password_changed notification
is sent to the target user whenever a password is included in an admin
update. Add service-level and handler-level tests covering both the
happy path and the store-failure/no-password cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code a638494540 feat(notifications): expose notifications and requests channels over WS with unread snapshot
Grants ChannelNotifications and ChannelRequests to all roles, adds
snapshotForChannel cases (unread_count via notifications service, {} for
requests), and wires NotificationsService into EventsHandler nil-tolerantly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code b5b3cccc18 fix(notifications): fail closed on child-profile resolution; cursor test
- childSafe() now returns true on ForUser or GetProfile errors, treating
  unresolvable profiles as children to prevent restricted categories from
  leaking on errors. Updated doc comment to clarify fail-closed behavior.
- Added TestNotificationsList_ResolverErrorFailsClosed to verify resolver
  errors trigger ChildSafe=true.
- Added TestNotificationsList_NextCursorSet to verify next_cursor is set
  correctly when limit=2 with exactly 2 items (next_cursor == ID of last item).
- Added cross-reference comments on limit clamps in both handlers and store
  files to ensure they stay in sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 2e9435d69e fix(notifications): resolve child profiles for category filtering
Add a profileResolver interface and childSafe helper to NotificationsHandler
so HandleList and HandleUnreadCount pass ChildSafe=true when the active profile
has IsChild=true. Provider is nil-tolerant (ChildSafe=false when absent).
Wire deps.UserStoreProvider in both router construction sites and add
TestNotificationsList_ChildProfileFiltered (plus adult/nil-resolver variants).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code f4fe1f7d94 feat(notifications): REST API for inbox, preferences, announcements
Adds service pass-throughs (List, UnreadCount, MarkRead, MarkAllRead,
Dismiss, GetPreferences, SetPreferences, ListAnnouncements), a
NotificationsHandler with cursor-paginated list, unread count, mark
read/all, dismiss, get/put preferences, and admin announcement CRUD,
wired into the chi router under /notifications (user, profile-scoped)
and /admin/announcements (RequireAdmin). TDD: 15 handler tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 1094ecb523 test(notifications): scan.failed coverage and admin alert assertions
Add TestAdminMatcher_ScanFailedNotifiesAdmins to verify scan.failed events
trigger admin notifications with proper event type, body content, link, and
dedup ref. Enhance TestAdminMatcher_JobFailedNotifiesAdmins with assertions on
Body containing job_type and job id, Link format, and DedupRef prefix. Add
clarifying comment on ChannelTasks entry explaining why it exists despite no
current task.failed event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 4f80010de5 feat(notifications): admin failure alerts with hourly throttle
Replaces the matchAdmin stub with a real implementation that fans job.failed
and scan.failed events out to all admin users, throttled to one alert per
(event, source-kind) per clock-hour via a day+hour dedup_ref bucket.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 229379e0a6 fix(notifications): day-bucket dedup, payload library authority, interest indexes
- Change hour-bucket dedup format to day-bucket (20060102) so refresh-driven
  repeats within a 48h window collapse while next-week episodes still notify.
- Use payload.LibraryID as authoritative library when non-zero; resolver's
  libraryID is now a fallback for events that omit the field.
- Pass raw seriesID as separate $4 parameter for the in-progress leg guard
  so a standalone item can never join episode rows on its own content id.
- New migration (20260610172401_notifications_interest_indexes) adds
  user_watchlist_media_item_idx and user_favorites_media_item_idx to support
  the InterestedProfiles access pattern efficiently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 954d26360f fix(notifications): gate content notifications on item recency
Extend ContentResolver.ItemContext to return the catalog row's created_at
and suppress content.added notifications when time.Since(createdAt) exceeds
the new 48-hour newItemWindow constant.  This prevents periodic library
refresh jobs (library_refresh.go, item_refresh.go) from re-notifying users
about years-old watchlist items on every metadata_updated event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 09f202f9f7 feat(notifications): content matcher with interest resolution and burst dedup
Implements ContentResolver interface (ItemContext + InterestedProfiles),
matchContent handler keyed on catalog.item.changed/metadata_updated with
hour-bucketed dedup_ref for series burst collapse, and ContentResolverRepo
backed by pgxpool joining media_items/episodes/user_watchlist/user_favorites/
user_watch_progress. No item_added change value exists in codebase; plan
assumption corrected to metadata_updated. In-progress leg retained using
user_watch_progress(updated_at). TDD: 4 new tests, red phase confirmed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 833ce25178 fix(notifications): scope notifications.send contract to plugins channel
Only envelopes on the ChannelPlugins channel are now processed by the
notifications.send matcher. Core code already calls Service.Create directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code a047519230 feat(notifications): notifications.send plugin contract
Implements matchSend in the Materializer so plugins can create
notifications by publishing a notifications.send event on ChannelPlugins.
Accepts bare and prefixed (plugin.<id>.*) event names; drops malformed
payloads and rejects announcements from plugin callers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 7c65c7b4dc fix(notifications): self-event guard, race-safe Stop, complete event mirror
- Add self-event guard in Materializer.handle() to prevent reacting to own notifications output
- Make Materializer.Stop() idempotent/race-safe using sync.Once
- Complete RequestEventData mirror by adding Quality field for payload completeness
- Add Title assertion in TestRequestMatcher_ApprovedCreatesNotification for coverage

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 98ed20444a feat(notifications): materializer with isolated matchers; request matcher
Adds Materializer (hub consumer with panic-isolated per-matcher dispatch) and
matchRequest (request.approved/declined/completed/failed → notifications rows).
Stubs matchSend, matchContent, matchAdmin for later tasks. TDD: tests written
first and confirmed failing before implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code e59343968d fix(requests): publish lifecycle events from aggregate request state
- Reconcile per-target loop: publish request.completed/request.failed
  based on updatedReq.Status/Outcome (aggregate), not the individual
  target's newStatus. Partial failure (one target fails while a sibling
  is still downloading) no longer fires a spurious request.failed event.
- Approve soft-fail: extract publishApprovalOutcome helper that emits
  request.failed when Outcome==OutcomeFailed, else request.approved.
  Approve and CreateRequest auto-approve both use it, so a submission
  failure (no backend configured, no targets returned) publishes
  request.failed instead of request.approved.
- Update TestApprove_PublishesRequestApproved to use a working router
  so it actually exercises the request.approved branch.
- Add TestReconcile_PartialTargetFailureDoesNotPublishFailed,
  TestApprove_SoftFailPublishesRequestFailed, and
  TestCreateRequest_AutoApprovePublishesApproved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 0add71fbd8 feat(requests): publish lifecycle events on ChannelRequests
Adds RequestEventPayload, SetEventsHub, and publishRequestEvent to the
requests service. Publishes request.submitted, request.approved,
request.declined, request.cancelled, request.completed, and
request.failed at each real transition site. Nil hub is a no-op.
Tests cover the approved publish path and the nil-hub no-panic contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 8d5e0fec85 fix(notifications): exact-match announcement dismissal, fan-out ordering, error wrapping
Replace prefix LIKE dismissal with exact dedup_ref match, resolve audience
before InsertAnnouncement to avoid orphaned rows on error, add proper error
wrapping throughout, and make fakeStore assign incrementing IDs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 423e2c02ae feat(notifications): announcement publish with audience fan-out
Add AllEnabledUserIDs to Store interface and Repository, implement
PublishAnnouncement with audience resolution (all/user_ids/library_ids),
deduplication, and expiry propagation; add DeleteAnnouncement that
dismisses unread notification rows on removal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 58fbc8f23c fix(notifications): WarnContext + digest opt-in tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 45995f659c feat(notifications): create service with prefs, dedup, hub publish
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 1756f54e21 fix(notifications): escape LIKE prefix, guard empty MarkRead, document store contracts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 5150fec3e3 feat(notifications): pgx store
Implements Store interface and Repository backed by pgxpool for the
notifications inbox: insert with dedup, list/unread-count with cursor
pagination, mark-read, dismiss, preferences upsert, announcements CRUD,
bulk-dismiss by type-ref, purge-old, and admin/library user-ID helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code bf880b292b feat(notifications): domain types 2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code db22615874 feat(events): add notifications and requests channels 2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code cfbd95e31c feat(notifications): add notifications, preferences, announcements tables 2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 70e8190348 docs: core notifications server implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 9626fd8f13 docs: core notifications system design spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
92 changed files with 17912 additions and 28 deletions
+41
View File
@@ -69,7 +69,9 @@ import (
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/pluginhost"
"github.com/Silo-Server/silo-server/internal/plugins"
"github.com/Silo-Server/silo-server/internal/presence"
"github.com/Silo-Server/silo-server/internal/proxy"
"github.com/Silo-Server/silo-server/internal/push"
"github.com/Silo-Server/silo-server/internal/ratelimit"
"github.com/Silo-Server/silo-server/internal/recommendations"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
@@ -496,6 +498,17 @@ func main() {
log.Fatalf("realtime hub start: %v", err)
}
eventsHub := realtimeHub.EventsHub()
var presenceRegistry presence.Registry
if cfg.Redis.URL != "" {
if rc, err := cache.NewRedisClient(cfg.Redis); err == nil {
presenceRegistry = presence.NewRedisRegistry(rc, 60*time.Second)
} else {
slog.Warn("presence: redis unavailable, falling back to in-memory registry", "error", err)
}
}
if presenceRegistry == nil {
presenceRegistry = presence.NewMemoryRegistry()
}
scanRegistry := evt.NewScanRegistry()
operationalWriter, opsRepo, opsPM := configureOperationalLogging(appCtx, pool, settingsRepo, cfg.Redis, logStreamHub, quietFilter, nodeID)
defer func() {
@@ -626,6 +639,30 @@ func main() {
configWatcher.RequestReload()
},
}
// Notifications subsystem: store → service → materializer.
// pool is always non-nil here (proxy/transcode returned early above).
notificationsStore := notifications.NewRepository(pool)
notificationsSvc := notifications.NewService(notificationsStore, eventsHub)
notificationsMaterializer := notifications.NewMaterializer(eventsHub, notificationsSvc, notifications.NewContentResolverRepo(pool))
if err := notificationsMaterializer.Start(appCtx); err != nil {
log.Fatalf("notifications materializer start: %v", err)
}
deps.NotificationsService = notificationsSvc
deps.PresenceRegistry = presenceRegistry
// Push subsystem: store → config → enqueuer → transports → worker.
pushStore := push.NewStore(pool)
pushConfig := push.NewConfig(settingsRepo)
notificationsSvc.SetPushEnqueuer(push.NewEnqueuer(pushStore, 30*time.Second, time.Now))
pushTransports := []push.Transport{
push.NewWebPushTransport(pushConfig.WebPush),
push.NewAPNsTransport(pushConfig.APNs),
push.NewFCMTransport(pushConfig.FCM),
}
pushWorker := push.NewWorker(push.NewStoreQueue(pushStore), presenceRegistry, pushTransports, time.Now)
deps.PushStore = pushStore
deps.PushConfig = pushConfig
audiobooksService := audiobooks.New(&audiobooksSettingsAdapter{repo: settingsRepo})
absCompatEnabled, err := audiobooksService.ABSCompatEnabled(appCtx)
if err != nil {
@@ -1605,6 +1642,7 @@ func main() {
),
)
requestReconcileSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB)))
requestReconcileSvc.SetEventsHub(eventsHub)
api.AttachRequestRouter(requestReconcileSvc, pluginService)
if userStoreProvider != nil {
reconcileResolver := access.NewResolver(
@@ -1663,6 +1701,9 @@ func main() {
taskMgr.Register(pluginTask)
}
}
taskMgr.Register(tasks.NewNotificationsRetentionTask(notificationsStore, pushStore))
taskMgr.Register(tasks.NewNotificationsDigestTask(notificationsSvc))
taskMgr.Register(push.NewPushDeliveryTask(pushWorker))
taskMgr.Start(appCtx)
defer taskMgr.Stop()
@@ -0,0 +1,100 @@
# Notifications & Push — Test Checklist
A repeatable regression pass for the core notifications + push subsystems. Organized by layer: automated (fast, run anytime), server pipeline (API, no browser), real browser (human-in-the-loop), and not-yet-testable (future work). Run the automated layer on every change; run the browser layer before shipping a user-facing change.
Specs/plans this exercises live under `docs/superpowers/specs|plans/2026-06-10-core-notifications-*` and `*-push-*`.
---
## 1. Automated tests (fast, no live instance)
Run from the repo root. `export GOWORK=off` first (or run inside the worktree).
```bash
# Go — materializer, matchers, worker state machine, request events, presence, handlers, tasks
GOWORK=off go test ./internal/notifications/ ./internal/push/ ./internal/presence/ \
./internal/requests/ ./internal/api/handlers/ ./internal/taskmanager/tasks/
# Web — bell, inbox, settings, admin page, usePushDevice, SW logic
cd web && pnpm exec vitest run
```
Expected: all green. The Go suites cover the outcome state machine (sent/skipped/failed-with-backoff/dead + dead-token prune), child-suppression SQL, the `notifications.send` plugin contract, request lifecycle publishing, and presence refcounting. The web suites cover the UI and the `usePushDevice` lifecycle/race-guard.
What this does NOT cover: real WebSocket delivery, real push banners, real migrations against production-shaped data. Use layers 2–3 for those.
---
## 2. Server pipeline (API — proves source → materializer → enqueuer → worker, no browser)
Needs an admin API key and the instance reachable (on silo-new the API is `http://localhost:8090`; create a temp key, delete it after — see the snippet at the bottom). Observe results in the `notifications` and `push_deliveries` tables.
For each notification **source**, trigger it and confirm a row lands (and, if push is configured + a device registered, a `push_deliveries` row):
- [ ] **Announcement** — `POST /api/v1/admin/announcements {title, body, audience:{all:true}}` → confirm a `notifications` row (category `announcement`) for each targeted user. ⚠️ `audience:{all:true}` fans out to **every** user's inbox — use `{user_ids:[<your id>]}` when testing on a live instance.
- [ ] **Request lifecycle** — submit a media request, then `POST /api/v1/requests/{id}/approve` (admin) → confirm a `request.approved` notification for the requester. Decline/complete/fail similarly produce `request.declined` / `request.completed` / `request.failed`.
- [ ] **System** — admin user-update that changes a password → confirm a `system.password_changed` notification for that user.
- [ ] **Content** — add an item to a profile's watchlist/favorites, then add/scan a matching library item → confirm a `content.added` notification for that profile (respects the 48h recency gate + per-day burst dedup).
- [ ] **Admin alert** — force a failed job/scan → confirm an `admin`-category notification fans out to admin users only (throttled 1/hour per source).
- [ ] **Digest** — opt a user into `content_digest`, then run the `notifications_digest` task (or wait for 08:00) → one digest notification if there were additions.
- [ ] **Push test** — `POST /api/v1/admin/push/test` → 202; a `system.test_push` notification + a `push_deliveries` row per the caller's registered push devices; the worker transitions it within ~30–45s (grace + tick). With a real subscription it sends; with a bogus one it fails-with-backoff (still proves the worker path).
Push config endpoints to verify:
- [ ] `GET /api/v1/admin/push/status` → `{apns,fcm,webpush}` booleans (flip after saving creds).
- [ ] `POST /api/v1/admin/push/generate-vapid-keys` → `{vapid_public, vapid_private}` (admin only).
- [ ] `GET /api/v1/notifications/push/webpush-key` → the configured public key (empty when unconfigured).
- [ ] `PUT /api/v1/notifications/push/device {transport:"webpush", token:<subscription JSON>}` with `X-Profile-Id` → 204 (400 without the profile header — the route is profile-gated).
- [ ] `GET /api/v1/notifications/push/devices` → lists the user's push devices; `PUT .../devices/{id} {enabled}` toggles.
---
## 3. Real browser (the only way to validate the UI + actual push banner)
Open the web app over a secure context (`localhost` or HTTPS — service workers require it). On silo-new that's `http://localhost:8090` (tunnel if remote).
In-app notifications:
- [ ] **Bell + badge** — top of the app; unread count badge.
- [ ] **Dropdown** — latest notifications, "Mark all read", deep links.
- [ ] **Inbox page** (`/notifications`) — category tabs, dismiss, mark-on-view, "Load more".
- [ ] **Live toast** — with the app open, trigger a notification (announcement to yourself, or the push-test) → a toast appears in real time (via the WebSocket), unless that category is muted.
- [ ] **Preferences** — Settings → Notifications → per-category mute toggles persist; "Daily digest" defaults off; the admin category shows only for admins.
Push (the headline manual test):
- [ ] **Admin config** — Admin → Settings → Push Notifications → "Generate keys", save the VAPID public/private + a `mailto:` subject → the Web Push badge flips to "Ready".
- [ ] **Enable on this device** — Settings → Notifications → "Push on this device" → grant the browser permission prompt → a device row appears in the list.
- [ ] **Background banner** — **close the app tab**, then `POST /admin/push/test` (or trigger any notification) → within ~30–45s an OS notification banner appears.
- [ ] **Click-through** — click the banner → the app opens/focuses and navigates to the notification's deep link.
- [ ] **Presence gating** — with the app tab open and focused, the same notification should NOT push a redundant banner (you saw it in-app); after closing the tab for >30s it does.
- [ ] **Per-device toggle / revoke** — disable the device in the list → no more banners; re-enable restores.
---
## 4. Not yet testable (tracked future work)
- **APNs (Apple) / FCM (Android)** — no mobile clients exist to register tokens, so these transports are only unit-tested (pure result-mappers). End-to-end needs silo-apple / silo-android (Phase 3 mobile).
- **Plugin `notifications.send`** — the ebook/audiobook request plugins don't publish it yet. Until those one-line `PublishEvent("notifications.send", …)` calls are added, plugin-sourced notifications can't be exercised end to end (the contract itself is unit-tested).
- **iOS Safari web push** — only works for an installed PWA, not a normal tab; the client degrades to an "unsupported" state otherwise.
---
## Appendix — temp admin key on silo-new (create, use, delete)
```bash
# create
KEY="sa_$(openssl rand -hex 32)"
docker exec silo-server-postgres-1 psql -U silo -d silo -tAc \
"INSERT INTO api_keys (user_id,label,api_key) VALUES (1,'manual-test','$KEY');"
echo "$KEY" # use as: Authorization: Bearer $KEY against http://localhost:8090/api/v1/...
# observe
docker exec silo-server-postgres-1 psql -U silo -d silo -tAc \
"SELECT id,category,type,title FROM notifications ORDER BY id DESC LIMIT 10;"
docker exec silo-server-postgres-1 psql -U silo -d silo -tAc \
"SELECT status,transport,attempts,left(last_error,40) FROM push_deliveries ORDER BY id DESC LIMIT 10;"
# clean up (delete the key + any test artifacts you created)
docker exec silo-server-postgres-1 psql -U silo -d silo -tAc \
"DELETE FROM api_keys WHERE label='manual-test';"
```
Always remove test notifications/devices you create on a real user's account; `push_deliveries` cascades when its `notifications` row is deleted.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,809 @@
# Core Notifications — Web UI Implementation Plan (Phase 2 of 3)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Notifications UI in the silo-server web app: unread badge (sidebar + mobile header bell with dropdown), live toasts from the events WebSocket, an inbox page, per-category preference toggles in settings, and an admin announcements page.
**Architecture:** A `notifications` query-hook module talks to the Phase-1 REST API; one live-wiring hook subscribes to the `notifications` WS channel via the existing `RealtimeEventsProvider` ref-counted `useEventChannel`, firing sonner toasts and patching React Query caches. UI components consume only the hooks. Desktop surface is an `AppSidebar` item with badge (no desktop top bar exists); mobile surface is a bell + popover in the `Layout` header.
**Tech Stack:** React 19, React Router v7, TanStack Query, sonner, lucide-react, shadcn/ui primitives (Popover, Switch, Dialog), Vitest + RTL.
**Spec:** `docs/superpowers/specs/2026-06-10-core-notifications-design.md` (Web UI section). Server API shipped in Phase 1.
Commands assume the repository root is the cwd; web commands run in `web/`. `pnpm` is the package manager.
---
## Server API contract (frozen, Phase 1)
The `api()` client prefixes `/api/v1` and adds auth + `X-Profile-Id` headers automatically.
```
GET /notifications?unread=1&category=&cursor=&limit= → {"items": Notification[], "next_cursor": number|null}
GET /notifications/unread-count → {"count": number}
POST /notifications/read {"ids":[...]} | {"all":true} → 204
POST /notifications/{id}/dismiss → 204 | 404
GET /notifications/preferences → {"preferences":[{"category","enabled"}]}
PUT /notifications/preferences {"preferences":[...]} → 204 | 400
GET /admin/announcements → {"items": Announcement[]}
POST /admin/announcements {title, body, audience:{all|user_ids|library_ids}, expires_at?} → 201
DELETE /admin/announcements/{id} → 204 | 404
```
Notification JSON: `{id, user_id, profile_id?, category, type, title, body, link?, item_id?, created_at, read_at?, expires_at?}`. Categories: `request | content | announcement | system | admin`; preference categories additionally `content_digest` (never a notification category). WS: channel `"notifications"`, event `"notification.created"`, `data` = the Notification; subscribe snapshot `data` = `{"unread_count": number}`.
## File map
| File | Action | Responsibility |
|---|---|---|
| `web/src/api/types.ts` | modify | `AppNotification`, `NotificationPreference`, `Announcement`, `AnnouncementAudience` types; add `"notifications"` to `EventChannel` |
| `web/src/hooks/queries/keys.ts` | modify | `notificationKeys` factory |
| `web/src/hooks/queries/notifications.ts` | create | all query/mutation hooks (user + admin) |
| `web/src/hooks/useNotificationsLive.ts` | create | WS subscription → toast + cache patching |
| `web/src/hooks/useNotificationsLive.test.ts` | create | live-hook tests |
| `web/src/components/NotificationBell.tsx` | create | badge + popover dropdown (mobile header) |
| `web/src/components/NotificationBell.test.tsx` | create | bell tests |
| `web/src/components/Layout.tsx` | modify | mount bell in mobile header; mount live hook |
| `web/src/components/AppSidebar.tsx` | modify | Notifications nav item with badge |
| `web/src/pages/Notifications.tsx` | create | inbox page |
| `web/src/pages/Notifications.test.tsx` | create | inbox tests |
| `web/src/pages/settings/NotificationsSettings.tsx` | create | preference toggles |
| `web/src/pages/SettingsLayout.tsx` | modify | nav section entry |
| `web/src/pages/AdminAnnouncements.tsx` | create | admin CRUD page |
| `web/src/components/AdminSidebar.tsx` | modify | announcements nav item |
| `web/src/App.tsx` | modify | routes: `/notifications`, admin `announcements`, settings `notifications` |
---
### Task 1: API types and event channel
**Files:**
- Modify: `web/src/api/types.ts` (EventChannel union ~line 2198; types appended near other domain types)
- [ ] **Step 1: Add the types**
Add `"notifications"` to the `EventChannel` union. Then add (named `AppNotification` to avoid colliding with the DOM `Notification` type — verify no existing `AppNotification` symbol first with grep):
```typescript
export type NotificationCategory = "request" | "content" | "announcement" | "system" | "admin";
export type NotificationPreferenceCategory = NotificationCategory | "content_digest";
export interface AppNotification {
id: number;
user_id: number;
profile_id?: string;
category: NotificationCategory;
type: string;
title: string;
body: string;
link?: string;
item_id?: string;
created_at: string;
read_at?: string;
expires_at?: string;
}
export interface NotificationListResponse {
items: AppNotification[] | null;
next_cursor: number | null;
}
export interface NotificationPreference {
category: NotificationPreferenceCategory;
enabled: boolean;
}
export interface AnnouncementAudience {
all?: boolean;
user_ids?: number[];
library_ids?: number[];
}
export interface Announcement {
id: number;
title: string;
body: string;
audience: AnnouncementAudience;
created_by?: number;
created_at: string;
expires_at?: string;
}
```
- [ ] **Step 2: Typecheck and commit**
```bash
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -5
git add src/api/types.ts && git commit -m "feat(web): notification API types and event channel"
```
(If the repo's typecheck command differs — check package.json scripts for `typecheck`/`build` — use that; `pnpm run build` also typechecks via `tsc -b`.)
---
### Task 2: Query keys and hooks
**Files:**
- Modify: `web/src/hooks/queries/keys.ts`
- Create: `web/src/hooks/queries/notifications.ts`
- [ ] **Step 1: Keys** — follow the existing factory style in keys.ts:
```typescript
export const notificationKeys = {
all: ["notifications"] as const,
list: (filters: { unread?: boolean; category?: string }) =>
[...notificationKeys.all, "list", filters] as const,
unreadCount: () => [...notificationKeys.all, "unread-count"] as const,
preferences: () => [...notificationKeys.all, "preferences"] as const,
announcements: () => ["admin", "announcements"] as const,
};
```
- [ ] **Step 2: Hooks** — `web/src/hooks/queries/notifications.ts`. Mirror the conventions in `web/src/hooks/queries/admin/users.ts` (api client import path, toast usage, invalidation):
```typescript
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { api } from "@/api/client";
import type {
Announcement,
AnnouncementAudience,
AppNotification,
NotificationListResponse,
NotificationPreference,
} from "@/api/types";
import { notificationKeys } from "@/hooks/queries/keys";
const PAGE_SIZE = 25;
export function useNotificationsList(filters: { unread?: boolean; category?: string } = {}) {
return useInfiniteQuery({
queryKey: notificationKeys.list(filters),
queryFn: async ({ pageParam }) => {
const params = new URLSearchParams();
params.set("limit", String(PAGE_SIZE));
if (filters.unread) params.set("unread", "1");
if (filters.category) params.set("category", filters.category);
if (pageParam) params.set("cursor", String(pageParam));
return api<NotificationListResponse>(`/notifications?${params.toString()}`);
},
initialPageParam: 0,
getNextPageParam: (last) => last.next_cursor ?? undefined,
});
}
export function useUnreadCount() {
return useQuery({
queryKey: notificationKeys.unreadCount(),
queryFn: () => api<{ count: number }>("/notifications/unread-count"),
select: (d) => d.count,
});
}
export function useMarkRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: { ids?: number[]; all?: boolean }) =>
api("/notifications/read", { method: "POST", body: JSON.stringify(body) }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: notificationKeys.all });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to mark read");
},
});
}
export function useDismissNotification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => api(`/notifications/${id}/dismiss`, { method: "POST" }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: notificationKeys.all });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to dismiss");
},
});
}
export function useNotificationPreferences() {
return useQuery({
queryKey: notificationKeys.preferences(),
queryFn: () => api<{ preferences: NotificationPreference[] }>("/notifications/preferences"),
select: (d) => d.preferences,
});
}
export function useSetNotificationPreferences() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (preferences: NotificationPreference[]) =>
api("/notifications/preferences", { method: "PUT", body: JSON.stringify({ preferences }) }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: notificationKeys.preferences() });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to save preferences");
},
});
}
export function useAnnouncements() {
return useQuery({
queryKey: notificationKeys.announcements(),
queryFn: () => api<{ items: Announcement[] | null }>("/admin/announcements"),
select: (d) => d.items ?? [],
});
}
export function useCreateAnnouncement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: {
title: string;
body: string;
audience: AnnouncementAudience;
expires_at?: string;
}) => api<Announcement>("/admin/announcements", { method: "POST", body: JSON.stringify(body) }),
onSuccess: () => {
toast.success("Announcement published");
void queryClient.invalidateQueries({ queryKey: notificationKeys.announcements() });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to publish");
},
});
}
export function useDeleteAnnouncement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => api(`/admin/announcements/${id}`, { method: "DELETE" }),
onSuccess: () => {
toast.success("Announcement deleted");
void queryClient.invalidateQueries({ queryKey: notificationKeys.announcements() });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to delete");
},
});
}
```
Check `api()`'s actual error behavior (does it throw on non-2xx with a message? read `web/src/api/client.ts:343-380`) and the import alias style (`@/` vs relative — match the queries directory's existing imports).
- [ ] **Step 3: Typecheck + commit**
```bash
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -5
git add src/hooks/queries/ && git commit -m "feat(web): notification query hooks"
```
---
### Task 3: Live wiring hook (WS → toasts + cache)
**Files:**
- Create: `web/src/hooks/useNotificationsLive.ts`
- Test: `web/src/hooks/useNotificationsLive.test.ts`
- [ ] **Step 1: Write the failing test**
Mock `useEventChannel` (from `@/components/realtimeEventsContext`) to capture handlers, mock `sonner`'s toast, wrap in a QueryClientProvider. Follow the mocking style of `web/src/components/RealtimeEventsProvider.test.tsx` (vi.mock + vi.hoisted).
```typescript
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const { capturedHandlers, toastMock } = vi.hoisted(() => ({
capturedHandlers: {} as { onEvent?: (m: unknown) => void; onSnapshot?: (m: unknown) => void },
toastMock: vi.fn(),
}));
vi.mock("@/components/realtimeEventsContext", () => ({
useEventChannel: (_channel: string, handlers: typeof capturedHandlers) => {
capturedHandlers.onEvent = handlers?.onEvent;
capturedHandlers.onSnapshot = handlers?.onSnapshot;
},
}));
vi.mock("sonner", () => ({ toast: toastMock }));
const profileState = vi.hoisted(() => ({ profile: { id: "p-1", is_child: false } }));
vi.mock("@/hooks/useAuth", () => ({ useAuth: () => profileState }));
import { useNotificationsLive } from "./useNotificationsLive";
import { notificationKeys } from "@/hooks/queries/keys";
function setup() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
queryClient.setQueryData(notificationKeys.unreadCount(), { count: 1 });
renderHook(() => useNotificationsLive(), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
});
return queryClient;
}
function frame(data: object) {
return { type: "event", channel: "notifications", event: "notification.created", data };
}
describe("useNotificationsLive", () => {
it("toasts and bumps unread count on notification.created", () => {
const qc = setup();
capturedHandlers.onEvent?.(
frame({ id: 5, category: "request", title: "Request approved", body: "Dune", profile_id: "p-1" }),
);
expect(toastMock).toHaveBeenCalledWith("Request approved", expect.anything());
expect(qc.getQueryData(notificationKeys.unreadCount())).toEqual({ count: 2 });
});
it("ignores frames for another profile", () => {
setup();
capturedHandlers.onEvent?.(
frame({ id: 6, category: "content", title: "New content", body: "", profile_id: "p-other" }),
);
expect(toastMock).not.toHaveBeenCalled();
});
it("suppresses restricted categories on child profiles", () => {
profileState.profile = { id: "p-1", is_child: true };
setup();
capturedHandlers.onEvent?.(
frame({ id: 7, category: "request", title: "Request approved", body: "", profile_id: undefined }),
);
expect(toastMock).not.toHaveBeenCalled();
profileState.profile = { id: "p-1", is_child: false };
});
it("seeds unread count from snapshot", () => {
const qc = setup();
capturedHandlers.onSnapshot?.({ type: "snapshot", channel: "notifications", data: { unread_count: 9 } });
expect(qc.getQueryData(notificationKeys.unreadCount())).toEqual({ count: 9 });
});
});
```
(The test file needs `.tsx` extension if JSX is used in the wrapper — name it `useNotificationsLive.test.tsx`.)
- [ ] **Step 2: Run to verify failure**
```bash
cd web && pnpm exec vitest run src/hooks/useNotificationsLive.test.tsx 2>&1 | tail -5
```
Expected: FAIL — module `./useNotificationsLive` not found.
- [ ] **Step 3: Implement**
```typescript
import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import type { AppNotification } from "@/api/types";
import { useEventChannel } from "@/components/realtimeEventsContext";
import { notificationKeys } from "@/hooks/queries/keys";
import { useAuth } from "@/hooks/useAuth";
const CHILD_HIDDEN_CATEGORIES = new Set(["request", "system", "admin"]);
/**
* Subscribes to the notifications WS channel: fires a toast for live
* notifications addressed to the active profile, keeps the unread-count
* cache current, and seeds it from the subscribe snapshot.
* Mount exactly once inside the authenticated layout.
*/
export function useNotificationsLive() {
const queryClient = useQueryClient();
const { profile } = useAuth();
const profileId = profile?.id;
const isChild = profile?.is_child ?? false;
const onEvent = useCallback(
(message: unknown) => {
const frame = message as { event?: string; data?: AppNotification };
if (frame.event !== "notification.created" || !frame.data) return;
const n = frame.data;
if (n.profile_id && n.profile_id !== profileId) return;
if (isChild && CHILD_HIDDEN_CATEGORIES.has(n.category)) return;
queryClient.setQueryData<{ count: number }>(notificationKeys.unreadCount(), (prev) => ({
count: (prev?.count ?? 0) + 1,
}));
void queryClient.invalidateQueries({
queryKey: [...notificationKeys.all, "list"],
refetchType: "none",
});
toast(n.title, { description: n.body });
},
[queryClient, profileId, isChild],
);
const onSnapshot = useCallback(
(message: unknown) => {
const frame = message as { data?: { unread_count?: number } };
if (typeof frame.data?.unread_count !== "number") return;
queryClient.setQueryData(notificationKeys.unreadCount(), { count: frame.data.unread_count });
},
[queryClient],
);
useEventChannel("notifications", { onEvent, onSnapshot });
}
```
NOTE on handler identity: `useEventChannel`'s effect re-subscribes when the handlers object changes — pass a stable object. Check how existing consumers handle this (grep `useEventChannel(` usages); if they memoize the handlers object, do the same: `useMemo(() => ({ onEvent, onSnapshot }), [onEvent, onSnapshot])`.
The toast call shape (`toast(title, { description })`) must match the test's `expect.anything()` second arg. If `useAuth` returns a different profile field shape, adapt (read `useAuth.tsx`).
- [ ] **Step 4: Run tests until green**
```bash
cd web && pnpm exec vitest run src/hooks/useNotificationsLive.test.tsx 2>&1 | tail -5
```
- [ ] **Step 5: Commit**
```bash
git add web/src/hooks/useNotificationsLive.* && git commit -m "feat(web): live notifications wiring (toasts, unread cache)"
```
---
### Task 4: NotificationBell component + Layout mount
**Files:**
- Create: `web/src/components/NotificationBell.tsx`
- Test: `web/src/components/NotificationBell.test.tsx`
- Modify: `web/src/components/Layout.tsx` (mobile header actions ~lines 151-168; also mount `useNotificationsLive()` once in the Layout component body)
- [ ] **Step 1: Failing test**
```typescript
// Mocks: @/hooks/queries/notifications (useUnreadCount → 3, useNotificationsList → two items,
// useMarkRead → mutate spy), react-router (MemoryRouter wrapper).
// Asserts: badge renders "3"; opening the popover (click bell button, role="button" name /notifications/i)
// shows both item titles; "Mark all read" calls markRead with {all:true};
// item with link renders an anchor/Link to that link.
```
Write it concretely following an existing component test's structure (see `web/src/components/AppSidebar.test.tsx` for provider wrapping conventions). Badge cap: 4+ digits render "99+" when count > 99 — include one assertion with count 120.
- [ ] **Step 2: Implement NotificationBell**
```tsx
import { Bell } from "lucide-react";
import { Link } from "react-router";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
useMarkRead,
useNotificationsList,
useUnreadCount,
} from "@/hooks/queries/notifications";
export function NotificationBell() {
const { data: count = 0 } = useUnreadCount();
const list = useNotificationsList({});
const markRead = useMarkRead();
const items = (list.data?.pages ?? []).flatMap((p) => p.items ?? []).slice(0, 10);
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Notifications" className="relative">
<Bell className="h-5 w-5" />
{count > 0 && (
<Badge className="absolute -top-1 -right-1 h-4 min-w-4 px-1 text-[10px]">
{count > 99 ? "99+" : count}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="text-sm font-medium">Notifications</span>
<Button
variant="ghost"
size="sm"
disabled={count === 0}
onClick={() => markRead.mutate({ all: true })}
>
Mark all read
</Button>
</div>
<ul className="max-h-96 list-none overflow-y-auto">
{items.length === 0 && (
<li className="text-muted-foreground px-3 py-6 text-center text-sm">
You're all caught up
</li>
)}
{items.map((n) => (
<li key={n.id} className={n.read_at ? "opacity-60" : ""}>
<Link to={n.link ?? "/notifications"} className="hover:bg-accent block px-3 py-2">
<div className="text-sm font-medium">{n.title}</div>
{n.body && <div className="text-muted-foreground truncate text-xs">{n.body}</div>}
</Link>
</li>
))}
</ul>
<div className="border-t px-3 py-2 text-center">
<Link to="/notifications" className="text-primary text-sm">
View all
</Link>
</div>
</PopoverContent>
</Popover>
);
}
```
Verify the ui primitives exist with these export names (`badge.tsx`, `button.tsx`, `popover.tsx` under `components/ui/`); adjust imports/props to the actual variants. Match `react-router` import name used elsewhere (the repo uses `react-router` v7 — check whether components import `Link` from `react-router` or `react-router-dom`).
Dropdown-open toast suppression (spec requirement): export a tiny module-level flag from NotificationBell —
```tsx
let bellOpenFlag = false;
export function isNotificationDropdownOpen() {
return bellOpenFlag;
}
// in the component: <Popover onOpenChange={(open) => { bellOpenFlag = open; }}>
```
and in `useNotificationsLive`'s onEvent, before toasting: `if (isNotificationDropdownOpen()) return;` (cache updates still run — only the toast is suppressed; the open dropdown shows the new item via the list invalidation). Add one live-hook test: set the flag via a popover-open simulation or export a test-only setter, assert no toast while open.
- [ ] **Step 3: Mount in Layout**
In `Layout.tsx`: call `useNotificationsLive()` at the top of the component (single mount point for the live hook), and render `<NotificationBell />` in the mobile header's right-side action cluster (next to the search icon button).
- [ ] **Step 4: Tests green, typecheck, commit**
```bash
cd web && pnpm exec vitest run src/components/NotificationBell.test.tsx 2>&1 | tail -5 && pnpm exec tsc -b --noEmit 2>&1 | head -3
git add src/components/NotificationBell.* src/components/Layout.tsx src/hooks/
git commit -m "feat(web): notification bell with live badge in mobile header"
```
---
### Task 5: Sidebar nav item with badge (desktop)
**Files:**
- Modify: `web/src/components/AppSidebar.tsx` (nav list — insert after the History item, ~line 645)
- Test: extend `web/src/components/AppSidebar.test.tsx`
- [ ] **Step 1: Add the nav item**
Follow the exact ViewTransitionLink pattern of the History item (active indicator span, icon sizing, `SidebarLabel`):
```tsx
<li>
<ViewTransitionLink
to="/notifications"
onClick={onNavigate}
className={navLinkClass("/notifications")}
aria-current={isActive("/notifications") ? "page" : undefined}
>
{isActive("/notifications") && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<Bell className="h-[18px] w-[18px] shrink-0" />
{unreadCount > 0 && (
<span className="bg-primary text-primary-foreground absolute top-1 left-6 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px]">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
<SidebarLabel show={showLabels}>Notifications</SidebarLabel>
</ViewTransitionLink>
</li>
```
`const { data: unreadCount = 0 } = useUnreadCount();` at the top of AppSidebar (import from `@/hooks/queries/notifications`; `Bell` from lucide-react — add to the existing lucide import list at line ~56). Badge positioning: verify visually against the collapsed-sidebar state (`showLabels` false) — the absolute chip must remain visible on the icon in both states; tweak offsets if the icon container differs.
- [ ] **Step 2: Test**
Extend AppSidebar.test.tsx: mock `@/hooks/queries/notifications` `useUnreadCount` to return `{ data: 5 }`; assert the rendered sidebar contains a link to `/notifications` showing "5". Follow the file's existing mock arrangement for other hooks.
- [ ] **Step 3: Run, typecheck, commit**
```bash
cd web && pnpm exec vitest run src/components/AppSidebar.test.tsx 2>&1 | tail -4
git add src/components/AppSidebar.* && git commit -m "feat(web): notifications sidebar item with unread badge"
```
---
### Task 6: Inbox page + route
**Files:**
- Create: `web/src/pages/Notifications.tsx`
- Test: `web/src/pages/Notifications.test.tsx`
- Modify: `web/src/App.tsx` (user routes block ~line 331+)
- [ ] **Step 1: Failing test**
Mock the notifications query hooks. Assertions:
- renders rows for items across categories with title/body/relative time;
- category filter tabs (All, Requests, Content, Announcements, System — admin tab only when `useAuth().user?.role === "admin"`) change the `category` filter passed to `useNotificationsList` (capture via mock);
- unread rows visually distinct (e.g. a `data-unread="true"` attribute — assert on it);
- per-row dismiss button calls `useDismissNotification().mutate(id)`;
- "Load more" appears when `hasNextPage` and calls `fetchNextPage`;
- on mount, visible unread ids are marked read: `useMarkRead().mutate({ ids: [...] })` (assert called once with the unread ids from page one).
- [ ] **Step 2: Implement**
Page structure (use the repo's page conventions — check an existing simple page like the History/Collections page for header/container classes):
```tsx
const CATEGORY_TABS: { key: string; label: string; adminOnly?: boolean }[] = [
{ key: "", label: "All" },
{ key: "request", label: "Requests" },
{ key: "content", label: "Content" },
{ key: "announcement", label: "Announcements" },
{ key: "system", label: "System" },
{ key: "admin", label: "Admin", adminOnly: true },
];
```
- `useState` for active category; `useNotificationsList({ category })` (omit when "");
- mark-on-view effect:
```tsx
const firstPage = list.data?.pages?.[0];
const markRead = useMarkRead();
const markedRef = useRef(false);
useEffect(() => {
if (markedRef.current || !firstPage) return;
const unreadIds = (firstPage.items ?? []).filter((n) => !n.read_at).map((n) => n.id);
if (unreadIds.length > 0) markRead.mutate({ ids: unreadIds });
markedRef.current = true;
}, [firstPage, markRead]);
```
- rows: title, body, `created_at` rendered with the repo's existing relative-time helper (grep `formatDistance\|relativeTime\|timeAgo` in web/src/lib — use what exists; if nothing, use `Intl.RelativeTimeFormat` inline helper in the page);
- dismiss button (X icon) per row; "Mark all read" header button; "Load more" via `fetchNextPage` when `hasNextPage`;
- empty state per tab.
- [ ] **Step 3: Route**
In `App.tsx`, register alongside the other authenticated user pages (match the guard used by e.g. the favorites/history page — likely inside the layout route with profile required):
```tsx
<Route path="/notifications" element={<Notifications />} />
```
(Lazy-load if neighboring pages are lazy — match the file's import pattern.)
- [ ] **Step 4: Tests, typecheck, commit**
```bash
cd web && pnpm exec vitest run src/pages/Notifications.test.tsx 2>&1 | tail -5 && pnpm exec tsc -b --noEmit 2>&1 | head -3
git add src/pages/Notifications.* src/App.tsx && git commit -m "feat(web): notifications inbox page"
```
---
### Task 7: Settings preferences section
**Files:**
- Create: `web/src/pages/settings/NotificationsSettings.tsx`
- Modify: `web/src/pages/SettingsLayout.tsx` (NAV_SECTIONS), `web/src/App.tsx` (settings child route)
- [ ] **Step 1: Implement the settings page**
Template: `web/src/pages/settings/PlaybackSettings.tsx` (SettingsGroup/SettingRow/Switch composition). Content:
```tsx
const PREF_LABELS: Record<string, { label: string; description: string; adminOnly?: boolean }> = {
request: { label: "Requests", description: "Updates when your requests are approved, declined, or ready" },
content: { label: "New content", description: "New arrivals matching your watchlist, favorites, and shows in progress" },
system: { label: "Account & security", description: "Password changes and account notices" },
admin: { label: "Admin alerts", description: "Failed jobs and scans (admins only)", adminOnly: true },
content_digest: { label: "Daily digest", description: "One daily summary of everything added to your libraries" },
};
```
- `useNotificationPreferences()` for current values; render a `Switch` per category in PREF_LABELS order, skipping `adminOnly` entries when `useAuth().user?.role !== "admin"`;
- on toggle: `useSetNotificationPreferences().mutate([{ category, enabled }])` (single-category PUT — server upserts per row);
- note under the group: "Announcements from your server admin can't be turned off."
- [ ] **Step 2: Register in SettingsLayout NAV_SECTIONS**
Add a "Notifications" section (icon `Bell`) pointing at path `notifications`, following the structure at SettingsLayout.tsx lines 39-100. Register the child route in App.tsx where other settings pages are routed (find `PlaybackSettings` route registration and mirror it).
- [ ] **Step 3: Test**
Extend `web/src/pages/SettingsLayout.test.tsx` if it asserts nav items (check); add `NotificationsSettings` rendering test only if sibling settings pages have tests (check `ls web/src/pages/settings/*.test.tsx`) — match local convention; if none exist, rely on the layout test + typecheck (note this in the commit message).
- [ ] **Step 4: Typecheck, test, commit**
```bash
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -3 && pnpm exec vitest run src/pages/SettingsLayout.test.tsx 2>&1 | tail -4
git add src/pages/settings/NotificationsSettings.tsx src/pages/SettingsLayout.tsx src/App.tsx
git commit -m "feat(web): notification preference toggles in settings"
```
---
### Task 8: Admin announcements page
**Files:**
- Create: `web/src/pages/AdminAnnouncements.tsx`
- Modify: `web/src/components/AdminSidebar.tsx` (nav items ~lines 81-134), `web/src/App.tsx` (admin routes ~lines 363-398)
- [ ] **Step 1: Implement the page**
Template: `web/src/pages/AdminUsers.tsx` for the table + dialog patterns (read it first; reuse its Table/Dialog/Button composition). Features:
- Table of announcements: title, audience summary ("Everyone" / "N users" / "Libraries: a, b"), created date, expires date, delete button with confirm dialog ("Deleting dismisses unread copies from user inboxes.").
- "New announcement" button → Dialog with: title (Input, required), body (Textarea), audience radio group (Everyone / Specific users / Libraries) — for "Specific users" a multi-select of users (check how AdminUsers fetches the user list — reuse that hook; if a multi-select primitive is missing, comma-separated ids input is NOT acceptable — use checkboxes in a scrollable list), for Libraries the same against the libraries list hook (grep `useLibraries` in hooks/queries) — optional `expires_at` datetime-local input.
- Submit via `useCreateAnnouncement()`; disable submit until title non-empty and a valid audience selection exists.
- [ ] **Step 2: Nav + route**
AdminSidebar: add item (icon `Megaphone` from lucide-react) labeled "Announcements" → `/admin/announcements`, following the existing items' structure. App.tsx admin routes: `<Route path="announcements" element={<AdminAnnouncements />} />`.
- [ ] **Step 3: Test**
`web/src/pages/AdminAnnouncements.test.tsx` — mock the three admin hooks; assert: table renders rows incl. audience summaries; create dialog validates (submit disabled with empty title); submitting with Everyone calls `useCreateAnnouncement().mutate` with `{audience: {all: true}, ...}`; delete confirm calls `useDeleteAnnouncement().mutate(id)`.
- [ ] **Step 4: Tests, typecheck, commit**
```bash
cd web && pnpm exec vitest run src/pages/AdminAnnouncements.test.tsx 2>&1 | tail -5 && pnpm exec tsc -b --noEmit 2>&1 | head -3
git add src/pages/AdminAnnouncements.* src/components/AdminSidebar.tsx src/App.tsx
git commit -m "feat(web): admin announcements management page"
```
---
### Task 9: Full verification
- [ ] **Step 1: Web checks**
```bash
cd web && pnpm run lint && pnpm run format:check && pnpm exec vitest run 2>&1 | tail -6 && pnpm run build 2>&1 | tail -4
```
All green; build also refreshes `web/dist` so the Go embed keeps working.
- [ ] **Step 2: Backend still green**
```bash
GOWORK=off go build ./... && GOWORK=off go test ./internal/api/handlers/ 2>&1 | tail -2
make verify-local-paths
```
- [ ] **Step 3: E2E smoke against the dev stack**
```bash
docker compose up -d postgres redis
GOWORK=off go build -o /tmp/silo-web-e2e ./cmd/silo && /tmp/silo-web-e2e &
```
In a browser (or report for manual check): log in, confirm the sidebar Notifications item + mobile bell render; create an announcement in /admin/announcements; verify toast fires live (WS) and badge increments without reload; open inbox, rows mark read, badge clears; toggle a preference off and verify it persists after reload.
- [ ] **Step 4: Commit anything outstanding; report**
---
## Self-review notes
- Desktop bell+dropdown from the spec adapted to a sidebar nav item + badge (no desktop top bar exists in this app); the dropdown ships on the mobile header bell. Spec intent (always-visible unread indicator + 1-click access) preserved.
- Toast child/profile filtering is client-side by necessity (WS frames are user-scoped; see spec Future work on claims enrichment) — `useNotificationsLive` suppresses other-profile and child-restricted frames.
- `content_digest` rows arrive as category `content` (server stores digest as content/content.digest) — the inbox Content tab includes them; the preferences page exposes the separate `content_digest` toggle. No UI filters by `content_digest` category (final server review noted this trap).
- Mark-on-view marks only page one's unread ids once per mount — deliberate; deeper pages mark via "Mark all read".
- Several steps include verify-greps (ui primitive names, Link import source, relative-time helper, settings test conventions, multi-select availability) — implementers must align with what exists rather than invent.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,838 @@
# Core Push — Web Client & Admin Config — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make push operable end-to-end from the browser: a Web Push client (service worker, subscribe, receive, per-device control, device list) plus an admin provider-config page (APNs/FCM/WebPush credentials, VAPID keygen, readiness), on top of the frozen push API.
**Architecture:** Five web units + two small Go endpoints. A static service worker receives pushes; a `usePushDevice` hook owns the browser subscription lifecycle; the existing notifications-settings page gains a push control + device list; a new admin page reuses the existing encrypted-settings write path for credentials. Two net-new server endpoints: VAPID keygen and a self-targeted test push.
**Tech Stack:** React 19, TanStack Query, sonner, the existing api client (auto device/profile headers), browser Push API / Service Worker / Notifications API, Go (chi), webpush-go (already a dep). Vitest + RTL.
**Spec:** `docs/superpowers/specs/2026-06-10-core-push-web-client-design.md`. The push registration API + delivery worker are frozen (Phase-3-server).
Commands assume the repository root is the cwd; web commands run in `web/` with `pnpm`. Go: `export GOWORK=off`.
## Frozen API the client calls
```
GET /api/v1/notifications/push/webpush-key → {vapid_public_key}
PUT /api/v1/notifications/push/device {transport, token} → 204 (needs X-Profile-Id; api client sends it)
DELETE /api/v1/notifications/push/device → 204
GET /api/v1/notifications/push/devices → {devices:[{device_id,name,platform,transport,push_enabled,registered_at}]}
PUT /api/v1/notifications/push/devices/{device_id} {enabled} → 204
GET /api/v1/admin/push/status → {apns,fcm,webpush: bool}
PUT /api/v1/admin/settings/{key} {value} → writes an encrypted push.* key (existing)
GET /api/v1/admin/settings/sensitive-status → {configured:[keys]} (existing)
```
Net-new this plan: `POST /api/v1/admin/push/generate-vapid-keys` and `POST /api/v1/admin/push/test`.
## File map
| File | Action | Responsibility |
|---|---|---|
| `internal/api/handlers/push.go` | modify | add `HandleGenerateVAPIDKeys`, `HandleSendTestPush`; add a `notifier` dep |
| `internal/api/router.go` | modify | register the two admin routes |
| `cmd/silo/main.go` | modify | pass the notifications service into the push handler |
| `web/public/sw.js` | create | service worker: push, notificationclick, pushsubscriptionchange |
| `web/src/lib/pushSw.ts` | create | pure SW logic (notification options, click URL) — tested; mirrored in sw.js |
| `web/src/lib/push.ts` | create | `urlBase64ToUint8Array`, VAPID-key cache helpers |
| `web/src/main.tsx` | modify | register `/sw.js` when supported |
| `web/src/api/types.ts` | modify | `PushDeviceInfo`, `VapidKeyPair` |
| `web/src/hooks/queries/push.ts` | create | query/mutation hooks (webpush-key, devices, toggle, register/revoke, admin keygen/test) |
| `web/src/hooks/usePushDevice.ts` | create | browser subscription lifecycle state machine |
| `web/src/hooks/usePushDevice.test.tsx` | create | hook tests |
| `web/src/pages/settings/NotificationsSettings.tsx` | modify | push control + device list |
| `web/src/pages/admin-settings/PushSettings.tsx` | create | three credential cards + keygen + test |
| `web/src/pages/admin-settings/AdminSettingsLayout.tsx` | modify | nav entry |
---
### Task 1: Server endpoints — VAPID keygen + test push
**Files:**
- Modify: `internal/api/handlers/push.go`, `internal/api/router.go`, `cmd/silo/main.go`
- Test: `internal/api/handlers/push_test.go`
- [ ] **Step 1: Add a notifier dep + the two handlers**
In `push.go`, add a narrow interface and a field on `PushHandler`:
```go
// pushNotifier creates a notification for the calling user (test push).
type pushNotifier interface {
CreateSystem(ctx context.Context, userID int, typ, title, body string)
}
```
Add `notifier pushNotifier` to `PushHandler` and a parameter to `NewPushHandler` (nil-tolerant — test-push 503s if absent). `*notifications.Service` satisfies `pushNotifier` (it has `CreateSystem(ctx, userID int, typ, title, body string)` from Phase-1).
Handlers:
```go
func (h *PushHandler) HandleGenerateVAPIDKeys(w http.ResponseWriter, r *http.Request) {
priv, pub, err := webpush.GenerateVAPIDKeys()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate keys")
return
}
writeJSON(w, http.StatusOK, map[string]string{"vapid_public": pub, "vapid_private": priv})
}
func (h *PushHandler) HandleSendTestPush(w http.ResponseWriter, r *http.Request) {
if h.notifier == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "notifications unavailable")
return
}
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "auth required")
return
}
h.notifier.CreateSystem(r.Context(), userID,
"system.test_push", "Test push",
"If you can see this on a device, push delivery is working.")
w.WriteHeader(http.StatusAccepted)
}
```
Import `webpush "github.com/SherClockHolmes/webpush-go"`. **Verify the `GenerateVAPIDKeys()` return order** against the installed version — webpush-go returns `(privateKey, publicKey string, err error)`. Confirm by reading the vendored signature or `go doc`; if it's `(public, private, err)` swap accordingly. Get this right — swapping the keys silently breaks push.
- [ ] **Step 2: Routes**
In `router.go`, in the admin push block (next to `r.Get("/push/status", ...)`, ~line 2295, behind `deps.PushStore != nil`):
```go
r.Post("/push/generate-vapid-keys", pushHandler.HandleGenerateVAPIDKeys)
r.Post("/push/test", pushHandler.HandleSendTestPush)
```
- [ ] **Step 3: Wire the notifier**
Update every `NewPushHandler(...)` call site (find with grep) to pass the notifications service. In `cmd/silo/main.go` and/or `router.go` where the push handler is constructed, add `deps.NotificationsService` (or the in-scope `notificationsSvc`) as the third arg. Build will tell you the sites.
- [ ] **Step 4: Tests**
Add to `push_test.go` (reuse the package's fakes / chi-request helper from Task-13 push tests):
```go
func TestPushHandleGenerateVAPIDKeys_ReturnsPair(t *testing.T) {
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, nil)
rr := httptest.NewRecorder()
h.HandleGenerateVAPIDKeys(rr, httptest.NewRequest(http.MethodPost, "/", nil))
if rr.Code != http.StatusOK {
t.Fatalf("code = %d", rr.Code)
}
var body map[string]string
_ = json.Unmarshal(rr.Body.Bytes(), &body)
if body["vapid_public"] == "" || body["vapid_private"] == "" || body["vapid_public"] == body["vapid_private"] {
t.Fatalf("bad key pair: %+v", body)
}
}
type fakeNotifier struct{ called bool; userID int; typ string }
func (f *fakeNotifier) CreateSystem(_ context.Context, userID int, typ, _, _ string) {
f.called = true
f.userID = userID
f.typ = typ
}
func TestPushHandleSendTestPush_CreatesForCaller(t *testing.T) {
n := &fakeNotifier{}
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, n)
r := httptest.NewRequest(http.MethodPost, "/", nil)
r = r.WithContext(apimw.SetUserID(r.Context(), 7)) // use the package's claims-injection helper if SetUserID differs
rr := httptest.NewRecorder()
h.HandleSendTestPush(rr, r)
if rr.Code != http.StatusAccepted || !n.called || n.userID != 7 || n.typ != "system.test_push" {
t.Fatalf("unexpected: code=%d notifier=%+v", rr.Code, n)
}
}
func TestPushHandleSendTestPush_NilNotifier503(t *testing.T) {
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, nil)
rr := httptest.NewRecorder()
h.HandleSendTestPush(rr, httptest.NewRequest(http.MethodPost, "/", nil))
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("code = %d", rr.Code)
}
}
```
Check how the existing push tests inject the user id into context (Task-13 used a claims/`apimw` helper — mirror it; `apimw.SetUserID` may not exist, use whatever the package's tests already use to set `GetUserID`).
- [ ] **Step 5: Build, test, commit**
```bash
GOWORK=off go build ./... && GOWORK=off go test ./internal/api/handlers/ -run TestPush -v 2>&1 | tail -8 && GOWORK=off go vet ./internal/api/handlers/
git add internal/api/handlers/push.go internal/api/handlers/push_test.go internal/api/router.go cmd/silo/main.go
git commit -m "feat(push): admin VAPID keygen and self-test endpoints"
```
---
### Task 2: Service worker + registration + helpers
**Files:**
- Create: `web/public/sw.js`, `web/src/lib/pushSw.ts`, `web/src/lib/push.ts`
- Test: `web/src/lib/pushSw.test.ts`, `web/src/lib/push.test.ts`
- Modify: `web/src/main.tsx`
- [ ] **Step 1: Pure SW logic (tested) — `web/src/lib/pushSw.ts`**
```ts
export interface PushPayload {
id?: number;
title?: string;
body?: string;
link?: string;
category?: string;
}
export interface SwNotification {
title: string;
options: { body: string; data: { link: string }; tag: string };
}
/** Maps a raw push payload to showNotification args; tolerant of partial/missing fields. */
export function buildNotification(payload: PushPayload | null): SwNotification {
const p = payload ?? {};
const link = p.link && p.link.length > 0 ? p.link : "/notifications";
const tag = p.id != null ? `n${p.id}` : "n";
return {
title: p.title && p.title.length > 0 ? p.title : "Silo",
options: { body: p.body ?? "", data: { link }, tag },
};
}
/** The URL a notification click should open/focus. */
export function clickUrl(data: unknown): string {
const d = (data ?? {}) as { link?: string };
return d.link && d.link.length > 0 ? d.link : "/notifications";
}
```
- [ ] **Step 2: Test it — `web/src/lib/pushSw.test.ts`**
```ts
import { describe, expect, it } from "vitest";
import { buildNotification, clickUrl } from "./pushSw";
describe("buildNotification", () => {
it("maps a full payload", () => {
const n = buildNotification({ id: 5, title: "Approved", body: "Dune", link: "/requests" });
expect(n.title).toBe("Approved");
expect(n.options).toEqual({ body: "Dune", data: { link: "/requests" }, tag: "n5" });
});
it("falls back on missing fields", () => {
const n = buildNotification(null);
expect(n.title).toBe("Silo");
expect(n.options.body).toBe("");
expect(n.options.data.link).toBe("/notifications");
expect(n.options.tag).toBe("n");
});
});
describe("clickUrl", () => {
it("uses link when present", () => expect(clickUrl({ link: "/items/9" })).toBe("/items/9"));
it("falls back to inbox", () => expect(clickUrl({})).toBe("/notifications"));
});
```
Run: `pnpm exec vitest run src/lib/pushSw.test.ts` → green.
- [ ] **Step 3: The static service worker — `web/public/sw.js`**
Self-contained (served statically, not bundled). Mirror the pure logic exactly (the canonical, tested source is `pushSw.ts`):
```js
// web/public/sw.js — static service worker for Web Push.
// NOTE: the buildNotification/clickUrl logic mirrors src/lib/pushSw.ts (the
// tested source of truth). Keep them in sync.
function buildNotification(payload) {
const p = payload || {};
const link = p.link && p.link.length > 0 ? p.link : "/notifications";
const tag = p.id != null ? "n" + p.id : "n";
return {
title: p.title && p.title.length > 0 ? p.title : "Silo",
options: { body: p.body || "", data: { link: link }, tag: tag },
};
}
function clickUrl(data) {
const d = data || {};
return d.link && d.link.length > 0 ? d.link : "/notifications";
}
self.addEventListener("push", (event) => {
let payload = null;
try {
payload = event.data ? event.data.json() : null;
} catch (e) {
payload = null;
}
const n = buildNotification(payload);
event.waitUntil(self.registration.showNotification(n.title, n.options));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = clickUrl(event.notification.data);
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((wins) => {
for (const c of wins) {
if ("focus" in c) {
c.postMessage({ type: "notification-click", link: url });
return c.focus();
}
}
return self.clients.openWindow(url);
}),
);
});
self.addEventListener("pushsubscriptionchange", (event) => {
// Best-effort re-subscribe using the cached VAPID key; the app re-registers
// the new subscription on next load if this fails.
event.waitUntil(
(async () => {
try {
const cache = await caches.open("silo-push");
const res = await cache.match("vapid-public-key");
if (!res) return;
const vapid = await res.text();
const sub = await self.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapid,
});
await fetch("/api/v1/notifications/push/device", {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ transport: "webpush", token: JSON.stringify(sub.toJSON()) }),
});
} catch (e) {
// swallow; app re-registers on next load
}
})(),
);
});
```
Note: the `pushsubscriptionchange` re-register relies on cookie auth (`credentials: include`) and the device/profile headers it can't set from the SW — it is genuinely best-effort and the app heals it on next load (documented in the spec). `applicationServerKey` accepts a base64url string in modern browsers; if the target browsers need a Uint8Array here, the app-side path (Task 4) converts it — the SW path is the fallback and may no-op on stricter browsers, which is acceptable.
- [ ] **Step 4: App-side helpers — `web/src/lib/push.ts`**
```ts
/** Convert a base64url VAPID public key to the Uint8Array applicationServerKey wants. */
export function urlBase64ToUint8Array(base64: string): Uint8Array {
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
const b64 = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(b64);
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
/** Cache the raw VAPID public key so the SW can re-subscribe without the app. */
export async function cacheVapidKey(base64: string): Promise<void> {
if (!("caches" in self)) return;
const cache = await caches.open("silo-push");
await cache.put("vapid-public-key", new Response(base64));
}
export function pushSupported(): boolean {
return (
typeof navigator !== "undefined" &&
"serviceWorker" in navigator &&
typeof window !== "undefined" &&
"PushManager" in window &&
"Notification" in window
);
}
```
- [ ] **Step 5: Test the converter — `web/src/lib/push.test.ts`**
```ts
import { describe, expect, it } from "vitest";
import { urlBase64ToUint8Array } from "./push";
describe("urlBase64ToUint8Array", () => {
it("decodes a url-safe base64 key to bytes", () => {
// "AQID" → [1,2,3]
expect(Array.from(urlBase64ToUint8Array("AQID"))).toEqual([1, 2, 3]);
});
it("handles url-safe chars and missing padding", () => {
const out = urlBase64ToUint8Array("a-_9"); // '-'→'+', '_'→'/'
expect(out.length).toBe(3);
});
});
```
- [ ] **Step 6: Register the SW — `web/src/main.tsx`**
After `createRoot(...).render(...)`, add:
```tsx
if ("serviceWorker" in navigator && "PushManager" in window) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(() => {
// registration failure is non-fatal; push just won't be available
});
});
}
```
- [ ] **Step 7: Run, build, commit**
```bash
cd web && pnpm exec vitest run src/lib/pushSw.test.ts src/lib/push.test.ts 2>&1 | tail -5 && pnpm exec tsc -b --noEmit 2>&1 | head -3
git add web/public/sw.js web/src/lib/pushSw.ts web/src/lib/pushSw.test.ts web/src/lib/push.ts web/src/lib/push.test.ts web/src/main.tsx
git commit -m "feat(web): web push service worker, registration, and helpers"
```
---
### Task 3: Push query hooks + types
**Files:**
- Modify: `web/src/api/types.ts`
- Create: `web/src/hooks/queries/push.ts`
- Modify: `web/src/hooks/queries/keys.ts` (add `pushKeys`)
- [ ] **Step 1: Types** — append to `types.ts`:
```ts
export interface PushDeviceInfo {
device_id: string;
name: string;
platform: string;
transport: string;
push_enabled: boolean;
registered_at?: string;
}
export interface VapidKeyPair {
vapid_public: string;
vapid_private: string;
}
```
- [ ] **Step 2: Keys** — in `keys.ts`:
```ts
export const pushKeys = {
all: ["push"] as const,
webpushKey: () => [...pushKeys.all, "webpush-key"] as const,
devices: () => [...pushKeys.all, "devices"] as const,
status: () => [...pushKeys.all, "status"] as const,
};
```
- [ ] **Step 3: Hooks — `web/src/hooks/queries/push.ts`**
Mirror `admin/users.ts` conventions (api import, toast, invalidation).
```ts
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { api } from "@/api/client";
import type { PushDeviceInfo, VapidKeyPair } from "@/api/types";
import { pushKeys } from "@/hooks/queries/keys";
export function useWebPushPublicKey() {
return useQuery({
queryKey: pushKeys.webpushKey(),
queryFn: () => api<{ vapid_public_key: string }>("/notifications/push/webpush-key"),
select: (d) => d.vapid_public_key,
staleTime: 5 * 60 * 1000,
});
}
export function usePushDevices() {
return useQuery({
queryKey: pushKeys.devices(),
queryFn: () => api<{ devices: PushDeviceInfo[] | null }>("/notifications/push/devices"),
select: (d) => d.devices ?? [],
});
}
export function useTogglePushDevice() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ deviceId, enabled }: { deviceId: string; enabled: boolean }) =>
api(`/notifications/push/devices/${deviceId}`, {
method: "PUT",
body: JSON.stringify({ enabled }),
}),
onSuccess: () => void qc.invalidateQueries({ queryKey: pushKeys.devices() }),
onError: (err) => toast.error(err instanceof Error ? err.message : "Failed to update device"),
});
}
export function usePushStatus() {
return useQuery({
queryKey: pushKeys.status(),
queryFn: () => api<{ apns: boolean; fcm: boolean; webpush: boolean }>("/admin/push/status"),
});
}
export function useGenerateVapidKeys() {
return useMutation({
mutationFn: () => api<VapidKeyPair>("/admin/push/generate-vapid-keys", { method: "POST" }),
onError: (err) => toast.error(err instanceof Error ? err.message : "Failed to generate keys"),
});
}
export function useSendTestPush() {
return useMutation({
mutationFn: () => api("/admin/push/test", { method: "POST" }),
onSuccess: () => toast.success("Test push queued — watch for a banner"),
onError: (err) => toast.error(err instanceof Error ? err.message : "Failed to send test"),
});
}
```
(Register/revoke device calls live inside `usePushDevice` (Task 4) since they're tied to the subscription, not standalone query hooks.)
- [ ] **Step 4: Typecheck + commit**
```bash
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -3
git add web/src/api/types.ts web/src/hooks/queries/keys.ts web/src/hooks/queries/push.ts
git commit -m "feat(web): push query hooks and types"
```
---
### Task 4: `usePushDevice` lifecycle hook
**Files:**
- Create: `web/src/hooks/usePushDevice.ts`, `web/src/hooks/usePushDevice.test.tsx`
- [ ] **Step 1: Failing tests**
Mock `navigator.serviceWorker`, `window.PushManager`, `Notification`, the api client, and the push helpers. Assert the status machine and the enable/disable flows.
```tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { apiMock, cacheVapidMock } = vi.hoisted(() => ({
apiMock: vi.fn(),
cacheVapidMock: vi.fn(),
}));
vi.mock("@/api/client", () => ({ api: apiMock }));
vi.mock("@/lib/push", async (orig) => ({
...(await orig<typeof import("@/lib/push")>()),
cacheVapidKey: cacheVapidMock,
}));
import { usePushDevice } from "./usePushDevice";
function wrap() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
}
let subscribeMock: ReturnType<typeof vi.fn>;
let getSubMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
apiMock.mockReset();
cacheVapidMock.mockReset();
subscribeMock = vi.fn(async () => ({ toJSON: () => ({ endpoint: "https://x", keys: {} }) }));
getSubMock = vi.fn(async () => null);
(globalThis as any).PushManager = function () {};
(navigator as any).serviceWorker = {
register: vi.fn(),
ready: Promise.resolve({ pushManager: { subscribe: subscribeMock, getSubscription: getSubMock } }),
};
(globalThis as any).Notification = { permission: "default", requestPermission: vi.fn(async () => "granted") };
});
afterEach(() => vi.restoreAllMocks());
describe("usePushDevice", () => {
it("reports unsupported when PushManager missing", () => {
delete (globalThis as any).PushManager;
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
expect(result.current.status).toBe("unsupported");
});
it("reports blocked when permission denied", () => {
(globalThis as any).Notification.permission = "denied";
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
expect(result.current.status).toBe("blocked");
});
it("enable(): permission → key → subscribe → PUT device, caches key, status on", async () => {
apiMock.mockImplementation((path: string) => {
if (path.includes("webpush-key")) return Promise.resolve({ vapid_public_key: "AQID" });
return Promise.resolve(undefined); // PUT device
});
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
await act(async () => { await result.current.enable(); });
expect(subscribeMock).toHaveBeenCalledOnce();
expect(cacheVapidMock).toHaveBeenCalledWith("AQID");
const putCall = apiMock.mock.calls.find((c) => c[0] === "/notifications/push/device");
expect(putCall?.[1]).toMatchObject({ method: "PUT" });
expect(JSON.parse(putCall![1].body)).toMatchObject({ transport: "webpush" });
await waitFor(() => expect(result.current.status).toBe("on"));
});
it("enable(): permission denied short-circuits, no subscribe", async () => {
(globalThis as any).Notification.requestPermission = vi.fn(async () => "denied");
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
await act(async () => { await result.current.enable(); });
expect(subscribeMock).not.toHaveBeenCalled();
expect(result.current.status).toBe("blocked");
});
it("disable(): unsubscribe + DELETE, status off", async () => {
const unsub = vi.fn(async () => true);
getSubMock.mockResolvedValue({ unsubscribe: unsub });
apiMock.mockResolvedValue(undefined);
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
await act(async () => { await result.current.disable(); });
expect(unsub).toHaveBeenCalled();
const del = apiMock.mock.calls.find((c) => c[0] === "/notifications/push/device" && c[1]?.method === "DELETE");
expect(del).toBeTruthy();
await waitFor(() => expect(result.current.status).toBe("off"));
});
});
```
- [ ] **Step 2: Run, verify fail**
```bash
cd web && pnpm exec vitest run src/hooks/usePushDevice.test.tsx 2>&1 | tail -6
```
- [ ] **Step 3: Implement — `web/src/hooks/usePushDevice.ts`**
```ts
import { useCallback, useEffect, useState } from "react";
import { api } from "@/api/client";
import { cacheVapidKey, pushSupported, urlBase64ToUint8Array } from "@/lib/push";
export type PushDeviceStatus = "unsupported" | "blocked" | "off" | "on" | "pending";
export function usePushDevice() {
const [status, setStatus] = useState<PushDeviceStatus>("pending");
const refresh = useCallback(async () => {
if (!pushSupported()) {
setStatus("unsupported");
return;
}
if (Notification.permission === "denied") {
setStatus("blocked");
return;
}
try {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
setStatus(sub ? "on" : "off");
} catch {
setStatus("off");
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const enable = useCallback(async () => {
if (!pushSupported()) {
setStatus("unsupported");
return;
}
setStatus("pending");
const perm = await Notification.requestPermission();
if (perm !== "granted") {
setStatus(perm === "denied" ? "blocked" : "off");
return;
}
try {
const reg = await navigator.serviceWorker.ready;
const { vapid_public_key } = await api<{ vapid_public_key: string }>(
"/notifications/push/webpush-key",
);
if (!vapid_public_key) {
setStatus("off");
return;
}
await cacheVapidKey(vapid_public_key);
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapid_public_key),
});
await api("/notifications/push/device", {
method: "PUT",
body: JSON.stringify({ transport: "webpush", token: JSON.stringify(sub.toJSON()) }),
});
setStatus("on");
} catch {
setStatus("off");
}
}, []);
const disable = useCallback(async () => {
setStatus("pending");
try {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (sub) await sub.unsubscribe();
} catch {
/* best-effort */
}
try {
await api("/notifications/push/device", { method: "DELETE" });
} catch {
/* server may already lack the row */
}
setStatus("off");
}, []);
return { status, enable, disable, refresh };
}
```
- [ ] **Step 4: Run green, typecheck, commit**
```bash
cd web && pnpm exec vitest run src/hooks/usePushDevice.test.tsx 2>&1 | tail -6 && pnpm exec tsc -b --noEmit 2>&1 | head -3
git add web/src/hooks/usePushDevice.ts web/src/hooks/usePushDevice.test.tsx
git commit -m "feat(web): usePushDevice subscription lifecycle hook"
```
---
### Task 5: NotificationsSettings — push control + device list
**Files:**
- Modify: `web/src/pages/settings/NotificationsSettings.tsx`
- Test: extend the page's test if one exists (check `ls web/src/pages/settings/*.test.tsx`)
- [ ] **Step 1: Add a push section**
Read the current `NotificationsSettings.tsx` (it uses `SettingsGroup`/`SettingRow`/`Switch`). Add, below the category-preference group, a new "Push notifications" group:
- A row driven by `usePushDevice()`:
- `status === "unsupported"` → static text "This browser doesn't support push notifications."
- `status === "blocked"` → text "Notifications are blocked for this site. Re-enable them in your browser's site settings."
- else → a `Switch` checked when `status === "on"`, disabled when `status === "pending"`, `onCheckedChange` calls `enable()` / `disable()`.
- Below it, a device list from `usePushDevices()`: one row per device (`name || device_id`, `platform`/`transport`, `registered_at`), each with a `Switch` bound to `useTogglePushDevice().mutate({ deviceId, enabled })` reflecting `push_enabled`. Empty state: "No devices registered for push yet."
Match the page's existing component composition and classes. Keep `content_digest` and the existing toggles untouched.
- [ ] **Step 2: Test (if the page is tested)**
If `NotificationsSettings.test.tsx` exists, mock `usePushDevice` (return `{status:"off", enable, disable}`) and `usePushDevices` (two devices); assert: toggling the device-level switch calls `useTogglePushDevice().mutate` with the device id; the main push switch calls `enable` when off. If no sibling test exists, rely on typecheck + the hook tests and note it in the commit.
- [ ] **Step 3: Typecheck, test, commit**
```bash
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -3 && pnpm exec vitest run src/pages/settings 2>&1 | tail -4
git add web/src/pages/settings/NotificationsSettings.tsx web/src/pages/settings/*.test.tsx
git commit -m "feat(web): push on-this-device control and device list in settings"
```
---
### Task 6: Admin PushSettings page + nav
**Files:**
- Create: `web/src/pages/admin-settings/PushSettings.tsx`
- Modify: `web/src/pages/admin-settings/AdminSettingsLayout.tsx`
- Test: `web/src/pages/admin-settings/PushSettings.test.tsx`
- [ ] **Step 1: Build the page**
Template: `IntegrationsSettings.tsx` (credential card = `CredentialStatus` badge + `SettingField` + `useUpdateServerSetting` + `useAdminSensitiveStatus`). Three cards. Header badge per card from `usePushStatus()`.
- **Web Push card** (leads): fields `push.webpush.vapid_public`, `push.webpush.vapid_private`, `push.webpush.subject`. A "Generate keys" button → `useGenerateVapidKeys().mutateAsync()` fills the public + private field state (local `useState`, not auto-saved). Subject field hint: `mailto:you@example.com`. Each field saves via `updateSetting.mutateAsync({ key, value })`. A "Send test push" button → `useSendTestPush().mutate()`.
- **APNs card**: `push.apns.p8_key` (textarea via `SettingField` multiline if supported, else a `<textarea>`), `push.apns.key_id`, `push.apns.team_id`, `push.apns.bundle_id`.
- **FCM card**: `push.fcm.service_account_json` (textarea).
`configured` per field from `new Set(sensitive?.configured ?? []).has(key)`. Page header + intro like IntegrationsSettings.
- [ ] **Step 2: Register nav**
In `AdminSettingsLayout.tsx`, add to the "Connections" group's `items` (import the component + a lucide icon, e.g. `BellRing`):
```tsx
{ id: "push", label: "Push Notifications", icon: BellRing, component: PushSettings },
```
(`id: "push"` is stable URL state.)
- [ ] **Step 3: Test — `PushSettings.test.tsx`**
Mock `useUpdateServerSetting` (capture mutate args), `useAdminSensitiveStatus` (some keys configured), `usePushStatus` (webpush true), `useGenerateVapidKeys` (returns a pair), `useSendTestPush`. Assert: three cards render; webpush badge shows ready; saving the VAPID public field calls `updateSetting.mutateAsync({ key: "push.webpush.vapid_public", value })`; "Generate keys" populates the public/private fields; "Send test push" calls the mutation. Follow the admin-settings test conventions (check a sibling `*.test.tsx` in that dir; if none, write a minimal RTL test with the mocks).
- [ ] **Step 4: Typecheck, test, build, commit**
```bash
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -3 && pnpm exec vitest run src/pages/admin-settings 2>&1 | tail -5
git add web/src/pages/admin-settings/PushSettings.tsx web/src/pages/admin-settings/PushSettings.test.tsx web/src/pages/admin-settings/AdminSettingsLayout.tsx
git commit -m "feat(web): admin push provider config page"
```
---
### Task 7: Full verification + manual smoke
- [ ] **Step 1: Web gates**
```bash
cd web && pnpm run lint 2>&1 | tail -3 && pnpm run format:check 2>&1 | tail -2 && pnpm exec vitest run 2>&1 | tail -5 && pnpm run build 2>&1 | tail -3
```
All green (lint warnings allowed, errors not). Fix any new-code lint/format issues (prettier --write the new files if format:check flags them). `build` also confirms `public/sw.js` is copied to `dist/` (Vite copies `public/` verbatim).
- [ ] **Step 2: Backend gates**
```bash
GOWORK=off go build ./... && GOWORK=off go test ./internal/api/handlers/ -run TestPush 2>&1 | tail -3 && make verify-local-paths
```
- [ ] **Step 3: Manual browser smoke (documented; requires a real browser over HTTPS or localhost)**
Service workers + Push require a secure context — `localhost` qualifies. Build the server with the web assets and run on a localhost port.
Commands assume the repository root is the cwd.
```bash
cd web && pnpm run build && cd ..
GOWORK=off go build -o ./bin/silo-pushweb ./cmd/silo && (PORT=18080 JF_PORT=18096 ./bin/silo-pushweb > ./silo-pushweb.log 2>&1 &)
```
Then in a Chromium browser at `http://localhost:18080`:
1. Log in as admin. Go to Admin → Settings → Push Notifications. Click "Generate keys", Save the VAPID public/private + a `mailto:` subject. Confirm the Web Push badge flips to "Ready" (and `GET /admin/push/status` → webpush:true).
2. Go to Settings → Notifications. Toggle "Push on this device" on → grant the browser permission prompt. Confirm a row appears in the device list and the toggle stays on.
3. Click "Send test push" on the admin page (or create an announcement). Within ~30–45s (grace + worker tick) a desktop notification banner appears. Close the app tab first to prove background delivery.
4. Click the banner → the app opens/focuses at the deep link.
5. Toggle push off → confirm `DELETE` and the device drops from the list.
Record the outcome. (If a real browser isn't available in the harness, run steps 1–2 via curl to confirm the API wiring — register a webpush device, confirm `push_deliveries` row + status — and note the visual steps as operator-verified.)
- [ ] **Step 4: Commit any verification fixes; report.** Work stays on `feat/core-notifications-server` (or a dedicated branch off it).
---
## Self-review notes (resolved during writing)
- `webpush.GenerateVAPIDKeys()` returns `(privateKey, publicKey, err)` in webpush-go — Task 1 Step 1 flags verifying the order so the keys aren't swapped.
- The SW's pure logic is duplicated between `public/sw.js` (untestable static file) and the tested `src/lib/pushSw.ts`; the duplication is small and intentional, with sw.js annotated as mirroring the tested source. The reviewer should diff the two functions for drift.
- `usePushDevice` owns register/revoke (tied to the live subscription); standalone query hooks (`push.ts`) cover read/list/toggle/admin. No overlap.
- Test push reuses `notifications.Service.CreateSystem` (Phase-1) → a real system notification → exercises the full enqueue→worker→transport pipeline to the caller's own devices. No bespoke push path.
- Admin credential writes use the existing `PUT /admin/settings/{key}` (encrypted, redacted) — no new write endpoint; only keygen + test are net-new server surface.
- iOS Safari web push (installed-PWA only) is handled by `pushSupported()` returning false in a normal tab → `unsupported` state; documented, not specially coded.
- Several steps embed verify-greps (NewPushHandler call sites, GenerateVAPIDKeys order, SettingField multiline support, sibling test conventions, the user-id context injection helper in push_test.go) — align with reality.
@@ -0,0 +1,188 @@
# Core Notifications System — Design
Date: 2026-06-10
Status: approved (design); implementation plan to follow
## Summary
A first-party, whole-system notification layer for Silo: persisted per-user notifications with an inbox, read state, per-category preferences, real-time toasts over the existing event WebSocket, and admin announcements. Ships across the server, web UI, and both mobile apps (in-app only — no APNs/FCM push in this project). External delivery (Discord, email, webhooks) remains the job of the existing `silo.notifications` plugin, which consumes the same events.
## Goals
- Persisted per-user notifications: inbox, unread badge, read/dismiss state.
- Real-time in-app delivery to connected clients via the existing `events.Hub` / `/api/v1/events/ws` pipeline.
- Five categories: `request`, `content`, `announcement`, `system`, `admin` (operational alerts for admin users), plus opt-in `content_digest`.
- Per-category user preferences (announcements not mutable).
- Admin announcements with audience targeting and expiry.
- A documented `notifications.send` event contract so any plugin can create in-app notifications (and, via the existing external plugin's subscription to the same event, external ones).
- Core request lifecycle events (`request.*`) — net-new publishing from `internal/requests`, consumed by notifications and available to the requests UI.
## Non-goals
- Mobile push (APNs/FCM) — future project; nothing here blocks it.
- Request-system unification (`request_router.v1`, migrating ebook/audiobook request plugins onto `media_requests`) — separate future project. When it lands, the book plugins' `notifications.send` publishes are replaced by core `request.*` events with no notifications schema/API change.
- Absorbing external delivery channels into core.
- Fine-grained per-show/per-author subscription rules (watchlist/favorites are the v1 signal).
## Architecture
New package `internal/notifications` in silo-server:
1. **Materializer** — in-process consumer registered on `events.Hub` at startup (same pattern as `internal/plugins/event_dispatcher.go`). Runs events through per-category matchers; writes `notifications` rows for each recipient.
2. **Store** — Postgres repository: insert (idempotent), list (cursor), unread count, mark read, dismiss, preferences CRUD.
3. **Notifier** — after insert, publishes `notification.created` on a new `ChannelNotifications` event channel, user-scoped like `ChannelUserState`. The WS handler (`internal/api/handlers/events_ws.go`) adds this channel to the per-user subscribe set; frames carry the full notification object. Subscribe-time snapshot carries unread count only.
4. **API handlers** — REST endpoints below.
5. **Service API** — `notifications.Create(...)` for direct in-process callers (system/security events).
Creation is DB-first and synchronous: a notification exists when the row commits. WS fan-out is best-effort; clients that miss frames reconcile on next fetch. No delivery queue or retry in core — "delivery" is reading your own table.
## Data model
One migration, three tables:
```sql
notifications (
id bigserial PRIMARY KEY,
user_id int NOT NULL REFERENCES users(id) ON DELETE CASCADE,
profile_id text NULL REFERENCES user_profiles(id) ON DELETE CASCADE, -- NULL = user-wide
category text NOT NULL, -- 'request' | 'content' | 'announcement' | 'system' | 'admin'
type text NOT NULL, -- e.g. 'request.approved', 'content.watchlist_episode'
title text NOT NULL,
body text NOT NULL,
link text NULL, -- in-app deep link
item_id bigint NULL, -- optional content reference (artwork, dedup)
source_event text NULL, -- originating event name (audit)
dedup_ref text NULL, -- idempotency: UNIQUE (user_id, type, dedup_ref) WHERE dedup_ref IS NOT NULL
created_at timestamptz NOT NULL DEFAULT now(),
read_at timestamptz NULL,
dismissed_at timestamptz NULL,
expires_at timestamptz NULL
);
-- index: (user_id, created_at DESC) partial WHERE dismissed_at IS NULL
-- partial unique index for dedup as above; inserts use ON CONFLICT DO NOTHING
notification_preferences (
user_id int NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category text NOT NULL, -- 'request' | 'content' | 'system' | 'admin' | 'content_digest'
enabled bool NOT NULL,
PRIMARY KEY (user_id, category)
);
-- absent row = enabled, except content_digest which defaults OFF
-- 'announcement' is not a valid category here (not mutable)
announcements (
id bigserial PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
audience jsonb NOT NULL, -- {"all":true} | {"user_ids":[...]} | {"library_ids":[...]}
created_by int REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NULL
);
```
Announcements fan out to `notifications` rows at publish time (audience resolved then), so the inbox is always a single-table query and read state is per-user.
Child profiles: rows with `category IN ('request','system','admin')` are filtered out of child-profile sessions server-side; content matching never targets a child profile unless the matched watchlist/favorite belongs to that profile.
Retention (nightly job, existing scheduler pattern): dismissed rows purged after 30 days; all rows after 90 days; unread rows from expired announcements at expiry.
## API
User endpoints (authenticated, profile from claims):
```
GET /api/v1/notifications ?unread=1&category=&cursor=&limit=
GET /api/v1/notifications/unread-count → {count}
POST /api/v1/notifications/read {ids:[...]} | {all:true}
POST /api/v1/notifications/{id}/dismiss
GET /api/v1/notifications/preferences
PUT /api/v1/notifications/preferences {category: enabled, ...}
```
List returns user-wide rows plus active-profile rows, newest first, cursor-paginated in the standard envelope. Child-profile filtering server-side.
Admin endpoints (RequireAdmin group):
```
GET /api/v1/admin/announcements
POST /api/v1/admin/announcements {title, body, audience, expires_at?}
DELETE /api/v1/admin/announcements/{id} -- also dismisses unread fanned-out rows
```
## Events
### New: core request lifecycle events
`internal/requests/service.go` and the reconciliation task publish on a new `ChannelRequests`:
- `request.submitted`, `request.approved`, `request.declined`, `request.completed`, `request.failed`
- Payload: `request_id, user_id, profile_id, title, media_type, quality, status`
`ChannelRequests` is added to WS subscribe sets (requesting user + admins), giving the requests UI live updates as a side benefit. This is the only change to the requests subsystem in this project.
### New: `notifications.send` plugin contract
The materializer consumes `notifications.send` events:
```json
{ "user_id": 1, "profile_id": null, "category": "request",
"type": "request.fulfilled", "title": "...", "body": "...",
"link": null, "dedup_ref": "ebook-req-123:fulfilled" }
```
Payloads are validated; malformed events are dropped with a logged warning. The external `silo.notifications` plugin already subscribes to this event name, so one publish reaches both in-app and external channels. The ebook/audiobook request plugins each add one `PublishEvent("notifications.send", ...)` at their acknowledged/fulfilled/failed transitions (their existing status events do not carry `user_id`; the plugins own that context).
### Matchers
- **request** — consumes core `request.*` events only. Dedup ref `(request_id, status)`.
- **content** — consumes `library.item_added` / `catalog.library.changed`. For each added item: resolve series/author; match per profile against watchlist, favorites (author/series link), and in-progress series (last play within 21 days); union, dedup, filter by library access. Burst guard: one notification per series per profile per hour (season imports collapse).
- **content_digest** — nightly job (not event-driven), opt-in: one row per user summarizing the previous day's additions to accessible libraries.
- **system** — not matched from the hub; emitted via `notifications.Create()` directly at the source (password change, new-device login, guest-pass redemption). Sites also publish their events as today; the explicit call means security notices never depend on matcher wiring.
- **announcement** — fan-out at publish time.
- **admin** — consumes existing `ChannelJobs` / `ChannelScans` / `ChannelTasks` failure events plus plugin-host crash/install failures; targets admin users; repeat-failure throttle (same source, max one per hour).
Preference checks run in the materializer before insert (cached per-user prefs lookup).
## Web UI
1. **Bell + dropdown** in the top bar: unread badge from `unread-count`, kept live by `ChannelNotifications` frames via the existing `RealtimeEventsProvider` socket. Dropdown: latest ~10, mark-all-read, deep links.
2. **Toasts** — WS notification frames fire sonner toasts; suppressed while the dropdown is open or when the frame targets another profile.
3. **Inbox page** (`/notifications`) — paginated list, category filter tabs, dismiss, mark-read on view.
4. **Settings** — per-category toggles (`content_digest` default off; `admin` visible to admins only). Admin: Announcements page (list/compose/expire) under admin settings.
## Mobile (in-app, both platforms)
Same three features in silo-apple and silo-android against the frozen REST API: inbox screen (list, mark-read, dismiss), unread badge on existing navigation, preferences screen. Transport: REST refresh of unread-count + first page on app foreground and pull-to-refresh. No WS subscription in v1 (current app sockets are playback-scoped; no background delivery exists without push anyway). TV variants: badge + read-only inbox, no preferences editing.
## Error handling
- Matcher isolation: a panic/error in one matcher logs and skips that event for that matcher only; never blocks the hub or other matchers (same containment as the plugin event dispatcher).
- Idempotent inserts via the dedup partial unique index + `ON CONFLICT DO NOTHING`; replayed events cannot double-notify.
- WS fan-out is fire-and-forget; DB rows are the source of truth.
- `notifications.send` validation failures: drop + warn (plugin bugs must not break core paths).
## Testing
- Unit tests per matcher with synthetic events; content-matcher edge cases: child profiles, library access, burst guard, dedup.
- Store tests against Postgres (repo convention).
- One integration test: event → materializer → row → WS frame through a real hub.
- API handler tests: auth, profile scoping, child filtering, admin gates.
- Web: component tests for bell/inbox; extend the RealtimeEventsProvider test pattern for the new channel.
## Implementation sequencing
1. **Server** — migration, store, materializer + matchers, request event publishing, API, WS channel, retention job.
2. **Web** — bell, toasts, inbox, settings, announcements admin.
3. **Mobile** — apple + android in-app surfaces against the frozen API.
4. (Separate projects, later: request unification via `request_router.v1`; mobile push.)
## Future work
- Request unification: core `request.*` events replace the book plugins' `notifications.send` publishes; notifications unchanged.
- Push: APNs/FCM device registry; the notifier gains a push fan-out alongside WS.
- Digest scheduling preferences (weekly, time-of-day).
- Profile claims enrichment: embed `is_child` in profile-scoped auth claims. Fixes two implementation-time findings: the WS pipeline cannot child-filter (snapshot and live frames use client-asserted profile identity; REST remains authoritative and fails closed — see the snapshot comment in `internal/api/handlers/events_ws.go`), and the REST `childSafe` check costs one profile lookup per request.
- Admin-alert coverage: only `job.failed` and `scan.failed` events exist today. `task.failed` and plugin-host crash events are not published by their subsystems yet; when they are, the admin matcher needs only a map entry.
- Dedicated `item_added` catalog event: the content matcher keys on `catalog.item.changed` (`metadata_updated`) plus a 48-hour `media_items.created_at` recency gate, because the scanner publishes no per-item add event. An explicit ingest-time event would remove the proxy heuristic.
- New-device login notifications: deferred from v1 (no device-fingerprint store exists); password-change notifications shipped.
@@ -0,0 +1,173 @@
# Core Push Notifications — Design
Date: 2026-06-10
Status: approved (design); implementation plan to follow
## Summary
A server-side push subsystem that mirrors the in-app notifications created in Phase 1 out to registered devices via APNs (Apple), FCM (Android), and Web Push (browsers). Delivery is durable (a DB-backed queue with a retry worker) and presence-gated (a notification only pushes to devices whose user has no live WebSocket connection after a short grace delay). This is a core-server project: the mobile clients have no push code yet, so the deliverable is the registry, queue, worker, transports, and APIs — built transport-agnostic so APNs/FCM are drop-in alongside the immediately-testable Web Push.
## Goals
- Deliver Phase-1 notifications to devices over APNs, FCM, and Web Push.
- Durable delivery: survive restarts, retry with backoff, prune dead tokens, observable in the admin task view.
- Presence-gated: don't buzz a device for a notification the user is actively seeing in-app.
- Reuse the existing per-category mute preferences; add per-device push on/off. No new per-channel matrix.
- Encrypted provider credentials via the existing settings mechanism.
- Transport-agnostic core: one `Transport` interface, three implementations, selected by device platform.
## Non-goals
- Mobile/web client integration (separate per-client projects; this ships the server contract).
- A separate in-app-vs-push preference matrix (category mutes + per-device toggle only).
- Rich push (images, action buttons) — title/body/link/category payload only in v1.
- New-device-login / security pushes beyond what the notification categories already produce.
## Architecture
New `internal/push` package and a new `internal/presence` package, plus touches to three existing seams (`notifications.Service.Create`, the events WS handler, `cmd/silo/main.go` wiring). Five independently-testable units:
1. **Device/token registry** — extends `user_devices` with push columns; a registration endpoint clients call to enroll/refresh/revoke a token and toggle per-device push. Owns "which devices want push and how to reach them."
2. **Presence registry** (`internal/presence`) — process-local `user_id → live-connection refcount`, incremented/decremented by the events WS handler on connect/disconnect. Answers "does this user have a live connection right now?" Cluster-aware via a short-TTL Redis key; degrades to per-node if Redis is absent.
3. **Enqueuer** — called from `notifications.Service.Create()` after the row commits. Resolves push-eligible devices, writes one `push_deliveries` row per device with `not_before = now + grace`.
4. **Delivery worker** — a `PushDeliveryTask` on the existing task framework; claims due rows, re-checks presence, dispatches via the platform's transport, records outcome, prunes dead tokens.
5. **Transports** — a `Transport` interface (`Send(ctx, target, payload) → Result`) with APNs, FCM, and Web Push implementations selected by device platform; credentials from the encrypted settings repo.
Data flow: `Create()` → enqueuer writes deliveries → worker (interval) claims due & un-present → transport sends → outcome / dead-token prune. The in-app path (immediate WS publish) is untouched; push is the deferred, gated mirror.
### Seam reference (current code)
- Fan-out hook: `internal/notifications/service.go` `Create()` — after `created, err := s.store.Insert(...)` succeeds and before/alongside the `hub.PublishJSON(... "notification.created" ...)` call. Enqueue is best-effort and must not block or fail `Create()`.
- Presence increment: `internal/api/handlers/events_ws.go` — after a successful subscribe, `release := presence.Add(claims.UserID)`, `defer release()`.
- No presence tracking exists today (confirmed); the registry is net-new.
- `user_devices` exists (`migrations/sql/180_user_devices.sql`, PK `user_id, profile_id, device_id`) with no token column.
- Credential encryption: `internal/catalog/encrypted_settings_repo.go` `SensitiveSettingKeys`.
- Background-worker precedent: `internal/taskmanager/tasks/*` (e.g. the request reconciler, notifications digest/retention).
## Data model
Extend `user_devices` (a push token belongs to a device):
```sql
ALTER TABLE user_devices
ADD COLUMN push_token text NULL,
ADD COLUMN push_transport text NULL, -- 'apns' | 'fcm' | 'webpush'
ADD COLUMN push_enabled boolean NOT NULL DEFAULT true,
ADD COLUMN push_token_at timestamptz NULL,
ADD COLUMN push_failures integer NOT NULL DEFAULT 0;
```
A device is push-eligible when `push_token IS NOT NULL AND push_enabled`. For `webpush`, `push_token` holds the JSON subscription object (endpoint + p256dh + auth); APNs/FCM store the bare token string. Each transport interprets its own format.
Delivery queue:
```sql
CREATE TABLE push_deliveries (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
notification_id bigint NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
user_id integer NOT NULL,
device_id text NOT NULL,
transport text NOT NULL,
status text NOT NULL DEFAULT 'pending', -- pending|sent|failed|skipped|dead
attempts integer NOT NULL DEFAULT 0,
not_before timestamptz NOT NULL,
last_error text NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX push_deliveries_claim_idx
ON push_deliveries (not_before)
WHERE status IN ('pending','failed');
```
Worker claim: `status IN ('pending','failed') AND not_before <= now()`, ordered by `not_before`, `FOR UPDATE SKIP LOCKED` (safe across nodes/concurrent workers).
Outcomes:
- **sent** — terminal success.
- **skipped** — user was present (live WS) at claim time; the in-app delivery sufficed. Terminal.
- **failed** — soft error (timeout, 5xx, rate-limit); bump `attempts`, push `not_before` out by the backoff schedule (1m, 5m, 30m). Once the schedule is exhausted (4 total attempts) → `dead`.
- **dead** — hard error / unregistered token; terminal. Also clears `push_token` and sets `push_enabled=false` on the `user_devices` row so the dead device stops being enqueued.
Deliveries do not duplicate notification content — the worker joins `notifications` at send time so an edited/expired notification sends current content (and an expired/dismissed one can be skipped). Retention: terminal deliveries older than 7 days are deleted by a pass on the existing notifications retention task.
Provider config (added to `SensitiveSettingKeys`, encrypted at rest):
`push.apns.p8_key`, `push.apns.key_id`, `push.apns.team_id`, `push.apns.bundle_id`, `push.fcm.service_account_json`, `push.webpush.vapid_public`, `push.webpush.vapid_private`, `push.webpush.subject`. A transport with missing/blank config reports itself unconfigured; its deliveries short-circuit to `skipped` (reason: unconfigured) rather than erroring in a loop.
## API
Device push registration (authenticated user routes; device identified by the existing `X-Silo-Device-Id` header + `user_id` from claims — never from the body):
```
PUT /api/v1/notifications/push/device {transport, token} → 204
enroll/refresh this device's token (upsert onto the user_devices row);
resets push_failures, sets push_enabled=true, push_token_at=now
DELETE /api/v1/notifications/push/device → 204
revoke: clear token, disable push for this device
GET /api/v1/notifications/push/devices → {devices:[{device_id,name,platform,push_enabled,registered_at}]}
PUT /api/v1/notifications/push/devices/{device_id} {enabled} → 204
per-device on/off toggle
GET /api/v1/notifications/push/webpush-key → {vapid_public_key} (empty when web push unconfigured)
```
Admin provider config uses the existing admin settings mechanism (encrypted `push.*` keys) — no bespoke write endpoints. One read for the admin UI:
```
GET /api/v1/admin/push/status → {apns:{configured:bool}, fcm:{configured:bool}, webpush:{configured:bool}}
```
Status reports booleans only; secrets are never returned.
## Presence registry
`presence.Registry` interface: `Connected(userID int) bool` and `Add(userID int) (release func())`. The events WS handler calls `release := registry.Add(claims.UserID)` after a successful subscribe and `defer release()` on disconnect.
- In-process implementation: `map[int]int` refcount under a mutex.
- Cluster-aware implementation: `INCR push:presence:{userID}` in Redis with a 60s TTL refreshed on the WS heartbeat; `DECR` on release; `Connected` is key `> 0`. Redis is already used by the events hub.
- Presence failure (Redis down) fails **open** — we push — because a missed push is worse than a redundant one.
The worker re-checks `Connected` at claim time; a present user's due delivery becomes `skipped`. The `not_before` grace (default 30s) means an actively-in-app user almost never gets a redundant push, while a user who closed the app moments ago does.
## Error handling & delivery semantics
- Enqueuer is best-effort; failure to write deliveries logs and returns. `Create()` is never blocked or failed by push.
- Worker isolates per-delivery: one device's failure never blocks the batch.
- Soft errors → `failed` + exponential backoff on `not_before` (1m, 5m, 30m), then `dead` after 4 total attempts.
- Hard errors (`Unregistered`/`InvalidToken`/FCM `NOT_FOUND`) → immediate `dead` + clear token + disable push on `user_devices`.
- Unconfigured transport → its deliveries short-circuit to `skipped` (reason logged once, not per-row spam).
- Rate-limit responses park that transport briefly (honor `Retry-After`).
- Child-profile suppression applied at enqueue: categories in {request, system, admin} are never enqueued for a child profile (same rule as the web toast suppression).
- APNs/FCM payloads set a collapse/thread key = notification id to avoid duplicate banners across retries.
## Security
- Provider secrets live only in the encrypted settings repo; never logged, never returned by any read endpoint. `/admin/push/status` reports booleans.
- Token registration is authenticated and device-scoped to the caller's `user_id` (from claims).
- Push payload carries title/body/link/category + notification id — no content beyond what the notification already holds.
## Testing
- Worker tested with a fake `Transport` asserting outcome transitions: sent, skipped (present), failed-with-backoff, dead-prunes-token. No network.
- Each real transport unit-tested against a mocked HTTP endpoint (request shape, auth header, error-code mapping) — standard repo pattern.
- Enqueuer tested with a fake device store + presence stub: eligible-device resolution, child suppression, per-device disable, `not_before` grace.
- Presence registry: refcount add/release, Connected transitions, Redis-absent fallback.
- Store tests against Postgres: claim query with `SKIP LOCKED`, dead-token prune, retention.
- One integration test: `Create() → delivery row → worker → fake transport → sent`.
- Handler tests: registration auth-scoping, per-device toggle, `/admin/push/status` unconfigured states, `webpush-key` empty when unconfigured.
## Implementation sequencing
1. Migration (user_devices columns + push_deliveries) and domain types.
2. `internal/presence` registry (in-process + Redis) and WS-handler wiring.
3. Device registry store + registration/toggle endpoints.
4. Enqueuer + hook into `Create()`.
5. `Transport` interface + fake; delivery worker + task registration; outcome/backoff/dead-prune.
6. Real transports (Web Push first — end-to-end testable today; then APNs, then FCM) + encrypted config keys + `/admin/push/status`.
7. Retention pass + full verification.
## Future work
- Client integration: Apple (APNs token registration + handling), Android (FCM), web (service worker + VAPID subscription). Each binds to the frozen registration API.
- Presence-aware in-app→push escalation tuning (per-category grace, quiet hours).
- Rich push (images from `item_id` artwork, action buttons).
- Per-channel preference matrix if category+device proves too coarse.
- Shared-device push semantics: a physical device registers one OS token but one user_devices row per profile. For user-wide notifications, EligibleDevices currently returns one delivery per profile-row → the same token is sent to more than once (the per-notification collapse key dedupes the on-device banner, so the user sees one, but the sends are redundant). And child-category suppression on a device shared by a child and an adult profile is ambiguous because push cannot know the active profile at send time. Both resolve with the active-profile/claims-enrichment work also needed for WS child-filtering: carry the active profile identity so EligibleDevices can dedupe by token and apply the correct profile's suppression.
@@ -0,0 +1,120 @@
# Core Push — Web Client & Admin Config — Design
Date: 2026-06-10
Status: approved (design); implementation plan to follow
## Summary
The core-buildable slice of Phase 3 (client work): a Web Push browser client and an admin push-provider config UI, both living in the silo-server web app plus one ~10-line Go endpoint. This makes the push subsystem operable end to end from the browser — an operator enters provider credentials in an admin page, a user enables push on their browser, and notifications arrive as OS banners with the app closed. The mobile clients (silo-apple APNs, silo-android FCM) remain out of scope; they are separate per-repo projects that consume the same frozen registration API.
This delivers user-facing notification delivery to browsers. It is not a plugin-communication channel; plugins relate to notifications only via the existing `notifications.send` event contract (a plugin produces a user notification, which then flows to in-app + push).
## Goals
- Web Push delivery to browsers: service worker, subscription, receive, click-through.
- A per-device "push on this device" control and a device-management list in user settings.
- An admin page to configure APNs / FCM / Web Push provider credentials with readiness status.
- VAPID key generation from the admin UI.
- Reuse everything already built: the frozen push registration API, the encrypted settings write path, the Phase-2 notifications settings page and api client.
## Non-goals
- Mobile client integration (silo-apple / silo-android) — separate projects against the frozen API.
- A PWA framework / offline caching (vite-plugin-pwa) — a static service worker covers push; offline is out of scope.
- Any change to the push delivery worker, transports, or registration API (all frozen).
- Rich push (images, action buttons).
## Frozen dependencies (already on this branch)
Push registration API (`internal/api/handlers/push.go`, routes in `router.go`):
```
PUT /api/v1/notifications/push/device {transport, token} → 204 (RequireProfile)
DELETE /api/v1/notifications/push/device → 204 (RequireProfile)
GET /api/v1/notifications/push/devices → {devices:[DeviceInfo]}
PUT /api/v1/notifications/push/devices/{device_id} {enabled} → 204
GET /api/v1/notifications/push/webpush-key → {vapid_public_key}
GET /api/v1/admin/push/status → {apns,fcm,webpush: bool}
```
`DeviceInfo`: `{device_id, name, platform, transport, push_enabled, registered_at}`. The api client (`web/src/api/client.ts`) auto-attaches `X-Silo-Device-Id` and `X-Profile-Id`. WebPush tokens are stored as the JSON subscription object.
Admin settings write path: `PUT /api/v1/admin/settings/{key}` `{value}` (`admin.go` HandleUpdateSetting) — all `push.*` keys are pre-registered sensitive in `encrypted_settings_repo.go` (encrypted at rest, value redacted from reads). No new write endpoint needed. Sensitive-status hook (`useAdminSensitiveStatus`) reports which keys are stored.
Web push server transport already sends payload `{id, title, body, link, category}` with `Topic: "n"+base36(notificationID)` for collapse.
## Architecture
Six units (five web, one tiny Go endpoint):
1. **Service worker** — `web/public/sw.js` (static). `push` → `showNotification` from payload; `notificationclick` → focus an open app tab (postMessage the link) or `openWindow(link)`; `pushsubscriptionchange` → re-subscribe with the cached VAPID key and re-register (best-effort token refresh).
2. **SW registration** — in `web/src/main.tsx`, registers `/sw.js` when `serviceWorker` + `PushManager` are supported; no-op otherwise.
3. **`usePushDevice` hook** — `web/src/hooks/usePushDevice.ts`. Owns the browser-push lifecycle and exposes a status state machine + `enable()`/`disable()`.
4. **Settings UI** — extend `web/src/pages/settings/NotificationsSettings.tsx`: "Push on this device" control + a device-management list (per-device enable/disable).
5. **Admin push-config page** — `web/src/pages/admin-settings/PushSettings.tsx`: three credential cards + VAPID keygen + readiness badges.
6. **VAPID keygen endpoint** — `POST /api/v1/admin/push/generate-vapid-keys` (RequireAdmin), ~10 lines in `push.go`. The only net-new server code.
## Push client data flow
`usePushDevice` reports `status`:
- **unsupported** — no serviceWorker/PushManager, or iOS Safari not installed as a PWA. UI explains; no action.
- **blocked** — `Notification.permission === "denied"`. UI explains how to re-enable in site settings (cannot un-deny programmatically).
- **off** — supported, not subscribed. Toggle → `enable()`.
- **on** — subscribed and registered. Toggle → `disable()`.
- **pending** — transition in flight; control disabled.
`enable()`: `Notification.requestPermission()` → if granted, `navigator.serviceWorker.ready` → `GET /notifications/push/webpush-key` → `pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidPublic) })` → `PUT /notifications/push/device { transport: "webpush", token: JSON.stringify(subscription.toJSON()) }`. The api client supplies device + profile headers (satisfies the route's RequireProfile). Cache the VAPID public key (Cache Storage / IndexedDB) so the SW can re-subscribe on `pushsubscriptionchange` without the app open. Success → `on`, toast.
`disable()`: `pushManager.getSubscription()` → `unsubscribe()` (best-effort) → `DELETE /notifications/push/device` → `off`, toast.
Receiving (SW): payload `{id,title,body,link,category}` → `showNotification(title, { body, data:{link}, tag:"n"+id })`. The `tag` is an on-device display-dedup keyed on the notification id (a redelivery of the same notification replaces the visible banner rather than stacking); it is independent of the server's Web Push `Topic` header (which dedups in the push-service queue). `notificationclick` → focus matching-origin client + postMessage `{type:"notification-click", link}`, else `openWindow(link ?? "/notifications")`.
Idempotency / healing: `enable()` is safe to re-run (server upserts); a stale local subscription with no server row heals on re-enable. Permission-granted-but-subscribe-fails → toast, stay `off`.
## Admin config page
`PushSettings.tsx`, registered in `AdminSettingsLayout` `SETTINGS_GROUPS` (Connections group). Pattern copied from `IntegrationsSettings.tsx` (`SettingField` + `CredentialStatus` + `useUpdateServerSetting`). Cards:
- **Web Push** (leads — usable today): `push.webpush.vapid_public`, `push.webpush.vapid_private`, `push.webpush.subject`. "Generate keys" button → `POST /admin/push/generate-vapid-keys` → fills public/private fields (not auto-saved; admin reviews then saves each). Subject defaults to a `mailto:` hint.
- **APNs**: `push.apns.p8_key` (textarea), `push.apns.key_id`, `push.apns.team_id`, `push.apns.bundle_id`.
- **FCM**: `push.fcm.service_account_json` (textarea).
Readiness: card header badge from `GET /admin/push/status`; per-field "configured, leave blank to keep" from the sensitive-status hook (secrets never returned). Each save = `PUT /admin/settings/{key}` `{value}` via `useUpdateServerSetting`; encryption automatic; badges refetch after save.
VAPID keygen endpoint: generates and returns `{vapid_public, vapid_private}`; does not persist. Handler test: non-empty pair, admin-gated.
### Open decision — "Send test push" button
OPTION (admin's call at spec review): a "Send test push" button that creates a one-off system notification addressed to the admin themselves to verify config→subscribe→receive end to end from the UI. Requires one more net-new server endpoint (`POST /api/v1/admin/push/test` → `notifications.Create` a system notification for the caller). Recommended for operability (turns "did I configure this right?" into one click) but it is the only other net-new server surface. **Decision: include it** unless cut at review. (Implementation plan will gate this behind the decision.)
## Error handling
- Capability gating (unsupported, denied) are UI states, not errors; iOS Safari shows "Add to Home Screen to enable push."
- Transient subscribe/register failures → toast, leave `off`; nothing half-committed (server PUT is source of truth, idempotent).
- SW resilience: tolerate partial payload (generic title fallback); `notificationclick` falls back to `/notifications`; failed `pushsubscriptionchange` re-register logs and retries on next app load (browser stops receiving until reopened — never a crash).
- Admin page: save failures toast; readiness badges always reflect server truth (refetch after save); partial config shows "Not configured" until complete; generate-keys failure leaves fields untouched.
## Testing
- `usePushDevice` — unit tests with mocked serviceWorker/PushManager/Notification/api client: status derivation, enable() happy path (permission→key→subscribe→PUT body), permission-denied short-circuit, disable() (unsubscribe + DELETE), idempotent re-enable.
- Settings UI — component tests: each status renders the right control; device-list rows + per-device toggle mutation.
- Service worker — vitest over the pure handlers: payload→showNotification-args mapping, click→target-URL logic. DOM wiring exercised in the manual smoke.
- Admin page — component tests: three cards render, badges reflect `/admin/push/status`, save calls `PUT /admin/settings/{key}` with the right key, generate-keys fills fields.
- Server — VAPID keygen handler test (non-empty pair, admin-gated); test-push endpoint handler test (if included).
- Manual smoke (documented in plan): real browser — configure VAPID, enable push, trigger a notification, confirm OS banner with app closed, click → deep link.
## Implementation sequencing
1. Server: VAPID keygen endpoint (+ test-push endpoint if kept).
2. Web: service worker + registration + VAPID-key caching.
3. Web: `usePushDevice` hook + tests.
4. Web: NotificationsSettings push control + device list.
5. Web: admin PushSettings page + nav.
6. Verification + manual browser smoke.
## Future work
- Mobile clients (APNs/FCM) against the frozen API.
- Shared-device token dedup + active-profile child-suppression (recorded in the push server spec) — informs whether multiple browsers/profiles dedupe.
- Per-category push targeting if category mutes prove too coarse.
- PWA/offline (vite-plugin-pwa) if the app later wants installable offline behavior.
@@ -0,0 +1,65 @@
# Home Notification Bars — Design
Date: 2026-06-11
Status: approved (design); implementation to follow
## Summary
Surface notifications at the top of the Home page so they're seen without opening the inbox tab, with system-level **announcements** visually distinguished from personal notifications. Two slim, dismissible bars stack at the top of Home: an **announcement bar** (distinct accent + 📢 + "Announcement" label) above a **notification strip** (the most recent unread personal notification). Both reuse existing hooks; no server changes. Web-only, additive.
## Goals
- Show the most recent unread notification high on the Home page, not only behind the bell/inbox.
- Make admin **announcements** clearly distinct from request/content/system/admin notifications, via a separate styled bar.
- Dismiss (✕) marks read and advances to the next unread; bars hide when nothing's unread.
- Reuse the Phase-2 notifications hooks; inherit per-profile + child filtering and live WebSocket updates for free.
## Non-goals
- No server/API changes (the data already distinguishes `category: "announcement"`).
- No change to the bell, inbox page, or toast (carrying the 📢 styling there is a possible follow-up, out of scope here).
- Not app-wide — Home page only.
## Components
Two components in `web/src/components/home/` (or alongside Home), each small and independently testable, plus one mount edit.
### AnnouncementBar (`web/src/components/AnnouncementBar.tsx`)
- Data: `useNotificationsList({ unread: true, category: "announcement" })` → the first (newest) item. Reliably finds the latest unread announcement regardless of how many other notifications are unread.
- Render: a distinct bar using the app's accent tokens (reads as a system notice, not a personal row) — a 📢 icon, an "ANNOUNCEMENT" label, the title, truncated body, relative time, and a ✕.
- Empty: renders nothing when there's no unread announcement.
- ✕: `useMarkRead().mutate({ ids: [id] })` → the unread query updates → the next unread announcement shows, or the bar hides. Marking read also drops the bell badge (shared cache).
- Click (anywhere but ✕): navigate to the notification's `link` (or `/notifications` when it has none) and mark it read.
### NotificationStrip (`web/src/components/NotificationStrip.tsx`)
- Data: `useNotificationsList({ unread: true })` → the first item whose `category !== "announcement"` (so announcements live only in the bar above, never duplicated here).
- Render: the slim strip — 🔔 icon, title, truncated body, relative time, ✕. Lighter styling than the announcement bar.
- Empty: renders nothing when there's no unread non-announcement.
- ✕ / click: same behavior as the bar (mark read + advance / navigate + mark read).
### Mount (`web/src/pages/Home.tsx`)
At the top of the Home content region (near the existing `heroSlot` / `TasteSeedBanner`), render `<AnnouncementBar />` then `<NotificationStrip />` as the first elements, so they sit above the content rows. When both are empty, Home renders exactly as today.
## Data flow & shared behavior
- Both bars read through TanStack Query; the announcement bar's query (`{unread:true, category:"announcement"}`) and the strip's (`{unread:true}`) are distinct cache keys — two small fetches, both already used elsewhere.
- Live updates: `useNotificationsLive` already invalidates the notifications list on `notification.created`, so both bars reactively show the newest unread without a reload.
- Filtering: the list endpoint already scopes to the active profile and suppresses child-restricted categories server-side, so the bars inherit correct per-profile behavior.
- The relative-time helper is the existing `timeAgo` (with the absolute-date fallback for old timestamps).
## Error handling
- While the query is loading, render nothing (no skeleton flash for a slim bar).
- A mark-read failure surfaces the existing mutation `onError` toast; the bar stays until the next successful read.
- A notification with no `link` navigates to `/notifications` rather than a dead link.
## Testing
- `AnnouncementBar.test.tsx`: renders the latest unread announcement with the 📢/label styling; ✕ calls `markRead({ids:[id]})`; clicking navigates (mocked `useNavigate`) to the link and marks read; renders nothing when no unread announcement.
- `NotificationStrip.test.tsx`: shows the newest unread non-announcement (skips an announcement at the head of the list); ✕ marks read; click navigates + marks read; renders nothing when no unread non-announcement.
- Existing suites stay green; tsc + lint clean.
## Future work
- Carry the 📢 announcement styling into the bell dropdown and inbox rows for a consistent identity.
- Optional: a per-user "hide the home bars" preference if they prove noisy.
+11 -5
View File
@@ -20,6 +20,7 @@ require (
)
require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/abadojack/whatlanggo v1.0.1
github.com/go-chi/cors v1.2.2
github.com/gorilla/websocket v1.5.3
@@ -30,20 +31,25 @@ require (
github.com/oklog/ulid/v2 v2.1.0
github.com/pgvector/pgvector-go v0.3.0
github.com/pressly/goose/v3 v3.27.1
github.com/sideshow/apns2 v0.25.0
github.com/zishang520/socket.io/v2 v2.5.0
go.n16f.net/thumbhash v1.1.0
golang.org/x/image v0.41.0
golang.org/x/net v0.55.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529
golang.org/x/oauth2 v0.36.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/PuerkitoBio/goquery v1.8.0 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dunglas/httpsfv v1.1.0 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/gookit/color v1.5.4 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
@@ -56,6 +62,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect
github.com/quic-go/webtransport-go v0.10.0 // indirect
@@ -69,7 +76,6 @@ require (
github.com/zishang520/engine.io/v2 v2.5.0 // indirect
github.com/zishang520/socket.io-go-parser/v2 v2.5.0 // indirect
github.com/zishang520/webtransport-go v0.9.1 // indirect
go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
)
@@ -98,10 +104,10 @@ require (
github.com/sony/sonyflake v1.3.0
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sync v0.20.0
golang.org/x/sync v0.21.0
golang.org/x/text v0.37.0
golang.org/x/time v0.14.0
google.golang.org/grpc v1.80.0
golang.org/x/time v0.15.0
google.golang.org/grpc v1.81.1
google.golang.org/protobuf v1.36.11
)
+97 -14
View File
@@ -1,11 +1,17 @@
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/Silo-Server/silo-plugin-sdk v0.6.0 h1:Gi9TdH9kt7b8X4xRXH493/nSYb9n0GO4VCWmlll0hKI=
github.com/Silo-Server/silo-plugin-sdk v0.6.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20201120081800-1786d5ef83d4/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
@@ -45,8 +51,9 @@ github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
@@ -69,10 +76,15 @@ github.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0=
github.com/go-pg/pg/v10 v10.11.0/go.mod h1:4BpHRoxE61y4Onpof3x1a2SQvi9c+q1dJnrNdMjsroA=
github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU=
github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo=
github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -153,8 +165,9 @@ github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pgvector/pgvector-go v0.3.0 h1:Ij+Yt78R//uYqs3Zk35evZFvr+G0blW0OUN+Q2D1RWc=
github.com/pgvector/pgvector-go v0.3.0/go.mod h1:duFy+PXWfW7QQd5ibqutBO4GxLsUZ9RVXhFZGIBsWSA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
@@ -185,10 +198,13 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEV
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/sideshow/apns2 v0.25.0 h1:XOzanncO9MQxkb03T/2uU2KcdVjYiIf0TMLzec0FTW4=
github.com/sideshow/apns2 v0.25.0/go.mod h1:7Fceu+sL0XscxrfLSkAoH6UtvKefq3Kq1n4W3ayQZqE=
github.com/sony/sonyflake v1.3.0 h1:tiB4Dlp0lnmKp/h6BLXA14P8Qi+LYS9+0QRpcrKHvg4=
github.com/sony/sonyflake v1.3.0/go.mod h1:LORtCywH/cq10ZbyfhKrHYgAUGH7mOBa76enV9txy/Y=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
@@ -215,6 +231,7 @@ github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHg
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
github.com/zishang520/engine.io-go-parser v1.3.2 h1:aEVrhQVhfk99Ct6htNffgHydUBC4dGclO/OXPz5CSy0=
@@ -233,10 +250,10 @@ go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
@@ -249,44 +266,110 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+10
View File
@@ -28,6 +28,7 @@ import (
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/config"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/markers"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/notifications"
@@ -530,6 +531,15 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request)
}
}
// Publish a domain event rather than creating the notification inline; the
// notifications materializer's system matcher turns it into the user's
// "Password changed" notice, keeping all notification creation event-driven.
if req.Password != nil && h.RealtimeHub != nil {
_ = h.RealtimeHub.EventsHub().PublishJSON(r.Context(),
evt.ChannelSessions, notifications.EventUserPasswordChanged, nil,
evt.PublishOptions{UserID: id})
}
user, err := h.userRepo.GetByID(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch updated user")
@@ -0,0 +1,143 @@
package handlers
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/notifications"
)
// ---------------------------------------------------------------------------
// Minimal fake UserRepository for AdminHandler tests.
// ---------------------------------------------------------------------------
type fakeUserRepo struct {
user *models.User
getErr error
}
func (f *fakeUserRepo) List(_ context.Context) ([]*models.User, error) { return nil, nil }
func (f *fakeUserRepo) Create(_ context.Context, _ models.CreateUserInput) (*models.User, error) {
return f.user, nil
}
func (f *fakeUserRepo) Update(_ context.Context, _ int, _ models.UpdateUserInput) error {
return nil
}
func (f *fakeUserRepo) Delete(_ context.Context, _ int) error { return nil }
func (f *fakeUserRepo) GetByID(_ context.Context, _ int) (*models.User, error) {
return f.user, f.getErr
}
// Compile-time check.
var _ UserRepository = (*fakeUserRepo)(nil)
// ---------------------------------------------------------------------------
// Helper: build AdminHandler with a realtime hub whose events the test can
// observe. Password changes publish a domain event; the notifications
// materializer (tested separately) turns it into the user's notification.
// ---------------------------------------------------------------------------
func newAdminHandlerWithHub(userRepo UserRepository) (*AdminHandler, *evt.Hub) {
rh := notifications.NewHub("test", nil)
h := NewAdminHandler(userRepo, nil, nil)
h.RealtimeHub = rh
return h, rh.EventsHub()
}
// adminUpdateRequest builds a PUT /admin/users/{id} HTTP request.
func adminUpdateRequest(body []byte, userID string) *http.Request {
req := httptest.NewRequest(http.MethodPut, "/admin/users/"+userID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", userID)
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
}
// ---------------------------------------------------------------------------
// Tests: HandleUpdateUser password change notification
// ---------------------------------------------------------------------------
// TestHandleUpdateUser_WithPassword_PublishesEvent asserts that including a
// Password in the update payload publishes one user.password_changed event on
// ChannelSessions targeted at the updated user.
func TestHandleUpdateUser_WithPassword_PublishesEvent(t *testing.T) {
targetUser := &models.User{
ID: 99,
Username: "alice",
Email: "alice@example.com",
Role: "user",
}
repo := &fakeUserRepo{user: targetUser}
h, eventsHub := newAdminHandlerWithHub(repo)
ch, unsub := eventsHub.Subscribe()
defer unsub()
body := []byte(`{"password":"newSecret123"}`)
req := adminUpdateRequest(body, "99")
rec := httptest.NewRecorder()
h.HandleUpdateUser(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
select {
case env := <-ch:
if env.Channel != evt.ChannelSessions {
t.Errorf("channel = %q, want %q", env.Channel, evt.ChannelSessions)
}
if env.Event != notifications.EventUserPasswordChanged {
t.Errorf("event = %q, want %q", env.Event, notifications.EventUserPasswordChanged)
}
if env.UserID != 99 {
t.Errorf("event user_id = %d, want 99", env.UserID)
}
case <-time.After(time.Second):
t.Fatal("expected a user.password_changed event, got none")
}
}
// TestHandleUpdateUser_WithoutPassword_NoEvent asserts that a non-password
// update publishes no user.password_changed event.
func TestHandleUpdateUser_WithoutPassword_NoEvent(t *testing.T) {
targetUser := &models.User{
ID: 99,
Username: "alice",
Email: "alice@example.com",
Role: "user",
}
repo := &fakeUserRepo{user: targetUser}
h, eventsHub := newAdminHandlerWithHub(repo)
ch, unsub := eventsHub.Subscribe()
defer unsub()
body := []byte(`{"username":"alice-renamed"}`)
req := adminUpdateRequest(body, "99")
rec := httptest.NewRecorder()
h.HandleUpdateUser(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
select {
case env := <-ch:
t.Errorf("expected no event, got %q on %q", env.Event, env.Channel)
case <-time.After(100 * time.Millisecond):
// no event, as expected
}
}
+60 -2
View File
@@ -12,6 +12,8 @@ import (
"github.com/Silo-Server/silo-server/internal/auth"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/historyimport"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/Silo-Server/silo-server/internal/presence"
"github.com/Silo-Server/silo-server/internal/scanqueue"
"github.com/Silo-Server/silo-server/internal/taskmanager"
"github.com/gorilla/websocket"
@@ -31,6 +33,11 @@ type activeScanLister interface {
ListActive(ctx context.Context) ([]evt.ScanRun, error)
}
// notificationUnreadCounter is the subset of notifications.Service used by EventsHandler.
type notificationUnreadCounter interface {
UnreadCount(ctx context.Context, userID int, profileID string, childSafe bool) (int, error)
}
type EventsHandler struct {
hub *evt.Hub
jobs *AdminJobsHandler
@@ -39,6 +46,8 @@ type EventsHandler struct {
scans *evt.ScanRegistry
persistedScans activeScanLister
historyImports historyImportActiveLister
notifications notificationUnreadCounter
presence presence.Registry
}
func NewEventsHandler(
@@ -49,6 +58,8 @@ func NewEventsHandler(
scans *evt.ScanRegistry,
persistedScans *scanqueue.Service,
historyImports historyImportActiveLister,
notificationsSvc *notifications.Service,
presenceReg presence.Registry,
) *EventsHandler {
return &EventsHandler{
hub: hub,
@@ -58,6 +69,8 @@ func NewEventsHandler(
scans: scans,
persistedScans: persistedScans,
historyImports: historyImports,
notifications: notificationsSvc,
presence: presenceReg,
}
}
@@ -85,7 +98,16 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
eventsCh, unsubscribe := h.hub.Subscribe()
defer unsubscribe()
if h.presence != nil {
release := h.presence.Add(ctx, claims.UserID)
defer release()
}
startWebSocketPingLoop(ctx, func() error {
if h.presence != nil {
h.presence.Refresh(ctx, claims.UserID)
}
return writeWebSocketControl(conn, websocket.PingMessage, nil)
})
@@ -274,6 +296,8 @@ func allowedChannelsForRole(role string) []evt.EventChannel {
channels := []evt.EventChannel{
evt.ChannelCatalog,
evt.ChannelHistoryImport,
evt.ChannelNotifications,
evt.ChannelRequests,
evt.ChannelUserState,
}
if role == "admin" {
@@ -294,12 +318,24 @@ func allowsEventForClaims(claims *auth.Claims, env evt.Envelope) bool {
if env.AdminOnly && claims.Role != "admin" {
return false
}
if env.UserID > 0 && claims.Role != "admin" && env.UserID != claims.UserID {
return false
if env.UserID > 0 && env.UserID != claims.UserID {
// The notifications and requests channels carry per-user private content
// (inbox titles/bodies, requested media names). Nobody — including
// admins — may receive another user's events on them. Other channels
// keep the admin cross-user monitoring bypass.
if claims.Role != "admin" || isPersonalChannel(env.Channel) {
return false
}
}
return true
}
// isPersonalChannel reports whether a channel carries per-user private content
// that must never be delivered across user boundaries, even to admins.
func isPersonalChannel(c evt.EventChannel) bool {
return c == evt.ChannelNotifications || c == evt.ChannelRequests
}
func marshalJSON(value any) json.RawMessage {
if value == nil {
return json.RawMessage("null")
@@ -319,6 +355,28 @@ func (h *EventsHandler) snapshotForChannel(
switch channel {
case evt.ChannelCatalog, evt.ChannelUserState:
return json.RawMessage("null"), nil
case evt.ChannelNotifications:
if h == nil || h.notifications == nil {
return json.RawMessage(`{"unread_count":0}`), nil
}
// Profile ID is read directly from the WS upgrade request header because
// the events/ws route does not go through the RequireProfile middleware.
// childSafe is intentionally false here: the EventsHandler has no access
// to a profile store, so the WS snapshot is a conservative approximation.
// The REST GET /notifications/unread-count endpoint is authoritative for
// child-safe profile filtering.
profileID := r.Header.Get("X-Profile-Id")
count, err := h.notifications.UnreadCount(r.Context(), claims.UserID, profileID, false)
if err != nil {
return nil, err
}
return marshalJSON(struct {
UnreadCount int `json:"unread_count"`
}{UnreadCount: count}), nil
case evt.ChannelRequests:
// Request state is fetched via REST; the WS channel only carries
// lifecycle events. Return an empty object as the initial snapshot.
return json.RawMessage(`{}`), nil
case evt.ChannelJobs:
if h == nil || h.jobs == nil || h.jobs.repo == nil {
return json.RawMessage("[]"), nil
+125
View File
@@ -0,0 +1,125 @@
package handlers
import (
"context"
"encoding/json"
"net/http/httptest"
"slices"
"testing"
"github.com/Silo-Server/silo-server/internal/auth"
evt "github.com/Silo-Server/silo-server/internal/events"
)
// ---------------------------------------------------------------------------
// Fake notificationUnreadCounter for snapshot tests.
// ---------------------------------------------------------------------------
type fakeUnreadCounter struct {
count int
err error
}
func (f *fakeUnreadCounter) UnreadCount(_ context.Context, _ int, _ string, _ bool) (int, error) {
return f.count, f.err
}
// ---------------------------------------------------------------------------
// allowedChannelsForRole tests
// ---------------------------------------------------------------------------
func TestAllowedChannelsForRole_UserContainsNotificationsAndRequests(t *testing.T) {
channels := allowedChannelsForRole("user")
if !slices.Contains(channels, evt.ChannelNotifications) {
t.Errorf("allowedChannelsForRole(user) missing ChannelNotifications; got %v", channels)
}
if !slices.Contains(channels, evt.ChannelRequests) {
t.Errorf("allowedChannelsForRole(user) missing ChannelRequests; got %v", channels)
}
}
func TestAllowedChannelsForRole_AdminContainsAllUserChannelsPlusExtras(t *testing.T) {
userChannels := allowedChannelsForRole("user")
adminChannels := allowedChannelsForRole("admin")
for _, ch := range userChannels {
if !slices.Contains(adminChannels, ch) {
t.Errorf("allowedChannelsForRole(admin) missing user channel %q", ch)
}
}
adminExtras := []evt.EventChannel{
evt.ChannelJobs,
evt.ChannelSessions,
evt.ChannelTasks,
evt.ChannelScans,
}
for _, ch := range adminExtras {
if !slices.Contains(adminChannels, ch) {
t.Errorf("allowedChannelsForRole(admin) missing admin-only channel %q", ch)
}
}
}
// ---------------------------------------------------------------------------
// snapshotForChannel tests
// ---------------------------------------------------------------------------
func TestSnapshotForChannel_NotificationsNilService(t *testing.T) {
h := &EventsHandler{notifications: nil}
r := httptest.NewRequest("GET", "/events/ws", nil)
claims := &auth.Claims{UserID: 42, Role: "user"}
data, err := h.snapshotForChannel(r, claims, evt.ChannelNotifications)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got struct {
UnreadCount int `json:"unread_count"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v, raw=%s", err, data)
}
if got.UnreadCount != 0 {
t.Errorf("nil service: want unread_count=0, got %d", got.UnreadCount)
}
}
func TestSnapshotForChannel_NotificationsWithService(t *testing.T) {
h := &EventsHandler{notifications: &fakeUnreadCounter{count: 7}}
r := httptest.NewRequest("GET", "/events/ws", nil)
r.Header.Set("X-Profile-Id", "profile-abc")
claims := &auth.Claims{UserID: 5, Role: "user"}
data, err := h.snapshotForChannel(r, claims, evt.ChannelNotifications)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got struct {
UnreadCount int `json:"unread_count"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v, raw=%s", err, data)
}
if got.UnreadCount != 7 {
t.Errorf("want unread_count=7, got %d", got.UnreadCount)
}
}
func TestSnapshotForChannel_RequestsReturnsEmptyObject(t *testing.T) {
h := &EventsHandler{}
r := httptest.NewRequest("GET", "/events/ws", nil)
claims := &auth.Claims{UserID: 1, Role: "user"}
data, err := h.snapshotForChannel(r, claims, evt.ChannelRequests)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got map[string]any
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v, raw=%s", err, data)
}
if len(got) != 0 {
t.Errorf("ChannelRequests snapshot should be empty object, got %v", got)
}
}
+316
View File
@@ -0,0 +1,316 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// profileResolver is the minimal interface NotificationsHandler needs from the
// userstore provider. Defined on the handler side so tests can stub it without
// importing the full provider.
type profileResolver interface {
ForUser(ctx context.Context, userID int) (userstore.UserStore, error)
}
// NotificationsHandler handles inbox, preferences, and announcement endpoints.
type NotificationsHandler struct {
svc *notifications.Service
profiles profileResolver // nil-tolerant: ChildSafe=false when absent
}
// NewNotificationsHandler creates a new NotificationsHandler.
// provider may be nil; when nil, ChildSafe defaults to false on all requests.
func NewNotificationsHandler(svc *notifications.Service, provider ...profileResolver) *NotificationsHandler {
h := &NotificationsHandler{svc: svc}
if len(provider) > 0 {
h.profiles = provider[0]
}
return h
}
// childSafe resolves the active profile and returns Profile.IsChild.
// Fails closed: an unresolvable profile is treated as a child so restricted
// categories are never leaked on errors. Logs at debug level only — never fails
// the request. Nil resolver / missing ids keep returning false (unauthenticated
// paths never reach here; that's the wiring-absent case).
func (h *NotificationsHandler) childSafe(r *http.Request, userID int, profileID string) bool {
if h.profiles == nil || userID == 0 || profileID == "" {
return false
}
store, err := h.profiles.ForUser(r.Context(), userID)
if err != nil {
slog.Debug("notifications: childSafe: ForUser failed", "user_id", userID, "error", err)
return true // fail closed: treat unknown as child
}
p, err := store.GetProfile(r.Context(), profileID)
if err != nil || p == nil {
slog.Debug("notifications: childSafe: GetProfile failed", "profile_id", profileID, "error", err)
return true // fail closed: treat unknown as child
}
return p.IsChild
}
// ---------------------------------------------------------------------------
// User-scoped inbox endpoints
// ---------------------------------------------------------------------------
// HandleList handles GET /notifications.
// Query params: unread=1, category, cursor (int64), limit (int, default 50, max 100).
// Response: {"items":[...],"next_cursor":<int64|null>}
func (h *NotificationsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
q := r.URL.Query()
limit := 50
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
// keep in sync with the clamp in internal/notifications/store.go List
if limit > 100 {
limit = 100
}
f := notifications.ListFilter{
UserID: userID,
ProfileID: profileID,
Limit: limit,
ChildSafe: h.childSafe(r, userID, profileID),
}
if q.Get("unread") == "1" {
f.UnreadOnly = true
}
if cat := q.Get("category"); cat != "" {
f.Category = notifications.Category(cat)
}
if v := q.Get("cursor"); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
f.Cursor = n
}
}
items, err := h.svc.List(r.Context(), f)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list notifications")
return
}
var nextCursor *int64
if len(items) == limit && limit > 0 {
last := items[len(items)-1].ID
nextCursor = &last
}
writeJSON(w, http.StatusOK, struct {
Items []*notifications.Notification `json:"items"`
NextCursor *int64 `json:"next_cursor"`
}{
Items: items,
NextCursor: nextCursor,
})
}
// HandleUnreadCount handles GET /notifications/unread-count.
// Response: {"count": n}
func (h *NotificationsHandler) HandleUnreadCount(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
count, err := h.svc.UnreadCount(r.Context(), userID, profileID, h.childSafe(r, userID, profileID))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get unread count")
return
}
writeJSON(w, http.StatusOK, struct {
Count int `json:"count"`
}{Count: count})
}
// markReadRequest is the request body for POST /notifications/read.
type markReadRequest struct {
IDs []int64 `json:"ids"`
All bool `json:"all"`
}
// HandleMarkRead handles POST /notifications/read.
// Body: {"ids":[...]} OR {"all":true}. 400 when neither present.
func (h *NotificationsHandler) HandleMarkRead(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
var req markReadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if !req.All && len(req.IDs) == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "Provide ids or all:true")
return
}
if req.All {
if err := h.svc.MarkAllRead(r.Context(), userID, profileID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark all read")
return
}
} else {
if err := h.svc.MarkRead(r.Context(), userID, profileID, req.IDs); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark notifications read")
return
}
}
w.WriteHeader(http.StatusNoContent)
}
// HandleDismiss handles POST /notifications/{id}/dismiss.
// 400 for invalid id; 404 on ErrNotFound; 204 on success.
func (h *NotificationsHandler) HandleDismiss(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
rawID := chi.URLParam(r, "id")
id, err := strconv.ParseInt(rawID, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid notification id")
return
}
if err := h.svc.Dismiss(r.Context(), userID, profileID, id); err != nil {
if errors.Is(err, notifications.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Notification not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to dismiss notification")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleGetPreferences handles GET /notifications/preferences.
// Response: {"preferences":[{category,enabled},...]}
func (h *NotificationsHandler) HandleGetPreferences(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
prefs, err := h.svc.GetPreferences(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get preferences")
return
}
writeJSON(w, http.StatusOK, struct {
Preferences []notifications.Preference `json:"preferences"`
}{Preferences: prefs})
}
// putPreferencesRequest is the request body for PUT /notifications/preferences.
type putPreferencesRequest struct {
Preferences []notifications.Preference `json:"preferences"`
}
// HandlePutPreferences handles PUT /notifications/preferences.
// 400 on empty or invalid category; 204 on success.
func (h *NotificationsHandler) HandlePutPreferences(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
var req putPreferencesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if len(req.Preferences) == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "preferences must not be empty")
return
}
if err := h.svc.SetPreferences(r.Context(), userID, req.Preferences); err != nil {
if errors.Is(err, notifications.ErrInvalidCategory) {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save preferences")
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Admin announcement endpoints
// ---------------------------------------------------------------------------
// HandleListAnnouncements handles GET /admin/announcements.
// Response: {"items":[...]}
func (h *NotificationsHandler) HandleListAnnouncements(w http.ResponseWriter, r *http.Request) {
items, err := h.svc.ListAnnouncements(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list announcements")
return
}
if items == nil {
items = []*notifications.Announcement{}
}
writeJSON(w, http.StatusOK, struct {
Items []*notifications.Announcement `json:"items"`
}{Items: items})
}
// HandleCreateAnnouncement handles POST /admin/announcements.
// Decodes an Announcement, sets CreatedBy from claims, publishes; 400 on
// validation error; 201 with the announcement on success.
func (h *NotificationsHandler) HandleCreateAnnouncement(w http.ResponseWriter, r *http.Request) {
claims := apimw.GetClaims(r.Context())
// Announcements fan out one row per (user, profile); cap the request body so
// a large title/body cannot be amplified into the whole server's inboxes.
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
var a notifications.Announcement
if err := json.NewDecoder(r.Body).Decode(&a); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if claims != nil {
userID := claims.UserID
a.CreatedBy = &userID
}
if err := h.svc.PublishAnnouncement(r.Context(), &a); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeJSON(w, http.StatusCreated, &a)
}
// HandleDeleteAnnouncement handles DELETE /admin/announcements/{id}.
// 404 on ErrNotFound; 204 on success.
func (h *NotificationsHandler) HandleDeleteAnnouncement(w http.ResponseWriter, r *http.Request) {
rawID := chi.URLParam(r, "id")
id, err := strconv.ParseInt(rawID, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid announcement id")
return
}
if err := h.svc.DeleteAnnouncement(r.Context(), id); err != nil {
if errors.Is(err, notifications.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Announcement not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete announcement")
return
}
w.WriteHeader(http.StatusNoContent)
}
+673
View File
@@ -0,0 +1,673 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// ---------------------------------------------------------------------------
// Fake profile resolver for child-safe tests.
// ---------------------------------------------------------------------------
// fakeProfileStore satisfies userstore.UserStore minimally.
// Only GetProfile is exercised by childSafe.
type fakeProfileStore struct {
userstore.UserStore // embed for unimplemented methods
isChild bool
}
func (s *fakeProfileStore) GetProfile(_ context.Context, id string) (*userstore.Profile, error) {
return &userstore.Profile{ID: id, IsChild: s.isChild}, nil
}
// fakeResolverAsProfileResolver implements the profileResolver interface used
// by NotificationsHandler.
type fakeResolverAsProfileResolver struct {
isChild bool
}
func (f *fakeResolverAsProfileResolver) ForUser(_ context.Context, _ int) (userstore.UserStore, error) {
return &fakeProfileStore{isChild: f.isChild}, nil
}
// fakeResolverWithError returns an error from ForUser.
type fakeResolverWithError struct{}
func (f *fakeResolverWithError) ForUser(_ context.Context, _ int) (userstore.UserStore, error) {
return nil, errors.New("resolver failed")
}
// ---------------------------------------------------------------------------
// Fake store implementing notifications.Store for handler tests.
// ---------------------------------------------------------------------------
type fakeNotificationsStore struct {
notifications.Store // embed for unimplemented panics
list []*notifications.Notification
announcements []*notifications.Announcement
unreadCount int
prefs map[notifications.Category]bool
markReadCalled bool
markAllReadCalled bool
dismissErr error
setPrefErr error
deleteAnnouncementErr error
insertCalled bool
// capturedFilter records the most recent ListFilter passed to List.
capturedFilter *notifications.ListFilter
}
func (f *fakeNotificationsStore) Insert(_ context.Context, n *notifications.Notification) (bool, error) {
f.insertCalled = true
f.list = append(f.list, n)
return true, nil
}
func (f *fakeNotificationsStore) List(_ context.Context, filter notifications.ListFilter) ([]*notifications.Notification, error) {
f.capturedFilter = &filter
return f.list, nil
}
func (f *fakeNotificationsStore) UnreadCount(_ context.Context, _ int, _ string, _ bool) (int, error) {
return f.unreadCount, nil
}
func (f *fakeNotificationsStore) MarkRead(_ context.Context, _ int, _ string, _ []int64) error {
f.markReadCalled = true
return nil
}
func (f *fakeNotificationsStore) MarkAllRead(_ context.Context, _ int, _ string) error {
f.markAllReadCalled = true
return nil
}
func (f *fakeNotificationsStore) Dismiss(_ context.Context, _ int, _ string, _ int64) error {
return f.dismissErr
}
func (f *fakeNotificationsStore) Preferences(_ context.Context, _ int) (map[notifications.Category]bool, error) {
if f.prefs != nil {
return f.prefs, nil
}
return map[notifications.Category]bool{}, nil
}
func (f *fakeNotificationsStore) SetPreferences(_ context.Context, _ int, _ []notifications.Preference) error {
return f.setPrefErr
}
func (f *fakeNotificationsStore) InsertAnnouncement(_ context.Context, a *notifications.Announcement) error {
a.ID = int64(len(f.announcements) + 1)
a.CreatedAt = time.Now().UTC()
f.announcements = append(f.announcements, a)
return nil
}
func (f *fakeNotificationsStore) ListAnnouncements(_ context.Context) ([]*notifications.Announcement, error) {
return f.announcements, nil
}
func (f *fakeNotificationsStore) DeleteAnnouncement(_ context.Context, id int64) error {
if f.deleteAnnouncementErr != nil {
return f.deleteAnnouncementErr
}
for i, a := range f.announcements {
if a.ID == id {
f.announcements = append(f.announcements[:i], f.announcements[i+1:]...)
return nil
}
}
return notifications.ErrNotFound
}
func (f *fakeNotificationsStore) DismissAnnouncementNotifications(_ context.Context, _ int64) error {
return nil
}
func (f *fakeNotificationsStore) ProfileIDsForUsers(_ context.Context, _ []int) (map[int][]string, error) {
return nil, nil
}
func (f *fakeNotificationsStore) PurgeOld(_ context.Context, _, _ time.Time) (int64, error) {
return 0, nil
}
func (f *fakeNotificationsStore) AdminUserIDs(_ context.Context) ([]int, error) { return nil, nil }
func (f *fakeNotificationsStore) UserIDsWithLibraryAccess(_ context.Context, _ int) ([]int, error) {
return nil, nil
}
func (f *fakeNotificationsStore) AllEnabledUserIDs(_ context.Context) ([]int, error) { return nil, nil }
// Compile-time check.
var _ notifications.Store = (*fakeNotificationsStore)(nil)
// ---------------------------------------------------------------------------
// Helper: build a handler over the fake store.
// ---------------------------------------------------------------------------
func newTestNotificationsHandler(store *fakeNotificationsStore) *NotificationsHandler {
hub := evt.NewHub("test", nil)
svc := notifications.NewService(store, hub)
return NewNotificationsHandler(svc)
}
func newTestNotificationsHandlerWithResolver(store *fakeNotificationsStore, resolver profileResolver) *NotificationsHandler {
hub := evt.NewHub("test", nil)
svc := notifications.NewService(store, hub)
return NewNotificationsHandler(svc, resolver)
}
// notifUserRequest creates an authenticated request with a profile ID.
func notifUserRequest(method, target string, body []byte, userID int, profileID string) *http.Request {
var req *http.Request
if len(body) > 0 {
req = httptest.NewRequest(method, target, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, target, nil)
}
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: userID, Role: "user"})
ctx = apimw.SetProfileID(ctx, profileID)
return req.WithContext(ctx)
}
// notifAdminRequest creates an admin-authenticated request.
func notifAdminRequest(method, target string, body []byte, userID int) *http.Request {
var req *http.Request
if len(body) > 0 {
req = httptest.NewRequest(method, target, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, target, nil)
}
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: userID, Role: "admin"})
return req.WithContext(ctx)
}
// notifChiRequest adds a chi URL param to a request context.
func notifChiRequest(r *http.Request, key, value string) *http.Request {
rctx := chi.NewRouteContext()
rctx.URLParams.Add(key, value)
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
// ---------------------------------------------------------------------------
// Tests: HandleList
// ---------------------------------------------------------------------------
func TestNotificationsHandleList_HappyPath_TwoRowsNoCursor(t *testing.T) {
now := time.Now().UTC()
store := &fakeNotificationsStore{
list: []*notifications.Notification{
{ID: 2, UserID: 1, Category: notifications.CategoryContent, Type: "content.added", Title: "New Movie", CreatedAt: now},
{ID: 1, UserID: 1, Category: notifications.CategoryRequest, Type: "request.approved", Title: "Approved", CreatedAt: now.Add(-time.Minute)},
},
}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-1")
h.HandleList(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
var resp struct {
Items []*notifications.Notification `json:"items"`
NextCursor *int64 `json:"next_cursor"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Items) != 2 {
t.Fatalf("items count = %d, want 2", len(resp.Items))
}
// len < limit (default 50), so next_cursor must be null.
if resp.NextCursor != nil {
t.Fatalf("next_cursor = %v, want nil (items < limit)", resp.NextCursor)
}
}
// ---------------------------------------------------------------------------
// Tests: HandleUnreadCount
// ---------------------------------------------------------------------------
func TestNotificationsHandleUnreadCount_ReturnsCount(t *testing.T) {
store := &fakeNotificationsStore{unreadCount: 7}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications/unread-count", nil, 1, "prof-1")
h.HandleUnreadCount(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var resp struct {
Count int `json:"count"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Count != 7 {
t.Fatalf("count = %d, want 7", resp.Count)
}
}
// ---------------------------------------------------------------------------
// Tests: HandleMarkRead
// ---------------------------------------------------------------------------
func TestNotificationsHandleMarkRead_EmptyBody_Returns400(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodPost, "/notifications/read", []byte(`{}`), 1, "prof-1")
h.HandleMarkRead(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
}
}
func TestNotificationsHandleMarkRead_AllTrue_Returns204(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodPost, "/notifications/read", []byte(`{"all":true}`), 1, "prof-1")
h.HandleMarkRead(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
}
if !store.markAllReadCalled {
t.Fatal("MarkAllRead was not called")
}
}
func TestNotificationsHandleMarkRead_IDs_Returns204(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodPost, "/notifications/read", []byte(`{"ids":[1,2,3]}`), 1, "prof-1")
h.HandleMarkRead(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
}
if !store.markReadCalled {
t.Fatal("MarkRead was not called")
}
}
// ---------------------------------------------------------------------------
// Tests: HandleDismiss
// ---------------------------------------------------------------------------
func TestNotificationsHandleDismiss_NotFound_Returns404(t *testing.T) {
store := &fakeNotificationsStore{dismissErr: notifications.ErrNotFound}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodPost, "/notifications/999/dismiss", nil, 1, "prof-1")
req = notifChiRequest(req, "id", "999")
h.HandleDismiss(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", rec.Code, rec.Body.String())
}
}
func TestNotificationsHandleDismiss_BadID_Returns400(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodPost, "/notifications/abc/dismiss", nil, 1, "prof-1")
req = notifChiRequest(req, "id", "abc")
h.HandleDismiss(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
}
}
// ---------------------------------------------------------------------------
// Tests: HandleGetPreferences
// ---------------------------------------------------------------------------
func TestNotificationsHandleGetPreferences_DefaultsReturned(t *testing.T) {
// No stored prefs → service returns all MutableCategories with defaults.
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications/preferences", nil, 1, "prof-1")
h.HandleGetPreferences(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
var resp struct {
Preferences []notifications.Preference `json:"preferences"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Preferences) != len(notifications.MutableCategories) {
t.Fatalf("prefs count = %d, want %d", len(resp.Preferences), len(notifications.MutableCategories))
}
prefMap := make(map[notifications.Category]bool)
for _, p := range resp.Preferences {
prefMap[p.Category] = p.Enabled
}
// content_digest defaults to false (opt-in); all others default to true.
for _, cat := range notifications.MutableCategories {
enabled, ok := prefMap[cat]
if !ok {
t.Errorf("category %q missing from preferences", cat)
continue
}
if cat == notifications.CategoryContentDigest {
if enabled {
t.Errorf("content_digest default should be false (opt-in), got true")
}
} else {
if !enabled {
t.Errorf("category %q default should be true, got false", cat)
}
}
}
}
// ---------------------------------------------------------------------------
// Tests: HandlePutPreferences
// ---------------------------------------------------------------------------
func TestNotificationsHandlePutPreferences_RejectsAnnouncementCategory_400(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
// "announcement" is not in MutableCategories.
body := `{"preferences":[{"category":"announcement","enabled":false}]}`
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodPut, "/notifications/preferences", []byte(body), 1, "prof-1")
h.HandlePutPreferences(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
}
}
func TestNotificationsHandlePutPreferences_ValidPrefs_204(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
body := `{"preferences":[{"category":"request","enabled":false},{"category":"content","enabled":true}]}`
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodPut, "/notifications/preferences", []byte(body), 1, "prof-1")
h.HandlePutPreferences(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
}
}
// ---------------------------------------------------------------------------
// Tests: Admin announcements
// ---------------------------------------------------------------------------
func TestNotificationsHandleCreateAnnouncement_201(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
body := `{"title":"Test Announcement","body":"Hello","audience":{"all":true}}`
rec := httptest.NewRecorder()
req := notifAdminRequest(http.MethodPost, "/admin/announcements", []byte(body), 1)
h.HandleCreateAnnouncement(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body: %s", rec.Code, rec.Body.String())
}
var resp notifications.Announcement
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Title != "Test Announcement" {
t.Fatalf("title = %q, want %q", resp.Title, "Test Announcement")
}
if resp.CreatedBy == nil || *resp.CreatedBy != 1 {
t.Fatalf("created_by = %v, want 1", resp.CreatedBy)
}
}
func TestNotificationsHandleCreateAnnouncement_ValidationError_400(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
// Empty title triggers service validation error.
body := `{"title":"","body":"Hello","audience":{"all":true}}`
rec := httptest.NewRecorder()
req := notifAdminRequest(http.MethodPost, "/admin/announcements", []byte(body), 1)
h.HandleCreateAnnouncement(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
}
}
func TestNotificationsHandleDeleteAnnouncement_204(t *testing.T) {
store := &fakeNotificationsStore{
announcements: []*notifications.Announcement{
{ID: 5, Title: "To Delete", Audience: notifications.Audience{All: true}},
},
}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifAdminRequest(http.MethodDelete, "/admin/announcements/5", nil, 1)
req = notifChiRequest(req, "id", "5")
h.HandleDeleteAnnouncement(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
}
}
func TestNotificationsHandleDeleteAnnouncement_NotFound_404(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifAdminRequest(http.MethodDelete, "/admin/announcements/999", nil, 1)
req = notifChiRequest(req, "id", "999")
h.HandleDeleteAnnouncement(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", rec.Code, rec.Body.String())
}
}
func TestNotificationsHandleListAnnouncements_ReturnsItems(t *testing.T) {
store := &fakeNotificationsStore{
announcements: []*notifications.Announcement{
{ID: 1, Title: "Hello", Audience: notifications.Audience{All: true}},
},
}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifAdminRequest(http.MethodGet, "/admin/announcements", nil, 1)
h.HandleListAnnouncements(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
var resp struct {
Items []*notifications.Announcement `json:"items"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Items) != 1 {
t.Fatalf("items count = %d, want 1", len(resp.Items))
}
}
// ---------------------------------------------------------------------------
// Tests: ChildSafe profile filtering
// ---------------------------------------------------------------------------
// TestNotificationsList_ChildProfileFiltered asserts that when the profile
// resolver reports IsChild=true, the ListFilter passed to the store has
// ChildSafe=true.
func TestNotificationsList_ChildProfileFiltered(t *testing.T) {
store := &fakeNotificationsStore{}
resolver := &fakeResolverAsProfileResolver{isChild: true}
h := newTestNotificationsHandlerWithResolver(store, resolver)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-child")
h.HandleList(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
if store.capturedFilter == nil {
t.Fatal("List was not called; capturedFilter is nil")
}
if !store.capturedFilter.ChildSafe {
t.Fatalf("capturedFilter.ChildSafe = false, want true for child profile")
}
}
// TestNotificationsList_AdultProfileNotFiltered asserts that a non-child
// profile leaves ChildSafe=false in the ListFilter.
func TestNotificationsList_AdultProfileNotFiltered(t *testing.T) {
store := &fakeNotificationsStore{}
resolver := &fakeResolverAsProfileResolver{isChild: false}
h := newTestNotificationsHandlerWithResolver(store, resolver)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-adult")
h.HandleList(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
if store.capturedFilter == nil {
t.Fatal("List was not called; capturedFilter is nil")
}
if store.capturedFilter.ChildSafe {
t.Fatalf("capturedFilter.ChildSafe = true, want false for non-child profile")
}
}
// TestNotificationsList_NilResolverDefaultsToFalse asserts that when no
// resolver is wired (nil), ChildSafe remains false.
func TestNotificationsList_NilResolverDefaultsToFalse(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store) // no resolver
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-1")
h.HandleList(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
if store.capturedFilter == nil {
t.Fatal("List was not called; capturedFilter is nil")
}
if store.capturedFilter.ChildSafe {
t.Fatalf("capturedFilter.ChildSafe = true, want false when no resolver is wired")
}
}
// TestNotificationsList_ResolverErrorFailsClosed asserts that when the resolver
// returns an error, ChildSafe=true (fail closed).
func TestNotificationsList_ResolverErrorFailsClosed(t *testing.T) {
store := &fakeNotificationsStore{}
resolver := &fakeResolverWithError{}
h := newTestNotificationsHandlerWithResolver(store, resolver)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-1")
h.HandleList(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
if store.capturedFilter == nil {
t.Fatal("List was not called; capturedFilter is nil")
}
if !store.capturedFilter.ChildSafe {
t.Fatalf("capturedFilter.ChildSafe = false, want true (fail closed on resolver error)")
}
}
// TestNotificationsList_NextCursorSet asserts that when limit=2 and the store
// returns exactly 2 notifications, next_cursor is set to the last item's ID.
func TestNotificationsList_NextCursorSet(t *testing.T) {
now := time.Now().UTC()
store := &fakeNotificationsStore{
list: []*notifications.Notification{
{ID: 12, UserID: 1, Category: notifications.CategoryContent, Type: "content.added", Title: "New Movie", CreatedAt: now},
{ID: 11, UserID: 1, Category: notifications.CategoryRequest, Type: "request.approved", Title: "Approved", CreatedAt: now.Add(-time.Minute)},
},
}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications?limit=2", nil, 1, "prof-1")
h.HandleList(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
var resp struct {
Items []*notifications.Notification `json:"items"`
NextCursor *int64 `json:"next_cursor"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Items) != 2 {
t.Fatalf("items count = %d, want 2", len(resp.Items))
}
// With limit=2 and 2 items returned, next_cursor should be set to the last item's ID (11).
if resp.NextCursor == nil {
t.Fatal("next_cursor is nil, want 11")
}
if *resp.NextCursor != 11 {
t.Fatalf("next_cursor = %d, want 11", *resp.NextCursor)
}
}
// Suppress unused import.
var _ = strings.Contains
+234
View File
@@ -0,0 +1,234 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
webpush "github.com/SherClockHolmes/webpush-go"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/push"
)
// maxPushTokenLen bounds a registered push token. APNs/FCM device tokens are
// short; a Web Push subscription JSON (endpoint + keys) is ~1 KB.
const maxPushTokenLen = 8192
// pushRegistry is the minimal interface PushHandler needs from *push.Store.
type pushRegistry interface {
RegisterToken(ctx context.Context, userID int, profileID, deviceID, transport, token string) error
RevokeToken(ctx context.Context, userID int, profileID, deviceID string) error
SetDeviceEnabled(ctx context.Context, userID int, deviceID string, enabled bool) error
ListDevices(ctx context.Context, userID int) ([]push.DeviceInfo, error)
}
// pushConfigReader is the minimal interface PushHandler needs from *push.Config.
type pushConfigReader interface {
WebPush(ctx context.Context) push.WebPushConfig
Status(ctx context.Context) push.Status
}
// pushNotifier is the minimal interface PushHandler needs to send system notifications.
type pushNotifier interface {
CreateSystem(ctx context.Context, userID int, typ, title, body string)
}
// PushHandler handles push notification registration, management, and status endpoints.
type PushHandler struct {
reg pushRegistry
config pushConfigReader
notifier pushNotifier
}
// NewPushHandler creates a new PushHandler. notifier may be nil (test-push endpoint
// returns 503 when nil).
func NewPushHandler(reg pushRegistry, config pushConfigReader, notifier pushNotifier) *PushHandler {
return &PushHandler{reg: reg, config: config, notifier: notifier}
}
// registerRequest is the request body for HandleRegister.
type registerRequest struct {
Transport string `json:"transport"`
Token string `json:"token"`
}
// deviceContext extracts the authenticated user, device id, and active profile
// required by the profile-scoped push device endpoints. It writes a 400 and
// returns ok=false when any of the three is missing.
func (h *PushHandler) deviceContext(w http.ResponseWriter, r *http.Request) (userID int, deviceID, profileID string, ok bool) {
userID = apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "Authentication required")
return 0, "", "", false
}
deviceID = strings.TrimSpace(r.Header.Get(deviceIDHeader))
if deviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "X-Silo-Device-Id header is required")
return 0, "", "", false
}
profileID = strings.TrimSpace(apimw.GetProfileID(r.Context()))
if profileID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "X-Profile-Id header is required")
return 0, "", "", false
}
return userID, deviceID, profileID, true
}
// HandleRegister handles PUT /notifications/push/device.
// Requires userID (from JWT claims), X-Silo-Device-Id, and X-Profile-Id headers.
// Body: {"transport": "apns"|"fcm"|"webpush", "token": "..."}
// Returns 204 on success.
func (h *PushHandler) HandleRegister(w http.ResponseWriter, r *http.Request) {
userID, deviceID, profileID, ok := h.deviceContext(w, r)
if !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
var req registerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
switch req.Transport {
case push.TransportAPNs, push.TransportFCM, push.TransportWebPush:
// valid
default:
writeError(w, http.StatusBadRequest, "bad_request", "transport must be one of apns, fcm, webpush")
return
}
if req.Token == "" {
writeError(w, http.StatusBadRequest, "bad_request", "token must not be empty")
return
}
// Cap token length: APNs/FCM device tokens are short; a Web Push subscription
// JSON (endpoint + keys) is ~1 KB. 8 KB is generous and bounds the stored row.
if len(req.Token) > maxPushTokenLen {
writeError(w, http.StatusBadRequest, "bad_request", "token too large")
return
}
if err := h.reg.RegisterToken(r.Context(), userID, profileID, deviceID, req.Transport, req.Token); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to register push token")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleRevoke handles DELETE /notifications/push/device.
// Requires userID, X-Silo-Device-Id, and X-Profile-Id headers.
// Returns 204 on success.
func (h *PushHandler) HandleRevoke(w http.ResponseWriter, r *http.Request) {
userID, deviceID, profileID, ok := h.deviceContext(w, r)
if !ok {
return
}
if err := h.reg.RevokeToken(r.Context(), userID, profileID, deviceID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to revoke push token")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleListDevices handles GET /notifications/push/devices.
// Response: {"devices": [...]}
func (h *PushHandler) HandleListDevices(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
devices, err := h.reg.ListDevices(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list devices")
return
}
if devices == nil {
devices = []push.DeviceInfo{}
}
writeJSON(w, http.StatusOK, struct {
Devices []push.DeviceInfo `json:"devices"`
}{Devices: devices})
}
// toggleDeviceRequest is the request body for HandleToggleDevice.
type toggleDeviceRequest struct {
Enabled *bool `json:"enabled"`
}
// HandleToggleDevice handles PUT /notifications/push/devices/{device_id}.
// Body: {"enabled": true|false}
// Returns 204 on success, 404 if device not found.
func (h *PushHandler) HandleToggleDevice(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
deviceID := chi.URLParam(r, "device_id")
var req toggleDeviceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if req.Enabled == nil {
writeError(w, http.StatusBadRequest, "bad_request", "enabled is required")
return
}
if err := h.reg.SetDeviceEnabled(r.Context(), userID, deviceID, *req.Enabled); err != nil {
if errors.Is(err, push.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Device not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update device")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleWebPushKey handles GET /notifications/push/webpush-key.
// Response: {"vapid_public_key": "..."} — empty string when not configured.
func (h *PushHandler) HandleWebPushKey(w http.ResponseWriter, r *http.Request) {
cfg := h.config.WebPush(r.Context())
writeJSON(w, http.StatusOK, struct {
VAPIDPublicKey string `json:"vapid_public_key"`
}{VAPIDPublicKey: cfg.VAPIDPublic})
}
// HandleAdminStatus handles GET /admin/push/status.
// Response: {"apns": bool, "fcm": bool, "webpush": bool}
func (h *PushHandler) HandleAdminStatus(w http.ResponseWriter, r *http.Request) {
status := h.config.Status(r.Context())
writeJSON(w, http.StatusOK, status)
}
// HandleGenerateVAPIDKeys handles POST /admin/push/generate-vapid-keys.
// Generates a fresh VAPID key pair and returns it as JSON.
// Response: {"vapid_public": "...", "vapid_private": "..."}
func (h *PushHandler) HandleGenerateVAPIDKeys(w http.ResponseWriter, r *http.Request) {
// GenerateVAPIDKeys returns (privateKey, publicKey string, err error).
priv, pub, err := webpush.GenerateVAPIDKeys()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate keys")
return
}
writeJSON(w, http.StatusOK, map[string]string{"vapid_public": pub, "vapid_private": priv})
}
// HandleSendTestPush handles POST /admin/push/test.
// Sends a system test push notification to the authenticated admin user.
func (h *PushHandler) HandleSendTestPush(w http.ResponseWriter, r *http.Request) {
if h.notifier == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "notifications unavailable")
return
}
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "auth required")
return
}
h.notifier.CreateSystem(r.Context(), userID, "system.test_push", "Test push",
"If you can see this on a device, push delivery is working.")
w.WriteHeader(http.StatusAccepted)
}
+496
View File
@@ -0,0 +1,496 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/push"
)
// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------
type fakePushRegistry struct {
registerErr error
revokeErr error
enableErr error
devices []push.DeviceInfo
// capture last RegisterToken call
lastUserID int
lastProfileID string
lastDeviceID string
lastTransport string
lastToken string
}
func (f *fakePushRegistry) RegisterToken(_ context.Context, userID int, profileID, deviceID, transport, token string) error {
f.lastUserID = userID
f.lastProfileID = profileID
f.lastDeviceID = deviceID
f.lastTransport = transport
f.lastToken = token
return f.registerErr
}
func (f *fakePushRegistry) RevokeToken(_ context.Context, _ int, _, _ string) error {
return f.revokeErr
}
func (f *fakePushRegistry) SetDeviceEnabled(_ context.Context, _ int, _ string, _ bool) error {
return f.enableErr
}
func (f *fakePushRegistry) ListDevices(_ context.Context, _ int) ([]push.DeviceInfo, error) {
return f.devices, nil
}
type fakePushConfig struct {
webPush push.WebPushConfig
status push.Status
}
func (f *fakePushConfig) WebPush(_ context.Context) push.WebPushConfig { return f.webPush }
func (f *fakePushConfig) Status(_ context.Context) push.Status { return f.status }
type fakeNotifier struct {
calls []fakeNotifierCall
}
type fakeNotifierCall struct {
userID int
typ string
title string
body string
}
func (f *fakeNotifier) CreateSystem(_ context.Context, userID int, typ, title, body string) {
f.calls = append(f.calls, fakeNotifierCall{userID: userID, typ: typ, title: title, body: body})
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// pushUserRequest builds a request with JWT claims (userID) and optionally
// sets the device-id and profile-id headers.
func pushUserRequest(method, target string, body []byte, userID int, profileID, deviceID string) *http.Request {
var req *http.Request
if len(body) > 0 {
req = httptest.NewRequest(method, target, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, target, nil)
}
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: userID, Role: "user"})
if profileID != "" {
ctx = apimw.SetProfileID(ctx, profileID)
}
req = req.WithContext(ctx)
if deviceID != "" {
req.Header.Set(deviceIDHeader, deviceID)
}
return req
}
// pushChiRequest injects a chi URL parameter into the request context.
func pushChiRequest(r *http.Request, key, value string) *http.Request {
rctx := chi.NewRouteContext()
rctx.URLParams.Add(key, value)
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
// newPushHandler builds a PushHandler backed by the given fakes.
func newPushHandler(reg *fakePushRegistry, cfg *fakePushConfig) *PushHandler {
return NewPushHandler(reg, cfg, nil)
}
// ---------------------------------------------------------------------------
// HandleRegister
// ---------------------------------------------------------------------------
func TestPushHandleRegister_MissingDeviceID_Returns400(t *testing.T) {
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]string{"transport": "apns", "token": "tok"})
// no device header
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 1, "prof-1", "")
h.HandleRegister(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestPushHandleRegister_MissingProfileID_Returns400(t *testing.T) {
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]string{"transport": "apns", "token": "tok"})
// device header set but no profile
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 1, "", "dev-1")
h.HandleRegister(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestPushHandleRegister_BadTransport_Returns400(t *testing.T) {
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]string{"transport": "smoke-signals", "token": "tok"})
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 1, "prof-1", "dev-1")
h.HandleRegister(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestPushHandleRegister_EmptyToken_Returns400(t *testing.T) {
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]string{"transport": "apns", "token": ""})
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 1, "prof-1", "dev-1")
h.HandleRegister(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestPushHandleRegister_HappyPath_Returns204(t *testing.T) {
reg := &fakePushRegistry{}
h := newPushHandler(reg, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]string{"transport": "fcm", "token": "my-fcm-token"})
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 42, "prof-99", "dev-abc")
h.HandleRegister(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
}
if reg.lastUserID != 42 {
t.Errorf("userID = %d, want 42", reg.lastUserID)
}
if reg.lastProfileID != "prof-99" {
t.Errorf("profileID = %q, want prof-99", reg.lastProfileID)
}
if reg.lastDeviceID != "dev-abc" {
t.Errorf("deviceID = %q, want dev-abc", reg.lastDeviceID)
}
if reg.lastTransport != "fcm" {
t.Errorf("transport = %q, want fcm", reg.lastTransport)
}
if reg.lastToken != "my-fcm-token" {
t.Errorf("token = %q, want my-fcm-token", reg.lastToken)
}
}
func TestPushHandleRegister_WebPushTransport_Returns204(t *testing.T) {
reg := &fakePushRegistry{}
h := newPushHandler(reg, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]string{"transport": "webpush", "token": "{}"})
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 1, "prof-1", "dev-1")
h.HandleRegister(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
}
// ---------------------------------------------------------------------------
// HandleRevoke
// ---------------------------------------------------------------------------
func TestPushHandleRevoke_HappyPath_Returns204(t *testing.T) {
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodDelete, "/notifications/push/device", nil, 1, "prof-1", "dev-1")
h.HandleRevoke(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
}
func TestPushHandleRevoke_MissingDevice_Returns400(t *testing.T) {
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodDelete, "/notifications/push/device", nil, 1, "prof-1", "")
h.HandleRevoke(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
// ---------------------------------------------------------------------------
// HandleListDevices
// ---------------------------------------------------------------------------
func TestPushHandleListDevices_ReturnsDevicesJSON(t *testing.T) {
now := time.Now().UTC()
reg := &fakePushRegistry{
devices: []push.DeviceInfo{
{DeviceID: "dev-1", Name: "iPhone", Platform: "ios", Transport: push.TransportAPNs, PushEnabled: true, RegisteredAt: &now},
{DeviceID: "dev-2", Name: "Chrome", Platform: "web", Transport: push.TransportWebPush, PushEnabled: false},
},
}
h := newPushHandler(reg, &fakePushConfig{})
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodGet, "/notifications/push/devices", nil, 5, "prof-1", "")
h.HandleListDevices(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var resp struct {
Devices []push.DeviceInfo `json:"devices"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Devices) != 2 {
t.Fatalf("devices count = %d, want 2", len(resp.Devices))
}
if resp.Devices[0].DeviceID != "dev-1" {
t.Errorf("devices[0].DeviceID = %q, want dev-1", resp.Devices[0].DeviceID)
}
}
func TestPushHandleListDevices_EmptySlice_ReturnsEmptyArray(t *testing.T) {
reg := &fakePushRegistry{devices: nil}
h := newPushHandler(reg, &fakePushConfig{})
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodGet, "/notifications/push/devices", nil, 1, "", "")
h.HandleListDevices(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
// Must produce {"devices":[]} not {"devices":null}
if !containsJSON(body, `"devices":[]`) {
t.Errorf("expected empty array in response, got: %s", body)
}
}
// ---------------------------------------------------------------------------
// HandleToggleDevice
// ---------------------------------------------------------------------------
func TestPushHandleToggleDevice_HappyPath_Returns204(t *testing.T) {
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]bool{"enabled": true})
req := pushUserRequest(http.MethodPut, "/notifications/push/devices/dev-1", body, 1, "", "")
req = pushChiRequest(req, "device_id", "dev-1")
h.HandleToggleDevice(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
}
}
func TestPushHandleToggleDevice_NotFound_Returns404(t *testing.T) {
reg := &fakePushRegistry{enableErr: push.ErrNotFound}
h := newPushHandler(reg, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]bool{"enabled": false})
req := pushUserRequest(http.MethodPut, "/notifications/push/devices/dev-x", body, 1, "", "")
req = pushChiRequest(req, "device_id", "dev-x")
h.HandleToggleDevice(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", rec.Code, rec.Body.String())
}
}
func TestPushHandleToggleDevice_WrappedNotFound_Returns404(t *testing.T) {
reg := &fakePushRegistry{enableErr: errors.Join(errors.New("outer"), push.ErrNotFound)}
h := newPushHandler(reg, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]bool{"enabled": false})
req := pushUserRequest(http.MethodPut, "/notifications/push/devices/dev-x", body, 1, "", "")
req = pushChiRequest(req, "device_id", "dev-x")
h.HandleToggleDevice(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", rec.Code, rec.Body.String())
}
}
// ---------------------------------------------------------------------------
// HandleWebPushKey
// ---------------------------------------------------------------------------
func TestPushHandleWebPushKey_ReturnsConfiguredKey(t *testing.T) {
cfg := &fakePushConfig{webPush: push.WebPushConfig{VAPIDPublic: "BPublicKey123", VAPIDPrivate: "priv", Subject: "mailto:a@b.com"}}
h := newPushHandler(&fakePushRegistry{}, cfg)
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodGet, "/notifications/push/webpush-key", nil, 1, "", "")
h.HandleWebPushKey(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var resp struct {
VAPIDPublicKey string `json:"vapid_public_key"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.VAPIDPublicKey != "BPublicKey123" {
t.Errorf("vapid_public_key = %q, want BPublicKey123", resp.VAPIDPublicKey)
}
}
func TestPushHandleWebPushKey_Unconfigured_ReturnsEmptyString(t *testing.T) {
cfg := &fakePushConfig{webPush: push.WebPushConfig{}}
h := newPushHandler(&fakePushRegistry{}, cfg)
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodGet, "/notifications/push/webpush-key", nil, 0, "", "")
h.HandleWebPushKey(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var resp struct {
VAPIDPublicKey string `json:"vapid_public_key"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.VAPIDPublicKey != "" {
t.Errorf("vapid_public_key = %q, want empty string", resp.VAPIDPublicKey)
}
}
// ---------------------------------------------------------------------------
// HandleAdminStatus
// ---------------------------------------------------------------------------
func TestPushHandleAdminStatus_ReturnsBooleans(t *testing.T) {
cfg := &fakePushConfig{status: push.Status{APNs: true, FCM: false, WebPush: true}}
h := newPushHandler(&fakePushRegistry{}, cfg)
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodGet, "/admin/push/status", nil, 1, "", "")
h.HandleAdminStatus(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var resp push.Status
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.APNs {
t.Error("apns = false, want true")
}
if resp.FCM {
t.Error("fcm = true, want false")
}
if !resp.WebPush {
t.Error("webpush = false, want true")
}
}
// ---------------------------------------------------------------------------
// HandleGenerateVAPIDKeys
// ---------------------------------------------------------------------------
func TestPushHandleGenerateVAPIDKeys_ReturnsPair(t *testing.T) {
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, nil)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/push/generate-vapid-keys", nil)
h.HandleGenerateVAPIDKeys(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
var resp struct {
VAPIDPublic string `json:"vapid_public"`
VAPIDPrivate string `json:"vapid_private"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.VAPIDPublic == "" {
t.Error("vapid_public is empty")
}
if resp.VAPIDPrivate == "" {
t.Error("vapid_private is empty")
}
if resp.VAPIDPublic == resp.VAPIDPrivate {
t.Errorf("vapid_public and vapid_private are identical: %q", resp.VAPIDPublic)
}
}
// ---------------------------------------------------------------------------
// HandleSendTestPush
// ---------------------------------------------------------------------------
func TestPushHandleSendTestPush_CreatesForCaller(t *testing.T) {
n := &fakeNotifier{}
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, n)
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodPost, "/admin/push/test", nil, 7, "", "")
h.HandleSendTestPush(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202; body: %s", rec.Code, rec.Body.String())
}
if len(n.calls) != 1 {
t.Fatalf("CreateSystem called %d times, want 1", len(n.calls))
}
call := n.calls[0]
if call.userID != 7 {
t.Errorf("userID = %d, want 7", call.userID)
}
if call.typ != "system.test_push" {
t.Errorf("typ = %q, want system.test_push", call.typ)
}
}
func TestPushHandleSendTestPush_NilNotifier503(t *testing.T) {
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, nil)
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodPost, "/admin/push/test", nil, 1, "", "")
h.HandleSendTestPush(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503; body: %s", rec.Code, rec.Body.String())
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func mustMarshal(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
return b
}
// containsJSON checks that s contains the substring (after stripping whitespace
// equivalents that json.Marshal doesn't emit — good enough for our payloads).
func containsJSON(s, sub string) bool {
return len(s) > 0 && (len(sub) == 0 || len(s) >= len(sub) && func() bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}())
}
+68
View File
@@ -48,6 +48,8 @@ import (
"github.com/Silo-Server/silo-server/internal/opslog"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/plugins"
"github.com/Silo-Server/silo-server/internal/presence"
"github.com/Silo-Server/silo-server/internal/push"
"github.com/Silo-Server/silo-server/internal/ratelimit"
"github.com/Silo-Server/silo-server/internal/recommendations"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
@@ -175,6 +177,23 @@ type Dependencies struct {
// clients hitting /login, /api/*, /abs/api/*, and /abs/socket.io/* all
// resolve correctly. May be nil; no ABS routes are registered in that case.
ABSHandler absHandler
// NotificationsService is the notifications domain service used to power
// the inbox, preferences, and announcement endpoints. May be nil; routes
// are not registered in that case.
NotificationsService *notifications.Service
// PushStore is the push token/device registry. May be nil; push routes
// are not registered in that case.
PushStore *push.Store
// PushConfig reads push provider configuration from the settings repo.
// May be nil; push routes are not registered when PushStore is nil.
PushConfig *push.Config
// PresenceRegistry tracks live WebSocket connections for presence-aware
// push suppression. May be nil; a no-op fallback is used in that case.
PresenceRegistry presence.Registry
}
// absHandler is the narrow interface the router needs from the ABS handler.
@@ -497,6 +516,7 @@ func NewRouter(deps Dependencies) chi.Router {
mediarequests.NewCatalogPresence(itemRepo, providerIDRepo),
)
AttachRequestRouter(requestSvc, deps.PluginService)
requestSvc.SetEventsHub(deps.EventsHub)
requestSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB)))
if viewerResolver != nil {
requestSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(viewerResolver))
@@ -1493,6 +1513,8 @@ func NewRouter(deps Dependencies) chi.Router {
deps.ScanRegistry,
deps.LibraryScanQueue,
historyImportSvc,
deps.NotificationsService,
deps.PresenceRegistry,
)
r.Get("/events/ws", eventsHandler.HandleWebSocket)
}
@@ -1641,6 +1663,36 @@ func NewRouter(deps Dependencies) chi.Router {
}
}
// Notifications inbox and preferences (profile-scoped).
if deps.NotificationsService != nil {
notificationsHandler := handlers.NewNotificationsHandler(deps.NotificationsService, deps.UserStoreProvider)
r.Route("/notifications", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/", notificationsHandler.HandleList)
r.Get("/unread-count", notificationsHandler.HandleUnreadCount)
r.Post("/read", notificationsHandler.HandleMarkRead)
r.Post("/{id}/dismiss", notificationsHandler.HandleDismiss)
r.Get("/preferences", notificationsHandler.HandleGetPreferences)
r.Put("/preferences", notificationsHandler.HandlePutPreferences)
})
}
// Push registration, device management, and VAPID key.
// Register and revoke are profile-scoped: they store a profileID alongside the
// push token so that notifications are delivered to the correct profile.
// RequireProfile is applied per-route (not via r.Use) to leave the three
// user-scoped endpoints (devices list, toggle, webpush-key) without it.
if deps.PushStore != nil && deps.PushConfig != nil {
pushHandler := handlers.NewPushHandler(deps.PushStore, deps.PushConfig, deps.NotificationsService)
r.Route("/notifications/push", func(r chi.Router) {
r.With(apimw.RequireProfile).Put("/device", pushHandler.HandleRegister)
r.With(apimw.RequireProfile).Delete("/device", pushHandler.HandleRevoke)
r.Get("/devices", pushHandler.HandleListDevices)
r.Put("/devices/{device_id}", pushHandler.HandleToggleDevice)
r.Get("/webpush-key", pushHandler.HandleWebPushKey)
})
}
// Progress and sync routes (profile-scoped).
if progressHandler != nil {
r.Route("/progress", func(r chi.Router) {
@@ -2395,6 +2447,22 @@ func NewRouter(deps Dependencies) chi.Router {
r.Get("/logs/audit", adminLogsHandler.HandleListAuditLogs)
r.Get("/logs/ws", adminLogsHandler.HandleLogStreamWebSocket)
}
if deps.NotificationsService != nil {
notificationsHandler := handlers.NewNotificationsHandler(deps.NotificationsService, deps.UserStoreProvider)
r.Route("/announcements", func(r chi.Router) {
r.Get("/", notificationsHandler.HandleListAnnouncements)
r.Post("/", notificationsHandler.HandleCreateAnnouncement)
r.Delete("/{id}", notificationsHandler.HandleDeleteAnnouncement)
})
}
if deps.PushStore != nil && deps.PushConfig != nil {
pushHandler := handlers.NewPushHandler(deps.PushStore, deps.PushConfig, deps.NotificationsService)
r.Get("/push/status", pushHandler.HandleAdminStatus)
r.Post("/push/generate-vapid-keys", pushHandler.HandleGenerateVAPIDKeys)
r.Post("/push/test", pushHandler.HandleSendTestPush)
}
if adminPlaybackControlHandler != nil {
r.Post("/sessions/{session_id}/pause", adminPlaybackControlHandler.HandlePauseSession)
r.Post("/sessions/{session_id}/resume", adminPlaybackControlHandler.HandleResumeSession)
@@ -91,6 +91,16 @@ var SensitiveSettingKeys = map[string]bool{
// still referenced by older request_integrations rows until backfilled).
"requests.radarr.api_key": true,
"requests.sonarr.api_key": true,
// Push notification provider credentials.
"push.apns.p8_key": true,
"push.apns.key_id": true,
"push.apns.team_id": true,
"push.apns.bundle_id": true,
"push.fcm.service_account_json": true,
"push.webpush.vapid_private": true,
"push.webpush.vapid_public": true,
"push.webpush.subject": true,
}
// EncryptedSettingsRepo decorates a raw settings store, transparently
+4
View File
@@ -16,6 +16,8 @@ const (
ChannelHistoryImport EventChannel = "history_import"
ChannelUserState EventChannel = "user_state"
ChannelPlugins EventChannel = "plugins"
ChannelNotifications EventChannel = "notifications"
ChannelRequests EventChannel = "requests"
)
var AllChannels = []EventChannel{
@@ -27,6 +29,8 @@ var AllChannels = []EventChannel{
ChannelHistoryImport,
ChannelUserState,
ChannelPlugins,
ChannelNotifications,
ChannelRequests,
}
type Envelope struct {
+101
View File
@@ -0,0 +1,101 @@
package notifications
import (
"context"
"encoding/json"
"fmt"
"time"
evt "github.com/Silo-Server/silo-server/internal/events"
)
const (
eventScanFailed = "scan.failed"
adminTasksLink = "/admin/tasks"
)
// adminAlertEvents maps failure event names to their notification titles.
// Keys are verified against real publishers:
// - "job.failed" → hub.go PublishJob on ChannelJobs (models.AdminJob payload)
// - "scan.failed" → libraries.go markScanFailed on ChannelScans (evt.ScanRun payload)
//
// Absent (no real event): task.failed (only task.updated exists), plugin failure events.
var adminAlertEvents = map[string]string{
string(TypeJobFailed): "Background job failed",
eventScanFailed: "Library scan failed",
}
// adminAlertChannels lists the only channels whose envelopes the admin matcher
// will process. Events arriving on any other channel are ignored even if the
// event name matches.
var adminAlertChannels = map[evt.EventChannel]bool{
evt.ChannelJobs: true,
evt.ChannelScans: true,
evt.ChannelTasks: true, // no task failure event exists yet (only task.updated); kept so a future task.failed event needs only a map entry
}
// matchAdmin fans failure events out to all admin users. A day-bucketed
// dedup_ref throttles repeats of the same failure to one notification per hour.
func (m *Materializer) matchAdmin(ctx context.Context, env evt.Envelope) error {
if !adminAlertChannels[env.Channel] {
return nil
}
title, ok := adminAlertEvents[env.Event]
if !ok {
return nil
}
// Best-effort decode of the failure payload to extract id and kind.
// For job.failed the payload is models.AdminJob (json tags: "id", "job_type").
// For scan.failed the payload is evt.ScanRun (json tags: "id", "mode").
var payload struct {
ID string `json:"id"`
JobType string `json:"job_type"` // models.AdminJob
Mode string `json:"mode"` // evt.ScanRun
}
if len(env.Data) > 0 {
_ = json.Unmarshal(env.Data, &payload)
}
// source is the "kind" of failure — job_type for jobs, mode for scans.
source := payload.JobType
if source == "" {
source = payload.Mode
}
if source == "" {
source = env.Event
}
body := source
if payload.ID != "" {
body = fmt.Sprintf("%s (%s)", source, payload.ID)
}
link := adminTasksLink
// Hourly dedup bucket: one alert per (event, source) per clock-hour.
dedupBucket := time.Now().UTC().Format("2006010215")
dedupRef := fmt.Sprintf("%s:%s:%s", env.Event, source, dedupBucket)
admins, err := m.svc.store.AdminUserIDs(ctx)
if err != nil {
return fmt.Errorf("matchAdmin: AdminUserIDs: %w", err)
}
for _, userID := range admins {
if err := m.svc.Create(ctx, CreateInput{
UserID: userID,
Category: CategoryAdmin,
Type: env.Event,
Title: title,
Body: body,
Link: link,
SourceEvent: env.Event,
DedupRef: dedupRef,
}); err != nil {
return fmt.Errorf("matchAdmin: Create for user %d: %w", userID, err)
}
}
return nil
}
+131
View File
@@ -0,0 +1,131 @@
package notifications
import (
"context"
"encoding/json"
"fmt"
"time"
evt "github.com/Silo-Server/silo-server/internal/events"
)
// changeIsAddition returns true for change values that represent a newly-added
// catalog item. All callers in this codebase use "metadata_updated" for both
// new and re-scanned items; there is no separate "item_added" value.
func changeIsAddition(change string) bool {
return change == metadataUpdated || change == "item_added"
}
const inProgressWindow = 21 * 24 * time.Hour
// newItemWindow is the maximum age of a catalog row that is still considered a
// new arrival. metadata_updated events fired by periodic library refreshes on
// older items are suppressed.
const newItemWindow = 48 * time.Hour
const metadataUpdated = "metadata_updated"
// ProfileRef identifies a specific user+profile pair to notify.
type ProfileRef struct {
UserID int
ProfileID string
}
// ContentResolver answers "who cares about this item" from catalog state.
type ContentResolver interface {
// ItemContext resolves the added item's display title, its series/parent
// content id (empty when standalone), library id (fallback when the event
// lacks one), and catalog row creation time. createdAt is the catalog row's
// creation time, used to distinguish new arrivals from metadata refreshes of
// old items.
ItemContext(ctx context.Context, contentID string) (title string, seriesID string, libraryID int, createdAt time.Time, err error)
// InterestedProfiles returns (user_id, profile_id) pairs whose watchlist
// or favorites contain contentID or seriesID, plus profiles with playback
// of the series within the in-progress window, restricted to users with
// access to libraryID.
InterestedProfiles(ctx context.Context, contentID, seriesID string, libraryID int, inProgressSince time.Time) ([]ProfileRef, error)
}
// catalogItemPayload is the JSON shape of a catalog.item.changed event.
type catalogItemPayload struct {
LibraryID int `json:"library_id"`
ContentID string `json:"content_id"`
Change string `json:"change"`
}
// matchContent handles catalog-channel catalog.item.changed events and creates
// content.added notifications for every interested profile.
func (m *Materializer) matchContent(ctx context.Context, env evt.Envelope) error {
if env.Channel != evt.ChannelCatalog || env.Event != string(TypeCatalogItemChanged) {
return nil
}
var payload catalogItemPayload
if err := json.Unmarshal(env.Data, &payload); err != nil {
return fmt.Errorf("content matcher: decode catalog.item.changed: %w", err)
}
if !changeIsAddition(payload.Change) {
return nil
}
contentID := payload.ContentID
if contentID == "" {
return nil
}
if m.content == nil {
return nil
}
title, seriesID, libraryID, createdAt, err := m.content.ItemContext(ctx, contentID)
if err != nil {
return fmt.Errorf("content matcher: item context for %s: %w", contentID, err)
}
// Suppress notifications for items whose catalog row is older than
// newItemWindow: metadata_updated also fires during periodic library
// refreshes of existing items, which would otherwise re-notify users.
if time.Since(createdAt) > newItemWindow {
return nil // metadata refresh of an old item, not a new arrival
}
// payload.LibraryID is authoritative; only fall back to the resolver's
// libraryID when the event omits one (payload.LibraryID == 0).
if payload.LibraryID != 0 {
libraryID = payload.LibraryID
}
inProgressSince := time.Now().UTC().Add(-inProgressWindow)
refs, err := m.content.InterestedProfiles(ctx, contentID, seriesID, libraryID, inProgressSince)
if err != nil {
return fmt.Errorf("content matcher: interested profiles for %s: %w", contentID, err)
}
// dayBucket caps refresh-driven repeats at 1 per day (within the 48h recency
// window a midnight crossing produces a new key, but next week's episode
// always gets a new date = new dedup key, so ongoing series still notify).
dayBucket := time.Now().UTC().Format("20060102")
// groupID collapses all episodes of a series into a single burst group.
groupID := contentID
if seriesID != "" {
groupID = seriesID
}
for _, ref := range refs {
dedupRef := fmt.Sprintf("%s:%s:%s", groupID, ref.ProfileID, dayBucket)
if err := m.svc.Create(ctx, CreateInput{
UserID: ref.UserID,
ProfileID: ref.ProfileID,
Category: CategoryContent,
Type: "content.added",
Title: "New content for you",
Body: title,
Link: "/items/" + contentID,
ItemID: contentID,
SourceEvent: env.Event,
DedupRef: dedupRef,
}); err != nil {
return fmt.Errorf("content matcher: create notification for user %d profile %s: %w", ref.UserID, ref.ProfileID, err)
}
}
return nil
}
+74
View File
@@ -0,0 +1,74 @@
package notifications
import (
"context"
"encoding/json"
"fmt"
evt "github.com/Silo-Server/silo-server/internal/events"
)
// RequestEventData mirrors requests.RequestEventPayload (kept separate to
// avoid an import cycle: requests must not import notifications).
// MediaType and Quality are carried for completeness of the mirror; matchers do not use them yet.
type RequestEventData struct {
RequestID string `json:"request_id"`
UserID int `json:"user_id"`
ProfileID string `json:"profile_id,omitempty"`
Title string `json:"title"`
MediaType string `json:"media_type"`
Quality string `json:"quality,omitempty"`
Status string `json:"status"`
}
var requestEventTitles = map[string]string{
"request.approved": "Request approved",
"request.declined": "Request declined",
"request.completed": "Request available",
"request.failed": "Request failed",
}
func (m *Materializer) matchRequest(ctx context.Context, env evt.Envelope) error {
if env.Channel != evt.ChannelRequests {
return nil
}
title, notify := requestEventTitles[env.Event]
if !notify {
return nil // request.submitted / request.cancelled: requester acted, no notification
}
var data RequestEventData
if err := json.Unmarshal(env.Data, &data); err != nil {
return fmt.Errorf("decode %s: %w", env.Event, err)
}
if data.UserID <= 0 {
return fmt.Errorf("%s without user_id", env.Event)
}
if data.RequestID == "" {
return fmt.Errorf("%s without request_id", env.Event)
}
return m.svc.Create(ctx, CreateInput{
UserID: data.UserID,
ProfileID: data.ProfileID,
Category: CategoryRequest,
Type: env.Event,
Title: title,
Body: data.Title,
Link: "/requests",
SourceEvent: env.Event,
DedupRef: fmt.Sprintf("%s:%s", data.RequestID, statusSuffix(env.Event)),
})
}
func statusSuffix(event string) string {
switch event {
case "request.approved":
return "approved"
case "request.declined":
return "declined"
case "request.completed":
return "completed"
case "request.failed":
return "failed"
}
return event
}
+52
View File
@@ -0,0 +1,52 @@
package notifications
import (
"context"
"encoding/json"
"log/slog"
"strings"
evt "github.com/Silo-Server/silo-server/internal/events"
)
// sendPayload is the documented notifications.send contract for plugins.
type sendPayload struct {
UserID int `json:"user_id"`
ProfileID string `json:"profile_id,omitempty"`
Category string `json:"category"`
Type string `json:"type"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
Link string `json:"link,omitempty"`
DedupRef string `json:"dedup_ref,omitempty"`
}
// matchSend consumes "notifications.send" events. Plugin-published events
// arrive prefixed ("plugin.<id>.notifications.send"); both forms accepted.
// Only ChannelPlugins envelopes are considered — the contract is plugin-facing;
// core code calls Service.Create directly.
// Malformed payloads are dropped with a warning — plugin bugs must not error
// core paths. Plugins may not create announcements.
func (m *Materializer) matchSend(ctx context.Context, env evt.Envelope) error {
if env.Channel != evt.ChannelPlugins {
return nil
}
if env.Event != "notifications.send" && !strings.HasSuffix(env.Event, ".notifications.send") {
return nil
}
var p sendPayload
if err := json.Unmarshal(env.Data, &p); err != nil {
slog.WarnContext(ctx, "notifications: malformed notifications.send dropped", "event", env.Event, "error", err)
return nil
}
cat := Category(p.Category)
if p.UserID <= 0 || p.Type == "" || p.Title == "" || !validCategory(cat) || cat == CategoryAnnouncement {
slog.WarnContext(ctx, "notifications: invalid notifications.send dropped", "event", env.Event, "user_id", p.UserID)
return nil
}
return m.svc.Create(ctx, CreateInput{
UserID: p.UserID, ProfileID: p.ProfileID, Category: cat, Type: p.Type,
Title: p.Title, Body: p.Body, Link: p.Link,
SourceEvent: env.Event, DedupRef: p.DedupRef,
})
}
+25
View File
@@ -0,0 +1,25 @@
package notifications
import (
"context"
"fmt"
evt "github.com/Silo-Server/silo-server/internal/events"
)
// matchSystem materializes account-security notifications from domain events.
// Currently the only source is a password change published by the admin user
// handler on ChannelSessions; keeping it event-driven means the admin handler
// has no dependency on the notifications service.
func (m *Materializer) matchSystem(ctx context.Context, env evt.Envelope) error {
if env.Channel != evt.ChannelSessions || env.Event != EventUserPasswordChanged {
return nil
}
if env.UserID <= 0 {
return fmt.Errorf("%s without user_id", EventUserPasswordChanged)
}
m.svc.CreateSystem(ctx, env.UserID,
"system.password_changed", "Password changed",
"Your account password was changed. If this wasn't you, contact your administrator.")
return nil
}
+678
View File
@@ -0,0 +1,678 @@
package notifications
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
evt "github.com/Silo-Server/silo-server/internal/events"
)
func publishAndSettle(t *testing.T, hub *evt.Hub, m *Materializer, env evt.Envelope) {
t.Helper()
before := m.Processed()
if err := hub.Publish(context.Background(), env); err != nil {
t.Fatalf("publish: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for m.Processed() <= before && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if m.Processed() <= before {
t.Fatalf("materializer did not process the envelope in time")
}
}
func TestSendContract_CreatesNotification(t *testing.T) {
store := &fakeStore{}
svc, hub := newTestService(store)
m := NewMaterializer(hub, svc, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := m.Start(ctx); err != nil {
t.Fatalf("start: %v", err)
}
payload := map[string]any{
"user_id": 5, "category": "request", "type": "request.fulfilled",
"title": "Your audiobook is ready", "body": "Dune by Frank Herbert",
"dedup_ref": "ab-req-9:fulfilled",
}
data, _ := json.Marshal(payload)
publishAndSettle(t, hub, m, evt.Envelope{
Channel: evt.ChannelPlugins, Event: "plugin.silo.audiobook-requests.notifications.send", Data: data,
})
if len(store.inserted) != 1 {
t.Fatalf("inserted = %d, want 1", len(store.inserted))
}
n := store.inserted[0]
if n.UserID != 5 || n.Category != CategoryRequest || n.Type != "request.fulfilled" ||
n.Title != "Your audiobook is ready" || n.DedupRef != "ab-req-9:fulfilled" ||
n.SourceEvent != "plugin.silo.audiobook-requests.notifications.send" {
t.Fatalf("bad notification: %+v", n)
}
}
func TestSendContract_MalformedDropped(t *testing.T) {
store := &fakeStore{}
svc, hub := newTestService(store)
m := NewMaterializer(hub, svc, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := m.Start(ctx); err != nil {
t.Fatalf("start: %v", err)
}
for _, raw := range []string{
`{"category":"request","type":"t","title":"x"}`, // no user_id
`{"user_id":5,"category":"announcement","type":"t","title":"x"}`, // plugins may not create announcements
`{"user_id":5,"category":"bogus","type":"t","title":"x"}`, // invalid category
`{"user_id":5,"category":"request","title":"x"}`, // no type
`{"user_id":5,"category":"request","type":"t"}`, // no title
`not json`,
} {
publishAndSettle(t, hub, m, evt.Envelope{
Channel: evt.ChannelPlugins, Event: "notifications.send", Data: json.RawMessage(raw),
})
}
if len(store.inserted) != 0 {
t.Fatalf("malformed payloads were inserted: %d", len(store.inserted))
}
}
func TestSendContract_IgnoresUnrelatedPluginEvents(t *testing.T) {
store := &fakeStore{}
svc, hub := newTestService(store)
m := NewMaterializer(hub, svc, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := m.Start(ctx); err != nil {
t.Fatalf("start: %v", err)
}
data, _ := json.Marshal(map[string]any{"user_id": 5, "category": "request", "type": "t", "title": "x"})
publishAndSettle(t, hub, m, evt.Envelope{
Channel: evt.ChannelPlugins, Event: "plugin.silo.ebooks.request_submitted", Data: data,
})
if len(store.inserted) != 0 {
t.Fatalf("unrelated plugin event created a notification")
}
}
func TestSendContract_IgnoresNonPluginChannel(t *testing.T) {
store := &fakeStore{}
svc, hub := newTestService(store)
m := NewMaterializer(hub, svc, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := m.Start(ctx); err != nil {
t.Fatalf("start: %v", err)
}
data, _ := json.Marshal(map[string]any{"user_id": 5, "category": "request", "type": "t", "title": "x"})
publishAndSettle(t, hub, m, evt.Envelope{
Channel: evt.ChannelTasks, Event: "notifications.send", Data: data,
})
if len(store.inserted) != 0 {
t.Fatalf("notifications.send honored off the plugins channel")
}
}
func TestRequestMatcher_ApprovedCreatesNotification(t *testing.T) {
store := &fakeStore{}
svc, hub := newTestService(store)
m := NewMaterializer(hub, svc, nil)
ctx := context.Background()
if err := m.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
defer m.Stop()
payload := RequestEventData{
RequestID: "req-1",
UserID: 7,
Title: "Dune",
MediaType: "movie",
Status: "approved",
}
data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal: %v", err)
}
env := evt.Envelope{
Channel: evt.ChannelRequests,
Event: "request.approved",
Data: data,
UserID: 7,
}
publishAndSettle(t, hub, m, env)
if len(store.inserted) != 1 {
t.Fatalf("expected 1 inserted notification, got %d", len(store.inserted))
}
n := store.inserted[0]
if n.Category != CategoryRequest {
t.Errorf("expected category %q, got %q", CategoryRequest, n.Category)
}
if n.Type != "request.approved" {
t.Errorf("expected type %q, got %q", "request.approved", n.Type)
}
if n.UserID != 7 {
t.Errorf("expected UserID 7, got %d", n.UserID)
}
if n.DedupRef != "req-1:approved" {
t.Errorf("expected DedupRef %q, got %q", "req-1:approved", n.DedupRef)
}
if n.Body != "Dune" {
t.Errorf("expected Body %q, got %q", "Dune", n.Body)
}
if n.Title != "Request approved" {
t.Fatalf("title = %q", n.Title)
}
}
func TestRequestMatcher_SubmittedDoesNotNotifyRequester(t *testing.T) {
store := &fakeStore{}
svc, hub := newTestService(store)
m := NewMaterializer(hub, svc, nil)
ctx := context.Background()
if err := m.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
defer m.Stop()
payload := RequestEventData{
RequestID: "req-2",
UserID: 7,
Title: "Dune",
MediaType: "movie",
Status: "submitted",
}
data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal: %v", err)
}
env := evt.Envelope{
Channel: evt.ChannelRequests,
Event: "request.submitted",
Data: data,
UserID: 7,
}
publishAndSettle(t, hub, m, env)
if len(store.inserted) != 0 {
t.Errorf("expected 0 inserted notifications for request.submitted, got %d", len(store.inserted))
}
}
func TestMaterializer_MatcherPanicIsolated(t *testing.T) {
store := &fakeStore{}
svc, hub := newTestService(store)
m := NewMaterializer(hub, svc, nil)
// Prepend a panicking matcher so it runs before the request matcher.
// This proves a panic in one matcher does not prevent subsequent matchers from running.
m.matchers = append([]namedMatcher{{
name: "panicker",
fn: func(context.Context, evt.Envelope) error {
panic("boom")
},
}}, m.matchers...)
ctx := context.Background()
if err := m.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
defer m.Stop()
payload := RequestEventData{
RequestID: "req-3",
UserID: 7,
Title: "Dune",
MediaType: "movie",
Status: "approved",
}
data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal: %v", err)
}
env := evt.Envelope{
Channel: evt.ChannelRequests,
Event: "request.approved",
Data: data,
UserID: 7,
}
publishAndSettle(t, hub, m, env)
// The request matcher should still have run despite the panicker.
if len(store.inserted) != 1 {
t.Errorf("expected 1 inserted notification despite panic, got %d", len(store.inserted))
}
}
// fakeResolver satisfies ContentResolver for unit tests.
type fakeResolver struct {
title string
seriesID string
library int
createdAt time.Time
refs []ProfileRef
}
func (f *fakeResolver) ItemContext(_ context.Context, _ string) (string, string, int, time.Time, error) {
return f.title, f.seriesID, f.library, f.createdAt, nil
}
func (f *fakeResolver) InterestedProfiles(_ context.Context, _, _ string, _ int, _ time.Time) ([]ProfileRef, error) {
return f.refs, nil
}
// catalogItemChangedEnvelope builds a raw evt.Envelope for catalog.item.changed events.
func catalogItemChangedEnvelope(libraryID int, contentID, change string) evt.Envelope {
data, _ := json.Marshal(map[string]any{
"library_id": libraryID,
"content_id": contentID,
"change": change,
})
return evt.Envelope{
Channel: evt.ChannelCatalog,
Event: "catalog.item.changed",
Data: data,
}
}
func TestContentMatcher_NotifiesInterestedProfiles(t *testing.T) {
store := &fakeStore{}
svc, hub := newTestService(store)
resolver := &fakeResolver{
title: "Inception",
seriesID: "",
library: 1,
createdAt: time.Now(),
refs: []ProfileRef{
{UserID: 10, ProfileID: "profile-a"},
{UserID: 11, ProfileID: "profile-b"},
},
}
m := NewMaterializer(hub, svc, resolver)
ctx := context.Background()
if err := m.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
defer m.Stop()
publishAndSettle(t, hub, m, catalogItemChangedEnvelope(1, "content-42", "metadata_updated"))
if len(store.inserted) != 2 {
t.Fatalf("expected 2 inserted notifications, got %d", len(store.inserted))
}
n := store.inserted[0]
if n.Category != CategoryContent {
t.Errorf("category = %q, want %q", n.Category, CategoryContent)
}
if n.Type != "content.added" {
t.Errorf("type = %q, want %q", n.Type, "content.added")
}
if n.ItemID == nil || *n.ItemID != "content-42" {
t.Errorf("item_id = %v, want %q", n.ItemID, "content-42")
}
if n.ProfileID == nil || *n.ProfileID != "profile-a" {
t.Errorf("profile_id = %v, want %q", n.ProfileID, "profile-a")
}
}
func TestContentMatcher_BurstCollapsesViaDedup(t *testing.T) {
store := &fakeStore{}
svc, hub := newTestService(store)
resolver := &fakeResolver{
title: "Episode",
seriesID: "series-99",
library: 1,
createdAt: time.Now(),
refs: []ProfileRef{
{UserID: 10, ProfileID: "profile-a"},
},
}
m := NewMaterializer(hub, svc, resolver)
ctx := context.Background()
if err := m.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
defer m.Stop()
// Three episodes from the same series published sequentially.
// All share the same (series_id, profile_id, dayBucket) → same dedup_ref → only 1 insert.
for _, epID := range []string{"ep-1", "ep-2", "ep-3"} {
publishAndSettle(t, hub, m, catalogItemChangedEnvelope(1, epID, "metadata_updated"))
}
if len(store.inserted) != 1 {
t.Fatalf("expected 1 insert (dedup collapsed burst), got %d", len(store.inserted))
}
}
func TestContentMatcher_NonAddedChangeIgnored(t *testing.T) {
store := &fakeStore{}
svc, hub := newTestService(store)
resolver := &fakeResolver{
title: "Something",
library: 1,
createdAt: time.Now(),
refs: []ProfileRef{{UserID: 10, ProfileID: "profile-a"}},
}
m := NewMaterializer(hub, svc, resolver)
ctx := context.Background()
if err := m.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
defer m.Stop()
// "library_rescan" is not a recognized addition change value → should be ignored.
data, _ := json.Marshal(map[string]any{
"library_id": 1,
"content_id": "content-x",
"change": "library_rescan",
})
publishAndSettle(t, hub, m, evt.Envelope{
Channel: evt.ChannelCatalog,
Event: "catalog.item.changed",
Data: data,
})
if len(store.inserted) != 0 {
t.Fatalf("expected 0 inserts for non-addition change, got %d", len(store.inserted))
}
}
func TestContentMatcher_NilResolverDisabled(t *testing.T) {
store := &fakeStore{}
svc, hub := newTestService(store)
// nil resolver → content matcher not registered
m := NewMaterializer(hub, svc, nil)
ctx := context.Background()
if err := m.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
defer m.Stop()
publishAndSettle(t, hub, m, catalogItemChangedEnvelope(1, "content-zzz", "metadata_updated"))
if len(store.inserted) != 0 {
t.Fatalf("expected 0 inserts when resolver is nil, got %d", len(store.inserted))
}
}
// ---- Admin matcher tests ----
// jobFailedEnvelope builds a raw evt.Envelope simulating job.failed on ChannelJobs.
// The payload mirrors models.AdminJob JSON tags (id, job_type, status).
func jobFailedEnvelope(jobID, jobType string) evt.Envelope {
data, _ := json.Marshal(map[string]any{
"id": jobID,
"job_type": jobType,
"status": "failed",
})
return evt.Envelope{
Channel: evt.ChannelJobs,
Event: "job.failed",
Data: data,
AdminOnly: true,
}
}
func TestAdminMatcher_ScanFailedNotifiesAdmins(t *testing.T) {
store := &fakeStore{admins: []int{1}}
svc, hub := newTestService(store)
m := NewMaterializer(hub, svc, nil)
ctx := context.Background()
if err := m.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
defer m.Stop()
data, _ := json.Marshal(map[string]any{
"id": "scan-9",
"mode": "full",
})
publishAndSettle(t, hub, m, evt.Envelope{
Channel: evt.ChannelScans,
Event: "scan.failed",
Data: data,
AdminOnly: true,
})
if len(store.inserted) != 1 {
t.Fatalf("expected 1 insert, got %d", len(store.inserted))
}
n := store.inserted[0]
if n.Type != "scan.failed" {
t.Errorf("type = %q, want %q", n.Type, "scan.failed")
}
if !strings.Contains(n.Body, "full") {
t.Errorf("Body does not contain 'full': %q", n.Body)
}
if !strings.Contains(n.Body, "scan-9") {
t.Errorf("Body does not contain 'scan-9': %q", n.Body)
}
if n.Link == nil || *n.Link != "/admin/tasks" {
t.Errorf("Link = %v, want %q", n.Link, "/admin/tasks")
}
if !strings.HasPrefix(n.DedupRef, "scan.failed:full:") {
t.Errorf("DedupRef prefix incorrect: %q (want to start with 'scan.failed:full:')", n.DedupRef)
}
}
func TestAdminMatcher_JobFailedNotifiesAdmins(t *testing.T) {
store := &fakeStore{admins: []int{1, 2}}
svc, hub := newTestService(store)
m := NewMaterializer(hub, svc, nil)
ctx := context.Background()
if err := m.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
defer m.Stop()
publishAndSettle(t, hub, m, jobFailedEnvelope("job-abc", "export"))
if len(store.inserted) != 2 {
t.Fatalf("expected 2 inserts (one per admin), got %d", len(store.inserted))
}
for _, n := range store.inserted {
if n.Category != CategoryAdmin {
t.Errorf("category = %q, want %q", n.Category, CategoryAdmin)
}
if n.Type != "job.failed" {
t.Errorf("type = %q, want %q", n.Type, "job.failed")
}
}
// Both admin users must be covered.
seen := map[int]bool{}
for _, n := range store.inserted {
seen[n.UserID] = true
}
if !seen[1] || !seen[2] {
t.Errorf("expected both admins (1, 2) notified; got %v", seen)
}
// Assert on one inserted row: Body contains job_type and id; Link and DedupRef prefix
n := store.inserted[0]
if !strings.Contains(n.Body, "export") {
t.Errorf("Body does not contain job_type 'export': %q", n.Body)
}
if !strings.Contains(n.Body, "job-abc") {
t.Errorf("Body does not contain job id 'job-abc': %q", n.Body)
}
if n.Link == nil || *n.Link != "/admin/tasks" {
t.Errorf("Link = %v, want %q", n.Link, "/admin/tasks")
}
if !strings.HasPrefix(n.DedupRef, "job.failed:export:") {
t.Errorf("DedupRef prefix incorrect: %q (want to start with 'job.failed:export:')", n.DedupRef)
}
}
func TestAdminMatcher_RepeatFailureThrottled(t *testing.T) {
store := &fakeStore{admins: []int{1}}
svc, hub := newTestService(store)
m := NewMaterializer(hub, svc, nil)
ctx := context.Background()
if err := m.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
defer m.Stop()
// Publish the same job.failed event 3 times; dedup_ref is hourly-bucketed
// so all three fall in the same bucket → only 1 insert per admin.
env := jobFailedEnvelope("job-xyz", "scan")
for range 3 {
publishAndSettle(t, hub, m, env)
}
if len(store.inserted) != 1 {
t.Fatalf("expected 1 insert after dedup throttle, got %d", len(store.inserted))
}
}
func TestAdminMatcher_NonFailureIgnored(t *testing.T) {
store := &fakeStore{admins: []int{1, 2}}
svc, hub := newTestService(store)
m := NewMaterializer(hub, svc, nil)
ctx := context.Background()
if err := m.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
defer m.Stop()
data, _ := json.Marshal(map[string]any{"id": "job-1", "job_type": "export", "status": "completed"})
publishAndSettle(t, hub, m, evt.Envelope{
Channel: evt.ChannelJobs,
Event: "job.completed",
Data: data,
})
if len(store.inserted) != 0 {
t.Fatalf("non-failure event should produce 0 inserts, got %d", len(store.inserted))
}
}
func TestAdminMatcher_WrongChannelIgnored(t *testing.T) {
store := &fakeStore{admins: []int{1, 2}}
svc, hub := newTestService(store)
m := NewMaterializer(hub, svc, nil)
ctx := context.Background()
if err := m.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
defer m.Stop()
// job.failed arriving on ChannelCatalog must be ignored.
data, _ := json.Marshal(map[string]any{"id": "job-1", "job_type": "export", "status": "failed"})
publishAndSettle(t, hub, m, evt.Envelope{
Channel: evt.ChannelCatalog,
Event: "job.failed",
Data: data,
})
if len(store.inserted) != 0 {
t.Fatalf("wrong channel should produce 0 inserts, got %d", len(store.inserted))
}
}
// TestContentMatcher_OldItemMetadataRefreshIgnored verifies that a
// metadata_updated event for an item whose catalog row is older than
// newItemWindow does not produce any notifications. This prevents periodic
// library refresh jobs from re-notifying users about years-old watchlist items.
func TestContentMatcher_OldItemMetadataRefreshIgnored(t *testing.T) {
store := &fakeStore{}
svc, hub := newTestService(store)
// createdAt 30 days ago — well beyond the 48-hour newItemWindow.
resolver := &fakeResolver{
title: "Old Movie",
seriesID: "",
library: 1,
createdAt: time.Now().Add(-30 * 24 * time.Hour),
refs: []ProfileRef{{UserID: 10, ProfileID: "profile-a"}},
}
m := NewMaterializer(hub, svc, resolver)
ctx := context.Background()
if err := m.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
defer m.Stop()
// metadata_updated fires (e.g. from a library refresh job) but the item is old.
publishAndSettle(t, hub, m, catalogItemChangedEnvelope(1, "content-old", "metadata_updated"))
if len(store.inserted) != 0 {
t.Fatalf("expected 0 inserts for old item metadata refresh, got %d", len(store.inserted))
}
}
func TestSystemMatcher_PasswordChanged_CreatesNotification(t *testing.T) {
store := &fakeStore{}
svc, hub := newTestService(store)
m := NewMaterializer(hub, svc, nil)
ctx := context.Background()
if err := m.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
defer m.Stop()
publishAndSettle(t, hub, m, evt.Envelope{
Channel: evt.ChannelSessions,
Event: EventUserPasswordChanged,
UserID: 99,
})
if len(store.inserted) != 1 {
t.Fatalf("expected 1 system notification, got %d", len(store.inserted))
}
n := store.inserted[0]
if n.Type != "system.password_changed" {
t.Errorf("type = %q, want %q", n.Type, "system.password_changed")
}
if n.UserID != 99 {
t.Errorf("user_id = %d, want 99", n.UserID)
}
if n.Category != CategorySystem {
t.Errorf("category = %q, want %q", n.Category, CategorySystem)
}
}
func TestSystemMatcher_IgnoresOtherChannels(t *testing.T) {
store := &fakeStore{}
svc, hub := newTestService(store)
m := NewMaterializer(hub, svc, nil)
ctx := context.Background()
if err := m.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
defer m.Stop()
// Same event name on a different channel must not materialize anything.
publishAndSettle(t, hub, m, evt.Envelope{
Channel: evt.ChannelCatalog,
Event: EventUserPasswordChanged,
UserID: 99,
})
if len(store.inserted) != 0 {
t.Fatalf("expected 0 notifications for off-channel event, got %d", len(store.inserted))
}
}
+101
View File
@@ -0,0 +1,101 @@
package notifications
import (
"context"
"log/slog"
"sync"
"sync/atomic"
evt "github.com/Silo-Server/silo-server/internal/events"
)
type matcherFunc func(ctx context.Context, env evt.Envelope) error
type namedMatcher struct {
name string
fn matcherFunc
}
// Materializer subscribes to the events hub and turns events into
// notifications rows via registered matchers. Matcher failures are isolated:
// one matcher erroring or panicking never blocks the hub or other matchers.
type Materializer struct {
hub *evt.Hub
svc *Service
content ContentResolver // nil disables the content matcher
matchers []namedMatcher
processed atomic.Int64
unsub func()
startOnce sync.Once
stopOnce sync.Once
}
func NewMaterializer(hub *evt.Hub, svc *Service, content ContentResolver) *Materializer {
m := &Materializer{hub: hub, svc: svc, content: content}
m.register("request", m.matchRequest)
m.register("send", m.matchSend)
if content != nil {
m.register("content", m.matchContent)
}
m.register("admin", m.matchAdmin)
m.register("system", m.matchSystem)
return m
}
func (m *Materializer) register(name string, fn matcherFunc) {
m.matchers = append(m.matchers, namedMatcher{name: name, fn: fn})
}
// Processed reports how many envelopes have been fully processed (test hook).
func (m *Materializer) Processed() int64 { return m.processed.Load() }
// Start subscribes to the hub and begins materializing events. It is
// single-shot and safe to call concurrently: only the first call subscribes;
// later calls are no-ops.
func (m *Materializer) Start(ctx context.Context) error {
m.startOnce.Do(func() {
ch, unsub := m.hub.Subscribe()
m.unsub = unsub
go func() {
for {
select {
case <-ctx.Done():
return
case env, ok := <-ch:
if !ok {
return
}
m.handle(ctx, env)
m.processed.Add(1)
}
}
}()
})
return nil
}
func (m *Materializer) Stop() {
m.stopOnce.Do(func() {
if m.unsub != nil {
m.unsub()
}
})
}
func (m *Materializer) handle(ctx context.Context, env evt.Envelope) {
if env.Channel == evt.ChannelNotifications {
return // never react to our own output — the materializer consumes, it must not feed itself
}
for _, matcher := range m.matchers {
func() {
defer func() {
if r := recover(); r != nil {
slog.ErrorContext(ctx, "notifications: matcher panicked", "matcher", matcher.name, "event", env.Event, "panic", r)
}
}()
if err := matcher.fn(ctx, env); err != nil {
slog.WarnContext(ctx, "notifications: matcher failed", "matcher", matcher.name, "event", env.Event, "error", err)
}
}()
}
}
+348
View File
@@ -0,0 +1,348 @@
package notifications
import (
"context"
"fmt"
"log/slog"
"time"
evt "github.com/Silo-Server/silo-server/internal/events"
)
const TypeContentDigest = "content.digest"
type CreateInput struct {
UserID int
ProfileID string // optional
Category Category
Type string
Title string
Body string
Link string
ItemID string
SourceEvent string
DedupRef string
ExpiresAt *time.Time
}
// PushEnqueuer mirrors a created notification to push delivery. Implemented by
// internal/push; nil when push is disabled.
type PushEnqueuer interface {
EnqueueForNotification(ctx context.Context, n *Notification)
}
type Service struct {
store Store
hub *evt.Hub
push PushEnqueuer
}
func NewService(store Store, hub *evt.Hub) *Service {
return &Service{store: store, hub: hub}
}
func (s *Service) SetPushEnqueuer(e PushEnqueuer) { s.push = e }
func validCategory(c Category) bool {
switch c {
case CategoryRequest, CategoryContent, CategoryAnnouncement, CategorySystem, CategoryAdmin:
return true
}
return false
}
// PublishAnnouncement stores the announcement and fans it out to notifications
// rows at publish time, resolving the audience to user ids now.
func (s *Service) PublishAnnouncement(ctx context.Context, a *Announcement) error {
if a.Title == "" {
return fmt.Errorf("notifications: announcement title required")
}
if len(a.Title) > MaxAnnouncementTitleLen {
return fmt.Errorf("notifications: announcement title exceeds %d characters", MaxAnnouncementTitleLen)
}
if len(a.Body) > MaxAnnouncementBodyLen {
return fmt.Errorf("notifications: announcement body exceeds %d characters", MaxAnnouncementBodyLen)
}
set := 0
if a.Audience.All {
set++
}
if len(a.Audience.UserIDs) > 0 {
set++
}
if len(a.Audience.LibraryIDs) > 0 {
set++
}
if set != 1 {
return fmt.Errorf("notifications: audience must set exactly one of all/user_ids/library_ids")
}
userIDs, err := s.resolveAudience(ctx, a.Audience)
if err != nil {
return fmt.Errorf("notifications: resolve audience: %w", err)
}
if err := s.store.InsertAnnouncement(ctx, a); err != nil {
return fmt.Errorf("notifications: insert announcement: %w", err)
}
profilesByUser, err := s.store.ProfileIDsForUsers(ctx, userIDs)
if err != nil {
return fmt.Errorf("notifications: profiles for audience: %w", err)
}
// Fan out one row per profile so each profile reads/dismisses the
// announcement independently (mirrors content/request notifications). The
// profile-scoped dedup_ref keeps the unique (user_id, type, dedup_ref) index
// satisfied across a single account's profiles.
for _, uid := range userIDs {
profiles := profilesByUser[uid]
if len(profiles) == 0 {
// No profiles on this account (unexpected): fall back to a single
// account-wide row so the user still receives it.
s.fanOutAnnouncement(ctx, a, uid, "", fmt.Sprintf("announcement-%d", a.ID))
continue
}
for _, pid := range profiles {
s.fanOutAnnouncement(ctx, a, uid, pid, fmt.Sprintf("announcement-%d-%s", a.ID, pid))
}
}
return nil
}
// fanOutAnnouncement creates one announcement notification for a single
// (user, profile) pair, logging rather than failing the publish on error.
func (s *Service) fanOutAnnouncement(ctx context.Context, a *Announcement, userID int, profileID, dedupRef string) {
in := CreateInput{
UserID: userID,
ProfileID: profileID,
Category: CategoryAnnouncement,
Type: "announcement",
Title: a.Title,
Body: a.Body,
DedupRef: dedupRef,
ExpiresAt: a.ExpiresAt,
}
if err := s.Create(ctx, in); err != nil {
slog.WarnContext(ctx, "notifications: announcement fan-out failed",
"user_id", userID, "profile_id", profileID, "error", err)
}
}
func (s *Service) resolveAudience(ctx context.Context, a Audience) ([]int, error) {
switch {
case a.All:
return s.store.AllEnabledUserIDs(ctx)
case len(a.UserIDs) > 0:
return a.UserIDs, nil
default:
seen := map[int]struct{}{}
var out []int
for _, lib := range a.LibraryIDs {
ids, err := s.store.UserIDsWithLibraryAccess(ctx, lib)
if err != nil {
return nil, err
}
for _, id := range ids {
if _, ok := seen[id]; !ok {
seen[id] = struct{}{}
out = append(out, id)
}
}
}
return out, nil
}
}
// DeleteAnnouncement removes the announcement and dismisses its unread rows.
func (s *Service) DeleteAnnouncement(ctx context.Context, id int64) error {
if err := s.store.DeleteAnnouncement(ctx, id); err != nil {
return fmt.Errorf("notifications: delete announcement: %w", err)
}
return s.store.DismissAnnouncementNotifications(ctx, id)
}
// List returns non-dismissed notifications for the given filter.
func (s *Service) List(ctx context.Context, f ListFilter) ([]*Notification, error) {
return s.store.List(ctx, f)
}
// UnreadCount returns the number of unread, non-dismissed notifications.
func (s *Service) UnreadCount(ctx context.Context, userID int, profileID string, childSafe bool) (int, error) {
return s.store.UnreadCount(ctx, userID, profileID, childSafe)
}
// MarkRead marks the specified notifications as read for the active profile.
func (s *Service) MarkRead(ctx context.Context, userID int, profileID string, ids []int64) error {
return s.store.MarkRead(ctx, userID, profileID, ids)
}
// MarkAllRead marks all of the active profile's notifications as read.
func (s *Service) MarkAllRead(ctx context.Context, userID int, profileID string) error {
return s.store.MarkAllRead(ctx, userID, profileID)
}
// Dismiss marks a single notification as dismissed for the active profile.
// Returns ErrNotFound when the notification does not exist for this profile or
// is already dismissed.
func (s *Service) Dismiss(ctx context.Context, userID int, profileID string, id int64) error {
return s.store.Dismiss(ctx, userID, profileID, id)
}
// GetPreferences returns the full preference list for userID, merging stored
// values with per-category defaults (content_digest defaults OFF; all others ON).
func (s *Service) GetPreferences(ctx context.Context, userID int) ([]Preference, error) {
stored, err := s.store.Preferences(ctx, userID)
if err != nil {
return nil, fmt.Errorf("notifications: load preferences: %w", err)
}
out := make([]Preference, 0, len(MutableCategories))
for _, cat := range MutableCategories {
enabled, ok := stored[cat]
if !ok {
// Apply default: content_digest is opt-in (default false); others default true.
enabled = cat != CategoryContentDigest
}
out = append(out, Preference{Category: cat, Enabled: enabled})
}
return out, nil
}
// SetPreferences validates and persists preferences. Returns an error (without
// writing any row) if any category is not in MutableCategories.
func (s *Service) SetPreferences(ctx context.Context, userID int, prefs []Preference) error {
mutableSet := make(map[Category]struct{}, len(MutableCategories))
for _, cat := range MutableCategories {
mutableSet[cat] = struct{}{}
}
for _, p := range prefs {
if _, ok := mutableSet[p.Category]; !ok {
return fmt.Errorf("notifications: category %q: %w", p.Category, ErrInvalidCategory)
}
}
// Persist the whole validated set atomically so a store error never leaves a
// partially applied preference set.
if err := s.store.SetPreferences(ctx, userID, prefs); err != nil {
return fmt.Errorf("notifications: set preferences: %w", err)
}
return nil
}
// ListAnnouncements returns all announcements (admin view).
func (s *Service) ListAnnouncements(ctx context.Context) ([]*Announcement, error) {
return s.store.ListAnnouncements(ctx)
}
// CreateSystem records a security/account notification for userID. Failures
// are logged, never returned — security notices must not break the calling flow.
func (s *Service) CreateSystem(ctx context.Context, userID int, typ, title, body string) {
if err := s.Create(ctx, CreateInput{
UserID: userID, Category: CategorySystem, Type: typ, Title: title, Body: body,
}); err != nil {
slog.WarnContext(ctx, "notifications: system notification failed", "type", typ, "user_id", userID, "error", err)
}
}
// RunDailyDigest creates one digest notification per opted-in user summarizing
// catalog additions in their accessible libraries over the lookback window.
func (s *Service) RunDailyDigest(ctx context.Context, since time.Time) error {
subs, err := s.store.DigestSubscribers(ctx)
if err != nil {
return fmt.Errorf("notifications: digest subscribers: %w", err)
}
dedupRef := "digest:" + time.Now().UTC().Format("20060102")
for _, uid := range subs {
count, err := s.store.AddedItemCountForUser(ctx, uid, since)
if err != nil {
slog.WarnContext(ctx, "notifications: digest count failed", "user_id", uid, "error", err)
continue
}
if count == 0 {
continue
}
if err := s.Create(ctx, CreateInput{
UserID: uid,
Category: CategoryContent,
Type: TypeContentDigest,
Title: "New in your libraries",
Body: fmt.Sprintf("%d new items were added in the last day", count),
Link: "/recently-added",
DedupRef: dedupRef,
}); err != nil {
slog.WarnContext(ctx, "notifications: digest create failed", "user_id", uid, "error", err)
}
}
return nil
}
// Create validates, applies preferences, inserts (idempotent on DedupRef) and
// publishes notification.created for live clients. A dedup conflict is not an
// error and publishes nothing.
func (s *Service) Create(ctx context.Context, in CreateInput) error {
if in.UserID <= 0 {
return fmt.Errorf("notifications: user_id required")
}
if !validCategory(in.Category) {
return fmt.Errorf("notifications: invalid category %q", in.Category)
}
if in.Type == "" || in.Title == "" {
return fmt.Errorf("notifications: type and title required")
}
if in.Category != CategoryAnnouncement {
prefs, err := s.store.Preferences(ctx, in.UserID)
if err != nil {
return fmt.Errorf("notifications: load preferences: %w", err)
}
prefCategory := in.Category
if in.Type == TypeContentDigest {
prefCategory = CategoryContentDigest
}
if prefCategory == CategoryContentDigest {
// content_digest is opt-in: absent row means disabled.
if enabled, ok := prefs[CategoryContentDigest]; !ok || !enabled {
return nil
}
} else if enabled, ok := prefs[prefCategory]; ok && !enabled {
return nil
}
}
n := &Notification{
UserID: in.UserID,
Category: in.Category,
Type: in.Type,
Title: in.Title,
Body: in.Body,
SourceEvent: in.SourceEvent,
DedupRef: in.DedupRef,
ExpiresAt: in.ExpiresAt,
}
if in.ProfileID != "" {
n.ProfileID = &in.ProfileID
}
if in.Link != "" {
n.Link = &in.Link
}
if in.ItemID != "" {
n.ItemID = &in.ItemID
}
created, err := s.store.Insert(ctx, n)
if err != nil {
return fmt.Errorf("notifications: insert: %w", err)
}
if !created {
return nil
}
if err := s.hub.PublishJSON(ctx, evt.ChannelNotifications, "notification.created", n, evt.PublishOptions{
UserID: n.UserID,
ProfileID: in.ProfileID,
AdminOnly: n.Category == CategoryAdmin,
}); err != nil {
slog.WarnContext(ctx, "notifications: publish failed", "error", err, "notification_id", n.ID)
}
if s.push != nil {
s.push.EnqueueForNotification(ctx, n)
}
return nil
}
+606
View File
@@ -0,0 +1,606 @@
package notifications
import (
"context"
"errors"
"strings"
"testing"
"time"
evt "github.com/Silo-Server/silo-server/internal/events"
)
type fakeStore struct {
Store // embed for unimplemented methods (nil-panic = test failure, intended)
inserted []*Notification
nextID int64
prefs map[int]map[Category]bool
admins []int
libUsers map[int][]int
allUsers []int
profilesByUser map[int][]string // user id → profile ids (announcement fan-out)
insertErr error // when non-nil, Insert returns this error
// recorded arg for the delete-cleanup call
dismissedAnnouncementID int64
// digest support
digestSubs []int
addedCounts map[int]int // userID → count
addedErrors map[int]error // userID → error (optional per-user error)
}
func (f *fakeStore) Insert(_ context.Context, n *Notification) (bool, error) {
if f.insertErr != nil {
return false, f.insertErr
}
for _, existing := range f.inserted {
if existing.UserID == n.UserID && existing.Type == n.Type &&
n.DedupRef != "" && existing.DedupRef == n.DedupRef {
return false, nil
}
}
n.ID = int64(len(f.inserted) + 1)
f.inserted = append(f.inserted, n)
return true, nil
}
func (f *fakeStore) Preferences(_ context.Context, userID int) (map[Category]bool, error) {
if p, ok := f.prefs[userID]; ok {
return p, nil
}
return map[Category]bool{}, nil
}
func (f *fakeStore) AdminUserIDs(context.Context) ([]int, error) { return f.admins, nil }
func (f *fakeStore) UserIDsWithLibraryAccess(_ context.Context, lib int) ([]int, error) {
return f.libUsers[lib], nil
}
func (f *fakeStore) AllEnabledUserIDs(context.Context) ([]int, error) { return f.allUsers, nil }
func (f *fakeStore) InsertAnnouncement(_ context.Context, a *Announcement) error {
f.nextID++
a.ID = f.nextID
return nil
}
func (f *fakeStore) DismissAnnouncementNotifications(_ context.Context, announcementID int64) error {
f.dismissedAnnouncementID = announcementID
return nil
}
func (f *fakeStore) ProfileIDsForUsers(_ context.Context, userIDs []int) (map[int][]string, error) {
out := make(map[int][]string, len(userIDs))
for _, uid := range userIDs {
if profiles, ok := f.profilesByUser[uid]; ok {
out[uid] = profiles
}
}
return out, nil
}
func (f *fakeStore) DeleteAnnouncement(context.Context, int64) error { return nil }
func (f *fakeStore) DigestSubscribers(context.Context) ([]int, error) {
return f.digestSubs, nil
}
func (f *fakeStore) AddedItemCountForUser(_ context.Context, userID int, _ time.Time) (int, error) {
if f.addedErrors != nil {
if err, ok := f.addedErrors[userID]; ok {
return 0, err
}
}
if f.addedCounts != nil {
return f.addedCounts[userID], nil
}
return 0, nil
}
func newTestService(store Store) (*Service, *evt.Hub) {
hub := evt.NewHub("test-node", nil)
return NewService(store, hub), hub
}
func TestCreate_InsertsAndPublishes(t *testing.T) {
store := &fakeStore{}
svc, hub := newTestService(store)
ch, unsub := hub.Subscribe()
defer unsub()
ctx := context.Background()
err := svc.Create(ctx, CreateInput{
UserID: 7,
Category: CategoryRequest,
Type: "request.approved",
Title: "Approved",
DedupRef: "req-1:approved",
})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
if len(store.inserted) != 1 {
t.Fatalf("expected 1 inserted notification, got %d", len(store.inserted))
}
env := <-ch
if env.Channel != evt.ChannelNotifications {
t.Errorf("expected channel %q, got %q", evt.ChannelNotifications, env.Channel)
}
if env.Event != "notification.created" {
t.Errorf("expected event %q, got %q", "notification.created", env.Event)
}
if env.UserID != 7 {
t.Errorf("expected UserID 7, got %d", env.UserID)
}
}
func TestCreate_MutedCategorySkipped(t *testing.T) {
store := &fakeStore{
prefs: map[int]map[Category]bool{
7: {CategoryRequest: false},
},
}
svc, _ := newTestService(store)
ctx := context.Background()
err := svc.Create(ctx, CreateInput{
UserID: 7,
Category: CategoryRequest,
Type: "request.approved",
Title: "Approved",
DedupRef: "req-1:approved",
})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
if len(store.inserted) != 0 {
t.Errorf("expected 0 inserted notifications, got %d", len(store.inserted))
}
}
func TestCreate_AnnouncementNotMutable(t *testing.T) {
store := &fakeStore{
prefs: map[int]map[Category]bool{
7: {CategoryAnnouncement: false},
},
}
svc, _ := newTestService(store)
ctx := context.Background()
err := svc.Create(ctx, CreateInput{
UserID: 7,
Category: CategoryAnnouncement,
Type: "announcement.new",
Title: "New Announcement",
})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
if len(store.inserted) != 1 {
t.Errorf("expected 1 inserted notification (announcement not mutable), got %d", len(store.inserted))
}
}
func TestCreate_DedupNoDoubleInsertNoSecondPublish(t *testing.T) {
store := &fakeStore{}
svc, hub := newTestService(store)
ch, unsub := hub.Subscribe()
defer unsub()
ctx := context.Background()
input := CreateInput{
UserID: 7,
Category: CategoryRequest,
Type: "request.approved",
Title: "Approved",
DedupRef: "req-1:approved",
}
if err := svc.Create(ctx, input); err != nil {
t.Fatalf("first Create returned error: %v", err)
}
if err := svc.Create(ctx, input); err != nil {
t.Fatalf("second Create returned error: %v", err)
}
if len(store.inserted) != 1 {
t.Errorf("expected 1 inserted notification (dedup), got %d", len(store.inserted))
}
// Drain the one envelope that should exist.
<-ch
// Assert no second envelope.
select {
case env, ok := <-ch:
if ok {
t.Errorf("expected no second envelope, got event %q on channel %q", env.Event, env.Channel)
}
default:
// good: channel is empty
}
}
func TestCreate_ValidationErrors(t *testing.T) {
store := &fakeStore{}
svc, _ := newTestService(store)
ctx := context.Background()
cases := []struct {
name string
input CreateInput
}{
{
name: "no user",
input: CreateInput{Category: CategoryRequest, Type: "request.approved", Title: "Approved"},
},
{
name: "no category",
input: CreateInput{UserID: 7, Type: "request.approved", Title: "Approved"},
},
{
name: "bogus category",
input: CreateInput{UserID: 7, Category: "bogus", Type: "request.approved", Title: "Approved"},
},
{
name: "no type",
input: CreateInput{UserID: 7, Category: CategoryRequest, Title: "Approved"},
},
{
name: "no title",
input: CreateInput{UserID: 7, Category: CategoryRequest, Type: "request.approved"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := svc.Create(ctx, tc.input)
if err == nil {
t.Errorf("expected error for %q, got nil", tc.name)
}
})
}
}
func TestCreate_DigestAbsentPrefSuppressed(t *testing.T) {
store := &fakeStore{}
svc, _ := newTestService(store)
err := svc.Create(context.Background(), CreateInput{
UserID: 7, Category: CategoryContent, Type: TypeContentDigest, Title: "Digest",
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if len(store.inserted) != 0 {
t.Fatalf("digest without opt-in was inserted")
}
}
func TestCreate_DigestOptInInserts(t *testing.T) {
store := &fakeStore{prefs: map[int]map[Category]bool{
7: {CategoryContentDigest: true},
}}
svc, _ := newTestService(store)
err := svc.Create(context.Background(), CreateInput{
UserID: 7, Category: CategoryContent, Type: TypeContentDigest, Title: "Digest",
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if len(store.inserted) != 1 {
t.Fatalf("opted-in digest was not inserted: %d", len(store.inserted))
}
}
func TestPublishAnnouncement_FanOutAllPerProfile(t *testing.T) {
store := &fakeStore{
allUsers: []int{1, 2, 3},
profilesByUser: map[int][]string{
1: {"p1a", "p1b"},
2: {"p2"},
3: {"p3a", "p3b", "p3c"},
},
}
svc, _ := newTestService(store)
err := svc.PublishAnnouncement(context.Background(), &Announcement{
Title: "Maintenance",
Audience: Audience{All: true},
})
if err != nil {
t.Fatalf("PublishAnnouncement returned error: %v", err)
}
// One row per profile across all three accounts (2 + 1 + 3).
if len(store.inserted) != 6 {
t.Fatalf("expected 6 inserted rows (one per profile), got %d", len(store.inserted))
}
for _, n := range store.inserted {
if n.Category != CategoryAnnouncement {
t.Errorf("expected category %q, got %q", CategoryAnnouncement, n.Category)
}
if n.ProfileID == nil || *n.ProfileID == "" {
t.Fatalf("expected a per-profile row, got nil/empty profile_id")
}
want := "announcement-1-" + *n.ProfileID
if n.DedupRef != want {
t.Errorf("expected dedup_ref %q, got %q", want, n.DedupRef)
}
}
}
func TestPublishAnnouncement_FallbackNoProfiles(t *testing.T) {
// A user with no profiles still receives a single account-wide row.
store := &fakeStore{allUsers: []int{1}}
svc, _ := newTestService(store)
err := svc.PublishAnnouncement(context.Background(), &Announcement{
Title: "Maintenance",
Audience: Audience{All: true},
})
if err != nil {
t.Fatalf("PublishAnnouncement returned error: %v", err)
}
if len(store.inserted) != 1 {
t.Fatalf("expected 1 account-wide row, got %d", len(store.inserted))
}
if n := store.inserted[0]; n.ProfileID != nil {
t.Errorf("expected nil profile_id (account-wide), got %q", *n.ProfileID)
} else if n.DedupRef != "announcement-1" {
t.Errorf("expected dedup_ref %q, got %q", "announcement-1", n.DedupRef)
}
}
func TestPublishAnnouncement_FanOutUserIDs(t *testing.T) {
store := &fakeStore{}
svc, _ := newTestService(store)
err := svc.PublishAnnouncement(context.Background(), &Announcement{
Title: "Hello",
Audience: Audience{UserIDs: []int{5, 9}},
})
if err != nil {
t.Fatalf("PublishAnnouncement returned error: %v", err)
}
if len(store.inserted) != 2 {
t.Fatalf("expected 2 inserted rows, got %d", len(store.inserted))
}
}
func TestPublishAnnouncement_FanOutLibraryIDs(t *testing.T) {
store := &fakeStore{
libUsers: map[int][]int{
3: {4, 5},
7: {5, 6},
},
}
svc, _ := newTestService(store)
err := svc.PublishAnnouncement(context.Background(), &Announcement{
Title: "Library News",
Audience: Audience{LibraryIDs: []int{3, 7}},
})
if err != nil {
t.Fatalf("PublishAnnouncement returned error: %v", err)
}
// users 4, 5, 6 — 5 is deduplicated
if len(store.inserted) != 3 {
t.Fatalf("expected 3 inserted rows (dedup user 5), got %d", len(store.inserted))
}
}
func TestPublishAnnouncement_AudienceValidation(t *testing.T) {
store := &fakeStore{}
svc, _ := newTestService(store)
ctx := context.Background()
cases := []struct {
name string
a *Announcement
}{
{
name: "no audience fields set",
a: &Announcement{Title: "Test"},
},
{
name: "two fields set (All+UserIDs)",
a: &Announcement{Title: "Test", Audience: Audience{All: true, UserIDs: []int{1}}},
},
{
name: "empty title",
a: &Announcement{Audience: Audience{All: true}},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := svc.PublishAnnouncement(ctx, tc.a)
if err == nil {
t.Errorf("expected error for %q, got nil", tc.name)
}
})
}
}
func TestPublishAnnouncement_ExpiryPropagates(t *testing.T) {
store := &fakeStore{allUsers: []int{1, 2}}
svc, _ := newTestService(store)
exp := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
err := svc.PublishAnnouncement(context.Background(), &Announcement{
Title: "Expiring",
Audience: Audience{All: true},
ExpiresAt: &exp,
})
if err != nil {
t.Fatalf("PublishAnnouncement returned error: %v", err)
}
if len(store.inserted) != 2 {
t.Fatalf("expected 2 inserted rows, got %d", len(store.inserted))
}
for _, n := range store.inserted {
if n.ExpiresAt == nil || !n.ExpiresAt.Equal(exp) {
t.Errorf("expected ExpiresAt %v, got %v", exp, n.ExpiresAt)
}
}
}
func TestDeleteAnnouncement_DismissesUnread(t *testing.T) {
store := &fakeStore{}
svc, _ := newTestService(store)
err := svc.DeleteAnnouncement(context.Background(), 42)
if err != nil {
t.Fatalf("DeleteAnnouncement returned error: %v", err)
}
if store.dismissedAnnouncementID != 42 {
t.Errorf("expected dismissedAnnouncementID 42, got %d", store.dismissedAnnouncementID)
}
}
// ---------------------------------------------------------------------------
// CreateSystem tests
// ---------------------------------------------------------------------------
func TestCreateSystem_InsertsSystemNotification(t *testing.T) {
store := &fakeStore{}
svc, _ := newTestService(store)
svc.CreateSystem(context.Background(), 42, "system.password_changed", "Password changed",
"Your account password was changed. If this wasn't you, contact your administrator.")
if len(store.inserted) != 1 {
t.Fatalf("expected 1 inserted notification, got %d", len(store.inserted))
}
n := store.inserted[0]
if n.Category != CategorySystem {
t.Errorf("expected category %q, got %q", CategorySystem, n.Category)
}
if n.Type != "system.password_changed" {
t.Errorf("expected type %q, got %q", "system.password_changed", n.Type)
}
if n.UserID != 42 {
t.Errorf("expected user_id 42, got %d", n.UserID)
}
}
func TestCreateSystem_StoreFailureDoesNotPanic(t *testing.T) {
store := &fakeStore{insertErr: errors.New("db down")}
svc, _ := newTestService(store)
// Must not panic; failure is logged and swallowed.
svc.CreateSystem(context.Background(), 7, "system.password_changed", "Password changed", "body")
if len(store.inserted) != 0 {
t.Errorf("expected 0 insertions on store error, got %d", len(store.inserted))
}
}
// ---------------------------------------------------------------------------
// RunDailyDigest tests
// ---------------------------------------------------------------------------
func TestRunDailyDigest_OptedInUserGetsOne(t *testing.T) {
store := &fakeStore{
digestSubs: []int{7},
addedCounts: map[int]int{7: 3},
prefs: map[int]map[Category]bool{
7: {CategoryContentDigest: true},
},
}
svc, _ := newTestService(store)
err := svc.RunDailyDigest(context.Background(), time.Now().Add(-24*time.Hour))
if err != nil {
t.Fatalf("RunDailyDigest returned error: %v", err)
}
if len(store.inserted) != 1 {
t.Fatalf("expected 1 inserted notification, got %d", len(store.inserted))
}
n := store.inserted[0]
if n.UserID != 7 {
t.Errorf("expected user_id 7, got %d", n.UserID)
}
if n.Type != TypeContentDigest {
t.Errorf("expected type %q, got %q", TypeContentDigest, n.Type)
}
if n.Title != "New in your libraries" {
t.Errorf("expected title %q, got %q", "New in your libraries", n.Title)
}
if !strings.Contains(n.Body, "3") {
t.Errorf("expected body to contain count %q, got %q", "3", n.Body)
}
}
func TestRunDailyDigest_ZeroCountSkipped(t *testing.T) {
store := &fakeStore{
digestSubs: []int{7},
addedCounts: map[int]int{7: 0},
prefs: map[int]map[Category]bool{
7: {CategoryContentDigest: true},
},
}
svc, _ := newTestService(store)
err := svc.RunDailyDigest(context.Background(), time.Now().Add(-24*time.Hour))
if err != nil {
t.Fatalf("RunDailyDigest returned error: %v", err)
}
if len(store.inserted) != 0 {
t.Errorf("expected 0 notifications for zero count, got %d", len(store.inserted))
}
}
func TestRunDailyDigest_SameDayRerunDedups(t *testing.T) {
store := &fakeStore{
digestSubs: []int{7},
addedCounts: map[int]int{7: 5},
prefs: map[int]map[Category]bool{
7: {CategoryContentDigest: true},
},
}
svc, _ := newTestService(store)
since := time.Now().Add(-24 * time.Hour)
if err := svc.RunDailyDigest(context.Background(), since); err != nil {
t.Fatalf("first RunDailyDigest: %v", err)
}
if err := svc.RunDailyDigest(context.Background(), since); err != nil {
t.Fatalf("second RunDailyDigest: %v", err)
}
if len(store.inserted) != 1 {
t.Errorf("expected 1 notification after two same-day runs (dedup), got %d", len(store.inserted))
}
}
func TestRunDailyDigest_CountErrorContinues(t *testing.T) {
store := &fakeStore{
digestSubs: []int{7, 8},
addedCounts: map[int]int{8: 2},
addedErrors: map[int]error{7: errors.New("db timeout")},
prefs: map[int]map[Category]bool{
7: {CategoryContentDigest: true},
8: {CategoryContentDigest: true},
},
}
svc, _ := newTestService(store)
err := svc.RunDailyDigest(context.Background(), time.Now().Add(-24*time.Hour))
if err != nil {
t.Fatalf("RunDailyDigest returned error on count failure: %v", err)
}
if len(store.inserted) != 1 {
t.Fatalf("expected 1 notification (user 7 skipped, user 8 notified), got %d", len(store.inserted))
}
if store.inserted[0].UserID != 8 {
t.Errorf("expected notification for user 8, got user %d", store.inserted[0].UserID)
}
}
+705
View File
@@ -0,0 +1,705 @@
package notifications
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Compile-time check that *Repository satisfies Store.
var _ Store = (*Repository)(nil)
var ErrNotFound = errors.New("notification not found")
// ErrInvalidCategory indicates a preference update referenced a non-mutable
// category. Callers should map it to a 400 (client error) rather than a 500.
var ErrInvalidCategory = errors.New("notification preference category is not mutable")
// Store is the data-access interface for the notifications subsystem.
type Store interface {
Insert(ctx context.Context, n *Notification) (created bool, err error)
List(ctx context.Context, f ListFilter) ([]*Notification, error)
UnreadCount(ctx context.Context, userID int, profileID string, childSafe bool) (int, error)
MarkRead(ctx context.Context, userID int, profileID string, ids []int64) error
MarkAllRead(ctx context.Context, userID int, profileID string) error
Dismiss(ctx context.Context, userID int, profileID string, id int64) error
Preferences(ctx context.Context, userID int) (map[Category]bool, error)
SetPreferences(ctx context.Context, userID int, prefs []Preference) error
InsertAnnouncement(ctx context.Context, a *Announcement) error
ListAnnouncements(ctx context.Context) ([]*Announcement, error)
DeleteAnnouncement(ctx context.Context, id int64) error
DismissAnnouncementNotifications(ctx context.Context, announcementID int64) error
PurgeOld(ctx context.Context, dismissedBefore, allBefore time.Time) (int64, error)
AdminUserIDs(ctx context.Context) ([]int, error)
UserIDsWithLibraryAccess(ctx context.Context, libraryID int) ([]int, error)
AllEnabledUserIDs(ctx context.Context) ([]int, error)
ProfileIDsForUsers(ctx context.Context, userIDs []int) (map[int][]string, error)
DigestSubscribers(ctx context.Context) ([]int, error)
AddedItemCountForUser(ctx context.Context, userID int, since time.Time) (int, error)
}
// Repository implements Store against a pgxpool.
type Repository struct{ pool *pgxpool.Pool }
// NewRepository returns a Repository backed by pool.
func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} }
// nullableString converts an empty string to nil so it stores as SQL NULL.
func nullableString(s string) *string {
if s == "" {
return nil
}
return &s
}
// Insert writes n to the notifications table. If dedup_ref is non-empty and a
// row with the same (user_id, type, dedup_ref) already exists, the INSERT is
// silently dropped and (false, nil) is returned.
// When created is false (dedup conflict), n.ID and n.CreatedAt are left unchanged.
func (r *Repository) Insert(ctx context.Context, n *Notification) (bool, error) {
err := r.pool.QueryRow(ctx, `
INSERT INTO notifications
(user_id, profile_id, category, type, title, body, link, item_id,
source_event, dedup_ref, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (user_id, type, dedup_ref) WHERE dedup_ref IS NOT NULL
DO NOTHING
RETURNING id, created_at
`,
n.UserID,
n.ProfileID,
n.Category,
n.Type,
n.Title,
n.Body,
n.Link,
n.ItemID,
nullableString(n.SourceEvent),
nullableString(n.DedupRef),
n.ExpiresAt,
).Scan(&n.ID, &n.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Dedup conflict: the row was not inserted.
return false, nil
}
return false, fmt.Errorf("insert notification: %w", err)
}
return true, nil
}
// List returns non-dismissed notifications for the given filter.
func (r *Repository) List(ctx context.Context, f ListFilter) ([]*Notification, error) {
limit := f.Limit
if limit <= 0 {
limit = 50
} else if limit > 100 {
// keep in sync with the clamp in internal/api/handlers/notifications.go HandleList
limit = 100
}
args := []any{f.UserID, f.ProfileID}
sb := strings.Builder{}
sb.WriteString(`
SELECT id, user_id, profile_id, category, type, title, body, link, item_id,
source_event, dedup_ref, created_at, read_at, dismissed_at, expires_at
FROM notifications
WHERE user_id = $1
AND dismissed_at IS NULL
AND (profile_id IS NULL OR profile_id = $2)
AND (expires_at IS NULL OR expires_at > now())
`)
if f.ChildSafe {
sb.WriteString(" AND category NOT IN ('request','system','admin')")
}
if f.UnreadOnly {
sb.WriteString(" AND read_at IS NULL")
}
if f.Category != "" {
args = append(args, f.Category)
sb.WriteString(" AND category = $" + strconv.Itoa(len(args)))
}
if f.Cursor > 0 {
args = append(args, f.Cursor)
sb.WriteString(" AND id < $" + strconv.Itoa(len(args)))
}
args = append(args, limit)
sb.WriteString(" ORDER BY id DESC LIMIT $" + strconv.Itoa(len(args)))
rows, err := r.pool.Query(ctx, sb.String(), args...)
if err != nil {
return nil, fmt.Errorf("list notifications: %w", err)
}
defer rows.Close()
var out []*Notification
for rows.Next() {
n, err := scanNotification(rows)
if err != nil {
return nil, fmt.Errorf("scan notification: %w", err)
}
out = append(out, n)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate notifications: %w", err)
}
return out, nil
}
// scanNotification scans a notifications row (15 columns in declaration order).
func scanNotification(row interface{ Scan(dest ...any) error }) (*Notification, error) {
var n Notification
var sourceEvent, dedupRef *string
if err := row.Scan(
&n.ID,
&n.UserID,
&n.ProfileID,
&n.Category,
&n.Type,
&n.Title,
&n.Body,
&n.Link,
&n.ItemID,
&sourceEvent,
&dedupRef,
&n.CreatedAt,
&n.ReadAt,
&n.DismissedAt,
&n.ExpiresAt,
); err != nil {
return nil, err
}
if sourceEvent != nil {
n.SourceEvent = *sourceEvent
}
if dedupRef != nil {
n.DedupRef = *dedupRef
}
return &n, nil
}
// UnreadCount returns the number of unread, non-dismissed, non-expired
// notifications for the user.
func (r *Repository) UnreadCount(ctx context.Context, userID int, profileID string, childSafe bool) (int, error) {
sb := strings.Builder{}
args := []any{userID, profileID}
sb.WriteString(`
SELECT COUNT(*)
FROM notifications
WHERE user_id = $1
AND dismissed_at IS NULL
AND read_at IS NULL
AND (profile_id IS NULL OR profile_id = $2)
AND (expires_at IS NULL OR expires_at > now())
`)
if childSafe {
sb.WriteString(" AND category NOT IN ('request','system','admin')")
}
var count int
if err := r.pool.QueryRow(ctx, sb.String(), args...).Scan(&count); err != nil {
return 0, fmt.Errorf("unread notification count: %w", err)
}
return count, nil
}
// MarkRead sets read_at = now() for the given notification IDs visible to the
// (userID, profileID) pair. Profile scoping prevents one profile from marking a
// sibling profile's notifications read; account-wide rows (profile_id IS NULL)
// are shared and remain markable by any profile.
func (r *Repository) MarkRead(ctx context.Context, userID int, profileID string, ids []int64) error {
if len(ids) == 0 {
return nil
}
_, err := r.pool.Exec(ctx, `
UPDATE notifications
SET read_at = now()
WHERE user_id = $1
AND (profile_id IS NULL OR profile_id = $2)
AND id = ANY($3)
AND read_at IS NULL
`, userID, profileID, ids)
if err != nil {
return fmt.Errorf("mark notifications read: %w", err)
}
return nil
}
// MarkAllRead sets read_at = now() for all unread notifications visible to the
// (userID, profileID) pair (the active profile's rows plus shared account-wide
// rows), leaving sibling profiles' notifications untouched.
func (r *Repository) MarkAllRead(ctx context.Context, userID int, profileID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE notifications
SET read_at = now()
WHERE user_id = $1
AND (profile_id IS NULL OR profile_id = $2)
AND read_at IS NULL
`, userID, profileID)
if err != nil {
return fmt.Errorf("mark all notifications read: %w", err)
}
return nil
}
// Dismiss sets dismissed_at = now() for the notification, scoped to the active
// profile (plus shared account-wide rows). Returns ErrNotFound if no such
// notification exists for this profile or it is already dismissed.
func (r *Repository) Dismiss(ctx context.Context, userID int, profileID string, id int64) error {
tag, err := r.pool.Exec(ctx, `
UPDATE notifications
SET dismissed_at = now()
WHERE user_id = $1
AND (profile_id IS NULL OR profile_id = $2)
AND id = $3
AND dismissed_at IS NULL
`, userID, profileID, id)
if err != nil {
return fmt.Errorf("dismiss notification: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// Preferences returns a map of Category → enabled for the user.
func (r *Repository) Preferences(ctx context.Context, userID int) (map[Category]bool, error) {
rows, err := r.pool.Query(ctx, `
SELECT category, enabled
FROM notification_preferences
WHERE user_id = $1
`, userID)
if err != nil {
return nil, fmt.Errorf("get notification preferences: %w", err)
}
defer rows.Close()
out := make(map[Category]bool)
for rows.Next() {
var cat Category
var enabled bool
if err := rows.Scan(&cat, &enabled); err != nil {
return nil, fmt.Errorf("scan notification preference: %w", err)
}
out[cat] = enabled
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate notification preferences: %w", err)
}
return out, nil
}
// SetPreferences upserts the given preferences for the user in a single
// transaction, so a mid-batch failure never leaves a partially applied set.
func (r *Repository) SetPreferences(ctx context.Context, userID int, prefs []Preference) error {
if len(prefs) == 0 {
return nil
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("set notification preferences: begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
for _, p := range prefs {
if _, err := tx.Exec(ctx, `
INSERT INTO notification_preferences (user_id, category, enabled)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, category) DO UPDATE SET enabled = EXCLUDED.enabled
`, userID, p.Category, p.Enabled); err != nil {
return fmt.Errorf("set notification preference %q: %w", p.Category, err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("set notification preferences: commit: %w", err)
}
return nil
}
// InsertAnnouncement writes a to the announcements table, populating ID and CreatedAt.
func (r *Repository) InsertAnnouncement(ctx context.Context, a *Announcement) error {
audienceJSON, err := json.Marshal(a.Audience)
if err != nil {
return fmt.Errorf("marshal announcement audience: %w", err)
}
err = r.pool.QueryRow(ctx, `
INSERT INTO announcements (title, body, audience, created_by, expires_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at
`, a.Title, a.Body, audienceJSON, a.CreatedBy, a.ExpiresAt).Scan(&a.ID, &a.CreatedAt)
if err != nil {
return fmt.Errorf("insert announcement: %w", err)
}
return nil
}
// ListAnnouncements returns all announcements ordered newest-first.
// Returns all announcements including expired ones — this is the admin management view.
func (r *Repository) ListAnnouncements(ctx context.Context) ([]*Announcement, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, title, body, audience, created_by, created_at, expires_at
FROM announcements
ORDER BY id DESC
`)
if err != nil {
return nil, fmt.Errorf("list announcements: %w", err)
}
defer rows.Close()
var out []*Announcement
for rows.Next() {
var a Announcement
var audienceJSON []byte
if err := rows.Scan(
&a.ID,
&a.Title,
&a.Body,
&audienceJSON,
&a.CreatedBy,
&a.CreatedAt,
&a.ExpiresAt,
); err != nil {
return nil, fmt.Errorf("scan announcement: %w", err)
}
if err := json.Unmarshal(audienceJSON, &a.Audience); err != nil {
return nil, fmt.Errorf("unmarshal announcement audience: %w", err)
}
out = append(out, &a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate announcements: %w", err)
}
return out, nil
}
// DeleteAnnouncement removes the announcement by ID. Returns ErrNotFound if it
// does not exist.
func (r *Repository) DeleteAnnouncement(ctx context.Context, id int64) error {
tag, err := r.pool.Exec(ctx, `DELETE FROM announcements WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("delete announcement: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// DismissAnnouncementNotifications dismisses the unread notification copies of
// an announcement across every profile it was fanned out to. It matches both
// the per-profile dedup refs (announcement-<id>-<profileID>) and the legacy
// account-wide ref (announcement-<id>). The trailing "-" in the LIKE prevents
// announcement-5 from matching announcement-50.
func (r *Repository) DismissAnnouncementNotifications(ctx context.Context, announcementID int64) error {
ref := fmt.Sprintf("announcement-%d", announcementID)
_, err := r.pool.Exec(ctx, `
UPDATE notifications
SET dismissed_at = now()
WHERE type = 'announcement'
AND (dedup_ref = $1 OR dedup_ref LIKE $1 || '-%')
AND read_at IS NULL
AND dismissed_at IS NULL
`, ref)
if err != nil {
return fmt.Errorf("dismiss announcement notifications: %w", err)
}
return nil
}
// ProfileIDsForUsers returns the profile ids for each given user id, keyed by
// user id. Users that have no profiles are absent from the returned map.
func (r *Repository) ProfileIDsForUsers(ctx context.Context, userIDs []int) (map[int][]string, error) {
out := make(map[int][]string, len(userIDs))
if len(userIDs) == 0 {
return out, nil
}
rows, err := r.pool.Query(ctx,
`SELECT user_id, id FROM user_profiles WHERE user_id = ANY($1) ORDER BY user_id, created_at`,
userIDs)
if err != nil {
return nil, fmt.Errorf("profiles for users: %w", err)
}
defer rows.Close()
for rows.Next() {
var uid int
var pid string
if err := rows.Scan(&uid, &pid); err != nil {
return nil, fmt.Errorf("scan profile: %w", err)
}
out[uid] = append(out[uid], pid)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate profiles: %w", err)
}
return out, nil
}
// PurgeOld deletes old notifications according to three criteria and returns
// the number of rows deleted.
func (r *Repository) PurgeOld(ctx context.Context, dismissedBefore, allBefore time.Time) (int64, error) {
tag, err := r.pool.Exec(ctx, `
DELETE FROM notifications
WHERE (dismissed_at IS NOT NULL AND dismissed_at < $1)
OR created_at < $2
OR (expires_at IS NOT NULL AND expires_at < now() AND read_at IS NULL)
`, dismissedBefore, allBefore)
if err != nil {
return 0, fmt.Errorf("purge old notifications: %w", err)
}
return tag.RowsAffected(), nil
}
// AdminUserIDs returns the IDs of all enabled admin users.
func (r *Repository) AdminUserIDs(ctx context.Context) ([]int, error) {
rows, err := r.pool.Query(ctx, `
SELECT id FROM users WHERE role = 'admin' AND enabled = true
`)
if err != nil {
return nil, fmt.Errorf("get admin user ids: %w", err)
}
defer rows.Close()
var out []int
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan admin user id: %w", err)
}
out = append(out, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate admin user ids: %w", err)
}
return out, nil
}
// UserIDsWithLibraryAccess returns the IDs of enabled users who either have no
// library restriction or have libraryID in their library_ids array.
func (r *Repository) UserIDsWithLibraryAccess(ctx context.Context, libraryID int) ([]int, error) {
rows, err := r.pool.Query(ctx, `
SELECT id FROM users
WHERE enabled = true
AND (library_ids IS NULL OR $1 = ANY(library_ids))
`, libraryID)
if err != nil {
return nil, fmt.Errorf("get user ids with library access: %w", err)
}
defer rows.Close()
var out []int
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan user id with library access: %w", err)
}
out = append(out, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate user ids with library access: %w", err)
}
return out, nil
}
// DigestSubscribers returns IDs of enabled users who opted into the daily digest.
func (r *Repository) DigestSubscribers(ctx context.Context) ([]int, error) {
rows, err := r.pool.Query(ctx, `
SELECT p.user_id
FROM notification_preferences p
JOIN users u ON u.id = p.user_id AND u.enabled = true
WHERE p.category = 'content_digest'
AND p.enabled = true
`)
if err != nil {
return nil, fmt.Errorf("digest subscribers: %w", err)
}
defer rows.Close()
var ids []int
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan digest subscriber: %w", err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate digest subscribers: %w", err)
}
return ids, nil
}
// AddedItemCountForUser counts distinct catalog items added since `since` in
// libraries the user can access. A NULL library_ids means the user can access
// all libraries. count(DISTINCT ...) prevents double-counting items that belong
// to multiple folders.
func (r *Repository) AddedItemCountForUser(ctx context.Context, userID int, since time.Time) (int, error) {
var count int
err := r.pool.QueryRow(ctx, `
SELECT count(DISTINCT mi.content_id)
FROM media_items mi
JOIN media_item_libraries mil ON mil.content_id = mi.content_id
JOIN users u ON u.id = $1
WHERE mi.created_at > $2
AND (u.library_ids IS NULL OR mil.media_folder_id = ANY(u.library_ids))
`, userID, since).Scan(&count)
if err != nil {
return 0, fmt.Errorf("added item count for user %d: %w", userID, err)
}
return count, nil
}
// AllEnabledUserIDs returns every enabled user id.
func (r *Repository) AllEnabledUserIDs(ctx context.Context) ([]int, error) {
rows, err := r.pool.Query(ctx, `SELECT id FROM users WHERE enabled = true`)
if err != nil {
return nil, fmt.Errorf("list enabled users: %w", err)
}
defer rows.Close()
var ids []int
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan user id: %w", err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate enabled user ids: %w", err)
}
return ids, nil
}
// ContentResolverRepo implements ContentResolver against a pgxpool.
type ContentResolverRepo struct{ pool *pgxpool.Pool }
// NewContentResolverRepo returns a ContentResolverRepo backed by pool.
func NewContentResolverRepo(pool *pgxpool.Pool) *ContentResolverRepo {
return &ContentResolverRepo{pool: pool}
}
// ItemContext resolves the display title, series id (empty for non-episodes),
// library id, and catalog row creation time for the given content_id.
//
// media_item_libraries.media_folder_id is the same integer as the library_id
// field in catalog events.
func (c *ContentResolverRepo) ItemContext(ctx context.Context, contentID string) (title, seriesID string, libraryID int, createdAt time.Time, err error) {
err = c.pool.QueryRow(ctx, `
SELECT
mi.title,
COALESCE(e.series_id, '') AS series_id,
COALESCE(mil.media_folder_id, 0) AS library_id,
mi.created_at
FROM media_items mi
LEFT JOIN episodes e ON e.content_id = mi.content_id
LEFT JOIN media_item_libraries mil ON mil.content_id = mi.content_id
WHERE mi.content_id = $1
LIMIT 1
`, contentID).Scan(&title, &seriesID, &libraryID, &createdAt)
if err != nil {
return "", "", 0, time.Time{}, fmt.Errorf("item context for %s: %w", contentID, err)
}
return title, seriesID, libraryID, createdAt, nil
}
// InterestedProfiles returns (user_id, profile_id) pairs that should receive a
// content.added notification for the given item. It unions three interest legs:
//
// 1. Watchlist — profiles whose watchlist contains contentID or seriesID.
// 2. Favorites — profiles whose favorites contain contentID or seriesID.
// 3. In-progress — profiles with recent watch_progress on any episode of
// seriesID (only when seriesID is non-empty) within the in-progress window.
//
// Results are restricted to users who are enabled and have access to libraryID.
func (c *ContentResolverRepo) InterestedProfiles(
ctx context.Context,
contentID, seriesID string,
libraryID int,
inProgressSince time.Time,
) ([]ProfileRef, error) {
// Build the query with optional in-progress leg.
// The in-progress leg requires seriesID to be non-empty: it joins episodes
// on series_id to find profiles that watched any episode of the series.
//
// Parameters:
// $1 libraryID
// $2 contentID – matched directly in watchlist/favorites IN-clause
// $3 seriesArg – seriesID when non-empty, otherwise contentID so the
// IN-clause degrades to IN (contentID, contentID)
// $4 seriesID – raw series id used only for the in-progress guard
// ($4 <> '') and join; never the degraded contentID so a
// standalone item cannot accidentally join episode rows
// $5 inProgressSince
query := `
WITH eligible_users AS (
SELECT id AS user_id
FROM users
WHERE enabled = true
AND (library_ids IS NULL OR $1 = ANY(library_ids))
),
watchlist_leg AS (
SELECT user_id, profile_id
FROM user_watchlist
WHERE media_item_id IN ($2, $3)
),
favorites_leg AS (
SELECT user_id, profile_id
FROM user_favorites
WHERE media_item_id IN ($2, $3)
),
inprogress_leg AS (
SELECT uwp.user_id, uwp.profile_id
FROM user_watch_progress uwp
JOIN episodes e ON e.content_id = uwp.media_item_id
WHERE $4 <> ''
AND e.series_id = $4
AND uwp.updated_at >= $5
),
all_interests AS (
SELECT user_id, profile_id FROM watchlist_leg
UNION
SELECT user_id, profile_id FROM favorites_leg
UNION
SELECT user_id, profile_id FROM inprogress_leg
)
SELECT DISTINCT ai.user_id, ai.profile_id
FROM all_interests ai
JOIN eligible_users eu ON eu.user_id = ai.user_id
ORDER BY ai.user_id, ai.profile_id
`
// seriesArg degrades to contentID for standalone items so the IN-clause
// in watchlist/favorites remains valid: IN (contentID, contentID) = IN (contentID).
// The in-progress leg uses the raw seriesID ($4) separately so a standalone
// item (seriesID == "") can never join episode rows via its own content id.
seriesArg := seriesID
if seriesArg == "" {
seriesArg = contentID
}
rows, err := c.pool.Query(ctx, query, libraryID, contentID, seriesArg, seriesID, inProgressSince)
if err != nil {
return nil, fmt.Errorf("interested profiles for %s: %w", contentID, err)
}
defer rows.Close()
var refs []ProfileRef
for rows.Next() {
var ref ProfileRef
if err := rows.Scan(&ref.UserID, &ref.ProfileID); err != nil {
return nil, fmt.Errorf("scan interested profile: %w", err)
}
refs = append(refs, ref)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate interested profiles: %w", err)
}
return refs, nil
}
+83
View File
@@ -0,0 +1,83 @@
package notifications
import "time"
// Bounds on admin-authored announcement text. Announcements fan out one row per
// (user, profile), so these caps limit write amplification across all inboxes.
const (
MaxAnnouncementTitleLen = 200
MaxAnnouncementBodyLen = 4000
)
// EventUserPasswordChanged is the domain event published (on ChannelSessions)
// when an account password changes. Shared by the publisher (admin user
// handler) and the system matcher so the two cannot drift apart.
const EventUserPasswordChanged = "user.password_changed"
type Category string
const (
CategoryRequest Category = "request"
CategoryContent Category = "content"
CategoryAnnouncement Category = "announcement"
CategorySystem Category = "system"
CategoryAdmin Category = "admin"
// CategoryContentDigest is a preference key only; digest rows are stored
// with CategoryContent and type "content.digest".
CategoryContentDigest Category = "content_digest"
)
// MutableCategories are valid notification_preferences rows.
var MutableCategories = []Category{
CategoryRequest, CategoryContent, CategorySystem, CategoryAdmin, CategoryContentDigest,
}
type Notification struct {
ID int64 `json:"id"`
UserID int `json:"user_id"`
ProfileID *string `json:"profile_id,omitempty"`
Category Category `json:"category"`
Type string `json:"type"`
Title string `json:"title"`
Body string `json:"body"`
Link *string `json:"link,omitempty"`
ItemID *string `json:"item_id,omitempty"`
SourceEvent string `json:"-"`
DedupRef string `json:"-"`
CreatedAt time.Time `json:"created_at"`
ReadAt *time.Time `json:"read_at,omitempty"`
DismissedAt *time.Time `json:"-"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
type Preference struct {
Category Category `json:"category"`
Enabled bool `json:"enabled"`
}
type Announcement struct {
ID int64 `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Audience Audience `json:"audience"`
CreatedBy *int `json:"created_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// Audience: exactly one field set.
type Audience struct {
All bool `json:"all,omitempty"`
UserIDs []int `json:"user_ids,omitempty"`
LibraryIDs []int `json:"library_ids,omitempty"`
}
type ListFilter struct {
UserID int
ProfileID string // active profile; matches profile_id IS NULL OR profile_id = this
ChildSafe bool // true => exclude request/system/admin categories
UnreadOnly bool
Category Category // optional
Cursor int64 // created-before id cursor; 0 = first page
Limit int
}
+62
View File
@@ -0,0 +1,62 @@
package presence
import (
"context"
"fmt"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
// RedisRegistry is a cluster-aware presence registry. Add increments a per-user
// counter key with a TTL; Connected reports key > 0. On any Redis error,
// Connected returns false so callers fail open (push proceeds rather than being
// suppressed by an outage).
type RedisRegistry struct {
client *redis.Client
ttl time.Duration
}
func NewRedisRegistry(client *redis.Client, ttl time.Duration) *RedisRegistry {
if ttl <= 0 {
ttl = 60 * time.Second
}
return &RedisRegistry{client: client, ttl: ttl}
}
func presenceKey(userID int) string { return fmt.Sprintf("push:presence:%d", userID) }
func (r *RedisRegistry) Add(ctx context.Context, userID int) func() {
key := presenceKey(userID)
pipe := r.client.TxPipeline()
pipe.Incr(ctx, key)
pipe.Expire(ctx, key, r.ttl)
_, _ = pipe.Exec(ctx) // best-effort; presence is advisory
var once sync.Once
return func() {
once.Do(func() {
bg, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if n, err := r.client.Decr(bg, key).Result(); err == nil && n <= 0 {
r.client.Del(bg, key)
}
})
}
}
func (r *RedisRegistry) Connected(ctx context.Context, userID int) bool {
n, err := r.client.Get(ctx, presenceKey(userID)).Int()
if err != nil {
return false // fail open: unknown presence → allow push
}
return n > 0
}
// Refresh extends the TTL for a user's presence key; call on WS heartbeat.
func (r *RedisRegistry) Refresh(ctx context.Context, userID int) {
r.client.Expire(ctx, presenceKey(userID), r.ttl)
}
var _ Registry = (*RedisRegistry)(nil)
+67
View File
@@ -0,0 +1,67 @@
package presence
import (
"context"
"os"
"testing"
"time"
"github.com/redis/go-redis/v9"
)
func testRedis(t *testing.T) *redis.Client {
t.Helper()
url := os.Getenv("TEST_REDIS_URL")
if url == "" {
url = "redis://localhost:6379/15"
}
opt, err := redis.ParseURL(url)
if err != nil {
t.Skipf("redis url parse: %v", err)
}
c := redis.NewClient(opt)
if err := c.Ping(context.Background()).Err(); err != nil {
t.Skipf("redis unavailable: %v", err)
}
return c
}
func TestRedisRegistry_ConnectedAndRelease(t *testing.T) {
c := testRedis(t)
ctx := context.Background()
c.FlushDB(ctx)
r := NewRedisRegistry(c, 60*time.Second)
if r.Connected(ctx, 42) {
t.Fatal("absent initially")
}
rel := r.Add(ctx, 42)
if !r.Connected(ctx, 42) {
t.Fatal("present after Add")
}
rel()
if r.Connected(ctx, 42) {
t.Fatal("absent after release")
}
}
func TestRedisRegistry_RefcountTwoConnections(t *testing.T) {
c := testRedis(t)
ctx := context.Background()
c.FlushDB(ctx)
r := NewRedisRegistry(c, 60*time.Second)
rel1 := r.Add(ctx, 7)
rel2 := r.Add(ctx, 7)
rel1()
if !r.Connected(ctx, 7) {
t.Fatal("one connection remains")
}
rel2()
if r.Connected(ctx, 7) {
t.Fatal("absent after both released")
}
}
func TestRedisRegistry_ImplementsInterface(t *testing.T) {
var _ Registry = (*RedisRegistry)(nil)
}
+61
View File
@@ -0,0 +1,61 @@
package presence
import (
"context"
"sync"
)
// Registry tracks whether a user has at least one live realtime connection.
// Used to suppress push notifications to users who are actively connected.
type Registry interface {
// Add registers one live connection for userID and returns a release
// function that must be called on disconnect. The returned function is
// safe to call multiple times.
Add(ctx context.Context, userID int) (release func())
// Connected reports whether userID has any live connection.
Connected(ctx context.Context, userID int) bool
// Refresh extends the liveness of userID's presence (used by registries
// with a TTL, such as Redis). Process-local registries may no-op.
Refresh(ctx context.Context, userID int)
}
// MemoryRegistry is a process-local refcount registry.
type MemoryRegistry struct {
mu sync.Mutex
counts map[int]int
}
func NewMemoryRegistry() *MemoryRegistry {
return &MemoryRegistry{counts: make(map[int]int)}
}
func (m *MemoryRegistry) Add(_ context.Context, userID int) func() {
m.mu.Lock()
m.counts[userID]++
m.mu.Unlock()
var once sync.Once
return func() {
once.Do(func() {
m.mu.Lock()
if m.counts[userID] > 0 {
m.counts[userID]--
if m.counts[userID] == 0 {
delete(m.counts, userID)
}
}
m.mu.Unlock()
})
}
}
func (m *MemoryRegistry) Connected(_ context.Context, userID int) bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.counts[userID] > 0
}
// Refresh is a no-op for the process-local registry, which has no TTL.
func (m *MemoryRegistry) Refresh(_ context.Context, _ int) {}
var _ Registry = (*MemoryRegistry)(nil)
+42
View File
@@ -0,0 +1,42 @@
package presence
import (
"context"
"testing"
)
func TestMemoryRegistry_ConnectedRefcount(t *testing.T) {
r := NewMemoryRegistry()
ctx := context.Background()
if r.Connected(ctx, 7) {
t.Fatal("user should start absent")
}
rel1 := r.Add(ctx, 7)
rel2 := r.Add(ctx, 7)
if !r.Connected(ctx, 7) {
t.Fatal("user should be present after Add")
}
rel1()
if !r.Connected(ctx, 7) {
t.Fatal("still present: one connection remains")
}
rel2()
if r.Connected(ctx, 7) {
t.Fatal("absent after all releases")
}
}
func TestMemoryRegistry_ReleaseIdempotent(t *testing.T) {
r := NewMemoryRegistry()
ctx := context.Background()
rel := r.Add(ctx, 1)
rel()
rel() // double release must not underflow / panic
if r.Connected(ctx, 1) {
t.Fatal("absent")
}
r.Add(ctx, 1)
if !r.Connected(ctx, 1) {
t.Fatal("present again")
}
}
+73
View File
@@ -0,0 +1,73 @@
package push
import "context"
// settingsReader is the slice of the settings repo we need.
type settingsReader interface {
Get(ctx context.Context, key string) (string, error)
}
type APNsConfig struct {
P8Key, KeyID, TeamID, BundleID string
}
type FCMConfig struct {
ServiceAccountJSON string
}
type WebPushConfig struct {
VAPIDPublic, VAPIDPrivate, Subject string
}
func (c APNsConfig) Configured() bool {
return c.P8Key != "" && c.KeyID != "" && c.TeamID != "" && c.BundleID != ""
}
func (c FCMConfig) Configured() bool { return c.ServiceAccountJSON != "" }
func (c WebPushConfig) Configured() bool {
return c.VAPIDPublic != "" && c.VAPIDPrivate != "" && c.Subject != ""
}
// Config loads provider config from the (encrypted) settings repo on demand.
type Config struct{ s settingsReader }
func NewConfig(s settingsReader) *Config { return &Config{s: s} }
func (c *Config) get(ctx context.Context, key string) string {
v, err := c.s.Get(ctx, key)
if err != nil {
return ""
}
return v
}
func (c *Config) APNs(ctx context.Context) APNsConfig {
return APNsConfig{
P8Key: c.get(ctx, "push.apns.p8_key"),
KeyID: c.get(ctx, "push.apns.key_id"),
TeamID: c.get(ctx, "push.apns.team_id"),
BundleID: c.get(ctx, "push.apns.bundle_id"),
}
}
func (c *Config) FCM(ctx context.Context) FCMConfig {
return FCMConfig{ServiceAccountJSON: c.get(ctx, "push.fcm.service_account_json")}
}
func (c *Config) WebPush(ctx context.Context) WebPushConfig {
return WebPushConfig{
VAPIDPublic: c.get(ctx, "push.webpush.vapid_public"),
VAPIDPrivate: c.get(ctx, "push.webpush.vapid_private"),
Subject: c.get(ctx, "push.webpush.subject"),
}
}
// Status reports per-transport configured booleans (no secrets).
type Status struct {
APNs bool `json:"apns"`
FCM bool `json:"fcm"`
WebPush bool `json:"webpush"`
}
func (c *Config) Status(ctx context.Context) Status {
return Status{
APNs: c.APNs(ctx).Configured(),
FCM: c.FCM(ctx).Configured(),
WebPush: c.WebPush(ctx).Configured(),
}
}
+33
View File
@@ -0,0 +1,33 @@
package push
import (
"context"
"testing"
)
type fakeSettings map[string]string
func (f fakeSettings) Get(_ context.Context, key string) (string, error) { return f[key], nil }
func TestConfig_Status(t *testing.T) {
s := fakeSettings{
"push.webpush.vapid_public": "pub",
"push.webpush.vapid_private": "priv",
"push.webpush.subject": "mailto:a@b.com",
}
c := NewConfig(s)
st := c.Status(context.Background())
if !st.WebPush || st.APNs || st.FCM {
t.Fatalf("status = %+v, want only webpush", st)
}
}
func TestConfig_APNsConfiguredRequiresAll(t *testing.T) {
c := NewConfig(fakeSettings{
"push.apns.p8_key": "k", "push.apns.key_id": "id", "push.apns.team_id": "t",
// bundle_id missing → not configured
})
if c.APNs(context.Background()).Configured() {
t.Fatal("apns must require all four fields")
}
}
+56
View File
@@ -0,0 +1,56 @@
package push
import (
"context"
"log/slog"
"time"
"github.com/Silo-Server/silo-server/internal/notifications"
)
// deviceSource is the slice of Store the enqueuer needs (interface for testing).
type deviceSource interface {
EligibleDevices(ctx context.Context, userID int, profileID, category string) ([]Device, error)
EnqueueDelivery(ctx context.Context, notificationID int64, d Device, notBefore time.Time) error
}
var _ deviceSource = (*Store)(nil)
// Enqueuer mirrors created notifications to per-device delivery rows with a
// presence-grace not_before. Best-effort: failures log and return.
type Enqueuer struct {
src deviceSource
grace time.Duration
now func() time.Time
}
func NewEnqueuer(src deviceSource, grace time.Duration, now func() time.Time) *Enqueuer {
if now == nil {
now = time.Now
}
return &Enqueuer{src: src, grace: grace, now: now}
}
func (e *Enqueuer) EnqueueForNotification(ctx context.Context, n *notifications.Notification) {
if n == nil || n.UserID <= 0 {
return
}
profileID := ""
if n.ProfileID != nil {
profileID = *n.ProfileID
}
devices, err := e.src.EligibleDevices(ctx, n.UserID, profileID, string(n.Category))
if err != nil {
slog.WarnContext(ctx, "push: eligible devices failed", "notification_id", n.ID, "error", err)
return
}
notBefore := e.now().Add(e.grace)
for _, d := range devices {
if err := e.src.EnqueueDelivery(ctx, n.ID, d, notBefore); err != nil {
slog.WarnContext(ctx, "push: enqueue delivery failed", "notification_id", n.ID, "device_id", d.DeviceID, "error", err)
}
}
}
// (compile-time check that Enqueuer satisfies the notifications seam)
var _ notifications.PushEnqueuer = (*Enqueuer)(nil)
+113
View File
@@ -0,0 +1,113 @@
package push
import (
"context"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/notifications"
)
type fakeDeviceSource struct {
devices []Device
enqueued []struct {
nid int64
dev Device
nb time.Time
}
eligibleErr error
}
func (f *fakeDeviceSource) EligibleDevices(_ context.Context, _ int, _, _ string) ([]Device, error) {
if f.eligibleErr != nil {
return nil, f.eligibleErr
}
return f.devices, nil
}
func (f *fakeDeviceSource) EnqueueDelivery(_ context.Context, nid int64, d Device, nb time.Time) error {
f.enqueued = append(f.enqueued, struct {
nid int64
dev Device
nb time.Time
}{nid, d, nb})
return nil
}
func TestEnqueuer_WritesOnePerDevice(t *testing.T) {
src := &fakeDeviceSource{devices: []Device{
{UserID: 7, DeviceID: "d1", Transport: "apns", Token: "t1"},
{UserID: 7, DeviceID: "d2", Transport: "webpush", Token: "t2"},
}}
now := time.Unix(1_000_000, 0)
e := NewEnqueuer(src, 30*time.Second, func() time.Time { return now })
link := "/requests"
e.EnqueueForNotification(context.Background(), &notifications.Notification{
ID: 5, UserID: 7, Category: notifications.CategoryRequest, Title: "Approved", Link: &link,
})
if len(src.enqueued) != 2 {
t.Fatalf("enqueued = %d, want 2", len(src.enqueued))
}
if !src.enqueued[0].nb.Equal(now.Add(30 * time.Second)) {
t.Fatalf("not_before = %v, want now+30s", src.enqueued[0].nb)
}
if src.enqueued[0].nid != 5 {
t.Fatalf("notification id = %d, want 5", src.enqueued[0].nid)
}
}
func TestEnqueuer_NoDevicesNoop(t *testing.T) {
src := &fakeDeviceSource{devices: nil}
e := NewEnqueuer(src, 30*time.Second, time.Now)
e.EnqueueForNotification(context.Background(), &notifications.Notification{ID: 1, UserID: 7, Category: notifications.CategoryContent, Title: "x"})
if len(src.enqueued) != 0 {
t.Fatal("no devices should enqueue nothing")
}
}
func TestEnqueuer_EligibleErrorIsSwallowed(t *testing.T) {
src := &fakeDeviceSource{eligibleErr: context.DeadlineExceeded}
e := NewEnqueuer(src, 30*time.Second, time.Now)
e.EnqueueForNotification(context.Background(), &notifications.Notification{ID: 1, UserID: 7, Category: notifications.CategorySystem, Title: "x"})
if len(src.enqueued) != 0 {
t.Fatal("error path enqueues nothing")
}
}
// TestEnqueuer_ProfileIDPropagated asserts that the Device (including
// ProfileID) passed to EligibleDevices is forwarded unchanged to
// EnqueueDelivery so that the delivery row is scoped to the correct
// (profile, device) pair.
func TestEnqueuer_ProfileIDPropagated(t *testing.T) {
src := &fakeDeviceSource{devices: []Device{
{UserID: 7, ProfileID: "prof-active", DeviceID: "d1", Transport: TransportWebPush, Token: "t1"},
}}
now := time.Unix(2_000_000, 0)
e := NewEnqueuer(src, 0, func() time.Time { return now })
e.EnqueueForNotification(context.Background(), &notifications.Notification{
ID: 99, UserID: 7, Category: notifications.CategoryContent, Title: "hi",
})
if len(src.enqueued) != 1 {
t.Fatalf("enqueued = %d, want 1", len(src.enqueued))
}
got := src.enqueued[0].dev
if got.ProfileID != "prof-active" {
t.Errorf("ProfileID = %q, want %q", got.ProfileID, "prof-active")
}
if got.DeviceID != "d1" {
t.Errorf("DeviceID = %q, want %q", got.DeviceID, "d1")
}
}
func TestEnqueuer_NilOrZeroUserNoop(t *testing.T) {
src := &fakeDeviceSource{devices: []Device{{UserID: 7, DeviceID: "d1"}}}
e := NewEnqueuer(src, 30*time.Second, time.Now)
e.EnqueueForNotification(context.Background(), nil)
e.EnqueueForNotification(context.Background(), &notifications.Notification{ID: 1, UserID: 0, Title: "x", Category: notifications.CategoryContent})
if len(src.enqueued) != 0 {
t.Fatal("nil/zero-user enqueues nothing")
}
}
+236
View File
@@ -0,0 +1,236 @@
package push
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("push: not found")
// hiddenForChild are categories never pushed to a child profile (mirrors the
// web toast suppression rule).
var hiddenForChild = []string{"request", "system", "admin"}
type Store struct{ pool *pgxpool.Pool }
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
// EligibleDevices returns push-eligible devices for a notification addressed to
// userID (profileID empty = all of the user's devices). A device is excluded
// when category is child-hidden and the device's profile is a child.
func (s *Store) EligibleDevices(ctx context.Context, userID int, profileID, category string) ([]Device, error) {
q := `
SELECT d.user_id, d.profile_id, d.device_id, d.push_transport, d.push_token
FROM user_devices d
JOIN user_profiles p ON p.user_id = d.user_id AND p.id = d.profile_id
WHERE d.user_id = $1
AND d.push_token IS NOT NULL
AND d.push_enabled = true
AND ($2 = '' OR d.profile_id = $2)
AND NOT (p.is_child AND $3 = ANY($4::text[]))`
rows, err := s.pool.Query(ctx, q, userID, profileID, category, hiddenForChild)
if err != nil {
return nil, fmt.Errorf("eligible devices: %w", err)
}
defer rows.Close()
var out []Device
for rows.Next() {
var d Device
var transport, token *string
if err := rows.Scan(&d.UserID, &d.ProfileID, &d.DeviceID, &transport, &token); err != nil {
return nil, fmt.Errorf("scan device: %w", err)
}
if transport != nil {
d.Transport = *transport
}
if token != nil {
d.Token = *token
}
out = append(out, d)
}
return out, rows.Err()
}
// EnqueueDelivery writes one pending delivery row.
func (s *Store) EnqueueDelivery(ctx context.Context, notificationID int64, d Device, notBefore time.Time) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO push_deliveries (notification_id, user_id, profile_id, device_id, transport, status, not_before)
VALUES ($1,$2,$3,$4,$5,'pending',$6)`,
notificationID, d.UserID, d.ProfileID, d.DeviceID, d.Transport, notBefore)
if err != nil {
return fmt.Errorf("enqueue delivery: %w", err)
}
return nil
}
// claimedDelivery bundles a delivery row with the data needed to send it.
type claimedDelivery struct {
Delivery
Token string
Payload Payload
}
// ClaimDue opens a tx, locks up to limit due deliveries FOR UPDATE SKIP LOCKED,
// joining device token + notification content. The caller records outcomes via
// the Mark*Tx methods on the returned tx, then Commit/Rollback and Release the
// conn.
func (s *Store) ClaimDue(ctx context.Context, now time.Time, limit int) (*pgxpool.Conn, pgx.Tx, []claimedDelivery, error) {
conn, err := s.pool.Acquire(ctx)
if err != nil {
return nil, nil, nil, fmt.Errorf("acquire: %w", err)
}
tx, err := conn.Begin(ctx)
if err != nil {
conn.Release()
return nil, nil, nil, fmt.Errorf("begin: %w", err)
}
rows, err := tx.Query(ctx, `
SELECT pd.id, pd.notification_id, pd.user_id, pd.profile_id, pd.device_id, pd.transport, pd.status, pd.attempts, pd.not_before,
d.push_token, n.title, n.body, COALESCE(n.link,''), n.category
FROM push_deliveries pd
JOIN user_devices d ON d.user_id = pd.user_id AND d.device_id = pd.device_id AND d.profile_id = pd.profile_id AND d.push_enabled = true AND d.push_token IS NOT NULL
JOIN notifications n ON n.id = pd.notification_id
WHERE pd.status IN ('pending','failed') AND pd.not_before <= $1
ORDER BY pd.not_before
LIMIT $2
FOR UPDATE OF pd SKIP LOCKED`, now, limit)
if err != nil {
_ = tx.Rollback(ctx)
conn.Release()
return nil, nil, nil, fmt.Errorf("claim query: %w", err)
}
defer rows.Close()
var out []claimedDelivery
for rows.Next() {
var c claimedDelivery
var token *string
if err := rows.Scan(&c.ID, &c.NotificationID, &c.UserID, &c.ProfileID, &c.DeviceID, &c.Transport,
&c.Status, &c.Attempts, &c.NotBefore,
&token, &c.Payload.Title, &c.Payload.Body, &c.Payload.Link, &c.Payload.Category); err != nil {
_ = tx.Rollback(ctx)
conn.Release()
return nil, nil, nil, fmt.Errorf("scan claim: %w", err)
}
if token != nil {
c.Token = *token
}
c.Payload.NotificationID = c.NotificationID
out = append(out, c)
}
if err := rows.Err(); err != nil {
_ = tx.Rollback(ctx)
conn.Release()
return nil, nil, nil, err
}
return conn, tx, out, nil
}
func (s *Store) MarkSentTx(ctx context.Context, tx pgx.Tx, id int64) error {
_, err := tx.Exec(ctx, `UPDATE push_deliveries SET status='sent', attempts=attempts+1, updated_at=now() WHERE id=$1`, id)
return err
}
func (s *Store) MarkSkippedTx(ctx context.Context, tx pgx.Tx, id int64, reason string) error {
_, err := tx.Exec(ctx, `UPDATE push_deliveries SET status='skipped', last_error=$2, updated_at=now() WHERE id=$1`, id, reason)
return err
}
func (s *Store) MarkFailedTx(ctx context.Context, tx pgx.Tx, id int64, nextNotBefore time.Time, errMsg string) error {
_, err := tx.Exec(ctx, `UPDATE push_deliveries SET status='failed', attempts=attempts+1, not_before=$2, last_error=$3, updated_at=now() WHERE id=$1`, id, nextNotBefore, errMsg)
return err
}
func (s *Store) MarkDeadTx(ctx context.Context, tx pgx.Tx, id int64, errMsg string) error {
_, err := tx.Exec(ctx, `UPDATE push_deliveries SET status='dead', attempts=attempts+1, last_error=$2, updated_at=now() WHERE id=$1`, id, errMsg)
return err
}
// PruneTokenTx clears a dead token from the device row (within the claim tx).
func (s *Store) PruneTokenTx(ctx context.Context, tx pgx.Tx, userID int, deviceID string) error {
_, err := tx.Exec(ctx, `UPDATE user_devices SET push_token=NULL, push_enabled=false WHERE user_id=$1 AND device_id=$2`, userID, deviceID)
return err
}
// RegisterToken upserts a push token onto the device row.
func (s *Store) RegisterToken(ctx context.Context, userID int, profileID, deviceID, transport, token string) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO user_devices (user_id, profile_id, device_id, push_token, push_transport, push_enabled, push_token_at, push_failures)
VALUES ($1,$2,$3,$4,$5,true,now(),0)
ON CONFLICT (user_id, profile_id, device_id) DO UPDATE
SET push_token=$4, push_transport=$5, push_enabled=true, push_token_at=now(), push_failures=0`,
userID, profileID, deviceID, token, transport)
if err != nil {
return fmt.Errorf("register token: %w", err)
}
return nil
}
func (s *Store) RevokeToken(ctx context.Context, userID int, profileID, deviceID string) error {
_, err := s.pool.Exec(ctx, `
UPDATE user_devices SET push_token=NULL, push_enabled=false
WHERE user_id=$1 AND profile_id=$2 AND device_id=$3`, userID, profileID, deviceID)
return err
}
func (s *Store) SetDeviceEnabled(ctx context.Context, userID int, deviceID string, enabled bool) error {
tag, err := s.pool.Exec(ctx, `UPDATE user_devices SET push_enabled=$3 WHERE user_id=$1 AND device_id=$2`, userID, deviceID, enabled)
if err != nil {
return fmt.Errorf("set device enabled: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
type DeviceInfo struct {
DeviceID string `json:"device_id"`
Name string `json:"name"`
Platform string `json:"platform"`
Transport string `json:"transport"`
PushEnabled bool `json:"push_enabled"`
RegisteredAt *time.Time `json:"registered_at,omitempty"`
}
func (s *Store) ListDevices(ctx context.Context, userID int) ([]DeviceInfo, error) {
rows, err := s.pool.Query(ctx, `
SELECT device_id,
(array_agg(device_name ORDER BY push_token_at DESC NULLS LAST))[1],
(array_agg(device_platform ORDER BY push_token_at DESC NULLS LAST))[1],
(array_agg(COALESCE(push_transport,'') ORDER BY push_token_at DESC NULLS LAST))[1],
bool_or(push_enabled),
max(push_token_at)
FROM user_devices
WHERE user_id=$1 AND push_token IS NOT NULL
GROUP BY device_id
ORDER BY max(last_seen_at) DESC`, userID)
if err != nil {
return nil, fmt.Errorf("list devices: %w", err)
}
defer rows.Close()
var out []DeviceInfo
for rows.Next() {
var d DeviceInfo
if err := rows.Scan(&d.DeviceID, &d.Name, &d.Platform, &d.Transport, &d.PushEnabled, &d.RegisteredAt); err != nil {
return nil, fmt.Errorf("scan device info: %w", err)
}
out = append(out, d)
}
return out, rows.Err()
}
// PurgeTerminal deletes terminal deliveries older than before.
func (s *Store) PurgeTerminal(ctx context.Context, before time.Time) (int64, error) {
tag, err := s.pool.Exec(ctx, `
DELETE FROM push_deliveries
WHERE status IN ('sent','skipped','dead') AND updated_at < $1`, before)
if err != nil {
return 0, fmt.Errorf("purge terminal: %w", err)
}
return tag.RowsAffected(), nil
}
+86
View File
@@ -0,0 +1,86 @@
package push
import (
"context"
"strconv"
"time"
"github.com/sideshow/apns2"
"github.com/sideshow/apns2/payload"
"github.com/sideshow/apns2/token"
)
// APNsTransport implements Transport for Apple Push Notification service using
// token-based (p8) authentication. A new token client is built per Send call,
// which is acceptable for v1 notification volumes.
//
// TODO(v2): add Development vs Production toggle to APNsConfig; v1 always uses Production.
type APNsTransport struct {
cfg func(ctx context.Context) APNsConfig
}
// NewAPNsTransport creates an APNsTransport. cfg is called on each Send so
// credentials can be rotated without restarting.
func NewAPNsTransport(cfg func(ctx context.Context) APNsConfig) *APNsTransport {
return &APNsTransport{cfg: cfg}
}
func (t *APNsTransport) Name() string { return TransportAPNs }
func (t *APNsTransport) Configured() bool { return t.cfg(context.Background()).Configured() }
func (t *APNsTransport) Send(ctx context.Context, deviceToken string, p Payload) (SendResult, time.Duration, error) {
cfg := t.cfg(ctx)
if !cfg.Configured() {
return ResultSoftFail, 0, nil
}
authKey, err := token.AuthKeyFromBytes([]byte(cfg.P8Key))
if err != nil {
return ResultSoftFail, 0, err
}
client := apns2.NewTokenClient(&token.Token{
AuthKey: authKey,
KeyID: cfg.KeyID,
TeamID: cfg.TeamID,
}).Production()
notif := &apns2.Notification{
DeviceToken: deviceToken,
Topic: cfg.BundleID,
CollapseID: strconv.FormatInt(p.NotificationID, 10),
Payload: payload.NewPayload().
AlertTitle(p.Title).
AlertBody(p.Body).
Custom("link", p.Link).
Custom("category", p.Category).
Custom("notification_id", p.NotificationID),
}
resp, err := client.PushWithContext(ctx, notif)
if err != nil {
return ResultSoftFail, 0, err // network/timeout
}
res, retryAfter := apnsResult(resp.Reason, resp.StatusCode)
return res, retryAfter, nil
}
// apnsResult maps an APNs response reason string and HTTP status code to a
// SendResult and optional retry-after duration. This is a pure function so it
// can be unit-tested without network access.
func apnsResult(reason string, status int) (SendResult, time.Duration) {
switch reason {
case "":
if status == 200 {
return ResultSent, 0
}
return ResultSoftFail, 0
case apns2.ReasonBadDeviceToken, apns2.ReasonUnregistered, apns2.ReasonDeviceTokenNotForTopic:
return ResultDead, 0
case apns2.ReasonTooManyRequests:
return ResultSoftFail, 30 * time.Second
default:
return ResultSoftFail, 0
}
}
+44
View File
@@ -0,0 +1,44 @@
package push
import (
"context"
"testing"
"github.com/sideshow/apns2"
)
func TestAPNsResultMapping(t *testing.T) {
cases := []struct {
reason string
status int
want SendResult
}{
{"", 200, ResultSent},
{apns2.ReasonUnregistered, 410, ResultDead},
{apns2.ReasonBadDeviceToken, 400, ResultDead},
{apns2.ReasonDeviceTokenNotForTopic, 400, ResultDead},
{apns2.ReasonTooManyRequests, 429, ResultSoftFail},
{"", 503, ResultSoftFail},
{"PayloadTooLarge", 413, ResultSoftFail},
}
for _, c := range cases {
got, _ := apnsResult(c.reason, c.status)
if got != c.want {
t.Fatalf("reason %q status %d: got %v want %v", c.reason, c.status, got, c.want)
}
}
if _, ra := apnsResult(apns2.ReasonTooManyRequests, 429); ra == 0 {
t.Fatal("429 should carry a retry-after")
}
}
func TestAPNsTransport_UnconfiguredSoftFails(t *testing.T) {
tr := NewAPNsTransport(func(context.Context) APNsConfig { return APNsConfig{} })
res, _, _ := tr.Send(context.Background(), "tok", Payload{})
if res != ResultSoftFail {
t.Fatalf("unconfigured → soft fail, got %v", res)
}
if tr.Name() != "apns" {
t.Fatal("name")
}
}
+190
View File
@@ -0,0 +1,190 @@
package push
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"time"
"golang.org/x/oauth2/google"
)
// fcmScope is the OAuth2 scope required to send FCM HTTP v1 messages.
const fcmScope = "https://www.googleapis.com/auth/firebase.messaging"
// FCM HTTP v1 FcmError error codes used for delivery-result classification.
const (
fcmErrUnregistered = "UNREGISTERED"
fcmErrInvalidArgument = "INVALID_ARGUMENT"
fcmErrQuotaExceeded = "QUOTA_EXCEEDED"
fcmErrUnavailable = "UNAVAILABLE"
)
// FCMTransport implements Transport for Firebase Cloud Messaging via the FCM
// HTTP v1 REST API. Credentials (a service-account JSON) are read on each Send
// so they can be rotated without restarting.
//
// TODO(v2): cache the parsed credentials / oauth2 TokenSource keyed on the JSON
// hash to avoid re-parsing per call at higher notification volumes (the
// TokenSource itself already caches and refreshes the access token).
type FCMTransport struct {
cfg func(ctx context.Context) FCMConfig
client *http.Client
}
// NewFCMTransport creates an FCMTransport. cfg is called on each Send so
// credentials can be rotated without restarting.
func NewFCMTransport(cfg func(ctx context.Context) FCMConfig) *FCMTransport {
return &FCMTransport{cfg: cfg, client: &http.Client{Timeout: 10 * time.Second}}
}
func (t *FCMTransport) Name() string { return TransportFCM }
func (t *FCMTransport) Configured() bool { return t.cfg(context.Background()).Configured() }
func (t *FCMTransport) Send(ctx context.Context, deviceToken string, p Payload) (SendResult, time.Duration, error) {
cfg := t.cfg(ctx)
if !cfg.Configured() {
return ResultSoftFail, 0, nil
}
creds, err := google.CredentialsFromJSONWithType(ctx, []byte(cfg.ServiceAccountJSON), google.ServiceAccount, fcmScope)
if err != nil {
return ResultSoftFail, 0, err
}
// The project_id is a standard field of the service-account JSON and is
// required to build the v1 endpoint.
var sa struct {
ProjectID string `json:"project_id"`
}
if err := json.Unmarshal([]byte(cfg.ServiceAccountJSON), &sa); err != nil {
return ResultSoftFail, 0, err
}
if sa.ProjectID == "" {
return ResultSoftFail, 0, fmt.Errorf("fcm: service-account JSON missing project_id")
}
tok, err := creds.TokenSource.Token()
if err != nil {
return ResultSoftFail, 0, err
}
collapse := strconv.FormatInt(p.NotificationID, 10)
body, err := json.Marshal(fcmMessage{
Message: fcmInnerMessage{
Token: deviceToken,
Notification: fcmNotification{Title: p.Title, Body: p.Body},
Data: map[string]string{
"notification_id": collapse,
"link": p.Link,
"category": p.Category,
},
Android: fcmAndroidConfig{CollapseKey: collapse},
},
})
if err != nil {
return ResultSoftFail, 0, err
}
url := fmt.Sprintf("https://fcm.googleapis.com/v1/projects/%s/messages:send", sa.ProjectID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return ResultSoftFail, 0, err
}
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := t.client.Do(req)
if err != nil {
return ResultSoftFail, 0, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
_, _ = io.Copy(io.Discard, resp.Body)
res, retryAfter := fcmResult(resp.StatusCode, "")
return res, retryAfter, nil
}
// Non-2xx: parse the v1 error body for the FCM error code. Any read/parse
// failure leaves errorCode empty, which classification handles by HTTP status.
respBody, _ := io.ReadAll(resp.Body)
errorCode := parseFCMErrorCode(respBody)
res, retryAfter := fcmResult(resp.StatusCode, errorCode)
return res, retryAfter, fmt.Errorf("fcm: http %d code=%q body=%s", resp.StatusCode, errorCode, respBody)
}
// FCM HTTP v1 request body types.
type fcmMessage struct {
Message fcmInnerMessage `json:"message"`
}
type fcmInnerMessage struct {
Token string `json:"token"`
Notification fcmNotification `json:"notification"`
Data map[string]string `json:"data"`
Android fcmAndroidConfig `json:"android"`
}
type fcmNotification struct {
Title string `json:"title"`
Body string `json:"body"`
}
type fcmAndroidConfig struct {
CollapseKey string `json:"collapse_key"`
}
// parseFCMErrorCode extracts the FcmError errorCode (e.g. "UNREGISTERED") from a
// v1 error response body. Returns "" if absent or unparseable.
//
// Shape: {"error":{"status":"...","details":[
//
// {"@type":".../google.firebase.fcm.v1.FcmError","errorCode":"UNREGISTERED"}]}}
func parseFCMErrorCode(body []byte) string {
var parsed struct {
Error struct {
Details []struct {
Type string `json:"@type"`
ErrorCode string `json:"errorCode"`
} `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return ""
}
for _, d := range parsed.Error.Details {
if d.ErrorCode != "" {
return d.ErrorCode
}
}
return ""
}
// fcmResult maps an FCM HTTP v1 response (status code + parsed FcmError
// errorCode) to a SendResult and optional retry-after duration. This is a pure
// function so it can be unit-tested without network access.
//
// Dead-token classification uses UNREGISTERED (stale/deleted token) and
// INVALID_ARGUMENT (malformed token), or HTTP 404. Rate-limit/service errors
// (QUOTA_EXCEEDED / UNAVAILABLE, or HTTP 429/503) are soft failures with a 30 s
// back-off. All other non-2xx responses are retryable with no delay.
func fcmResult(status int, errorCode string) (SendResult, time.Duration) {
if status >= 200 && status < 300 {
return ResultSent, 0
}
switch errorCode {
case fcmErrUnregistered, fcmErrInvalidArgument:
return ResultDead, 0
case fcmErrQuotaExceeded, fcmErrUnavailable:
return ResultSoftFail, 30 * time.Second
}
switch status {
case http.StatusNotFound:
return ResultDead, 0
case http.StatusTooManyRequests, http.StatusServiceUnavailable:
return ResultSoftFail, 30 * time.Second
}
return ResultSoftFail, 0
}
+76
View File
@@ -0,0 +1,76 @@
package push
import (
"context"
"testing"
"time"
)
// TestFCMResult exercises the pure HTTP-status + FcmError-code classification
// helper. No network access is required.
func TestFCMResult(t *testing.T) {
tests := []struct {
name string
status int
errorCode string
wantRes SendResult
wantRetry time.Duration
}{
{"success", 200, "", ResultSent, 0},
{"unregistered", 404, fcmErrUnregistered, ResultDead, 0},
{"invalid_argument", 400, fcmErrInvalidArgument, ResultDead, 0},
{"quota_exceeded", 429, fcmErrQuotaExceeded, ResultSoftFail, 30 * time.Second},
{"unavailable", 503, fcmErrUnavailable, ResultSoftFail, 30 * time.Second},
{"http_404_no_code", 404, "", ResultDead, 0},
{"http_429_no_code", 429, "", ResultSoftFail, 30 * time.Second},
{"http_503_no_code", 503, "", ResultSoftFail, 30 * time.Second},
{"unknown", 500, "INTERNAL", ResultSoftFail, 0},
{"unknown_no_code", 400, "", ResultSoftFail, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res, retry := fcmResult(tt.status, tt.errorCode)
if res != tt.wantRes {
t.Fatalf("res: got %v want %v", res, tt.wantRes)
}
if retry != tt.wantRetry {
t.Fatalf("retry: got %v want %v", retry, tt.wantRetry)
}
})
}
}
// TestParseFCMErrorCode verifies extraction of the FcmError errorCode from a v1
// error response body, and graceful handling of missing/garbage bodies.
func TestParseFCMErrorCode(t *testing.T) {
body := []byte(`{"error":{"status":"NOT_FOUND","details":[` +
`{"@type":"type.googleapis.com/google.firebase.fcm.v1.FcmError","errorCode":"UNREGISTERED"}]}}`)
if got := parseFCMErrorCode(body); got != "UNREGISTERED" {
t.Fatalf("got %q want UNREGISTERED", got)
}
if got := parseFCMErrorCode([]byte("not json")); got != "" {
t.Fatalf("garbage body: got %q want empty", got)
}
if got := parseFCMErrorCode([]byte(`{"error":{"status":"X"}}`)); got != "" {
t.Fatalf("no details: got %q want empty", got)
}
}
// TestFCMTransport_UnconfiguredSoftFails verifies that Send returns ResultSoftFail
// when no service-account JSON is configured, and that Name() returns "fcm".
//
// Live-network status/error-code paths are exercised by fcmResult /
// parseFCMErrorCode above and by the worker's dead-token integration test.
func TestFCMTransport_UnconfiguredSoftFails(t *testing.T) {
tr := NewFCMTransport(func(context.Context) FCMConfig { return FCMConfig{} })
res, _, err := tr.Send(context.Background(), "tok", Payload{})
if res != ResultSoftFail {
t.Fatalf("unconfigured → soft fail, got %v", res)
}
if err != nil {
t.Fatalf("unconfigured → nil err, got %v", err)
}
if tr.Name() != "fcm" {
t.Fatal("name")
}
}
+100
View File
@@ -0,0 +1,100 @@
package push
import (
"context"
"encoding/json"
"net/http"
"strconv"
"time"
webpush "github.com/SherClockHolmes/webpush-go"
)
// WebPushTransport implements Transport for the Web Push protocol (RFC 8291).
type WebPushTransport struct {
cfg func(ctx context.Context) WebPushConfig
client *http.Client
}
// NewWebPushTransport creates a WebPushTransport. cfg is called on each Send
// so credentials can be rotated without restarting.
func NewWebPushTransport(cfg func(ctx context.Context) WebPushConfig) *WebPushTransport {
return &WebPushTransport{cfg: cfg, client: &http.Client{Timeout: 10 * time.Second}}
}
func (t *WebPushTransport) Name() string { return TransportWebPush }
func (t *WebPushTransport) Configured() bool {
return t.cfg(context.Background()).Configured()
}
func (t *WebPushTransport) Send(ctx context.Context, token string, payload Payload) (SendResult, time.Duration, error) {
cfg := t.cfg(ctx)
if !cfg.Configured() {
return ResultSoftFail, 0, nil
}
var sub webpush.Subscription
if err := json.Unmarshal([]byte(token), &sub); err != nil {
// Malformed subscription JSON is a permanent failure; prune the device.
return ResultDead, 0, err
}
body, _ := json.Marshal(map[string]any{
"id": payload.NotificationID,
"title": payload.Title,
"body": payload.Body,
"link": payload.Link,
"category": payload.Category,
})
resp, err := webpush.SendNotificationWithContext(ctx, body, &sub, &webpush.Options{
Subscriber: cfg.Subject,
VAPIDPublicKey: cfg.VAPIDPublic,
VAPIDPrivateKey: cfg.VAPIDPrivate,
TTL: 86400,
Topic: webpushTopic(payload.NotificationID), // dedupe: same notification replaces pending
HTTPClient: t.client,
})
if err != nil {
return ResultSoftFail, 0, err
}
defer func() { _ = resp.Body.Close() }()
switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300:
return ResultSent, 0, nil
case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone:
// Subscription is expired or invalid; prune the device.
return ResultDead, 0, nil
case resp.StatusCode == http.StatusTooManyRequests:
return ResultSoftFail, parseRetryAfter(resp.Header.Get("Retry-After")), nil
default:
return ResultSoftFail, 0, nil
}
}
// parseRetryAfter parses the Retry-After header value, supporting both the
// delta-seconds integer form and the HTTP-date form. Returns 0 for empty,
// unparseable, or past-dated values.
func parseRetryAfter(h string) time.Duration {
if h == "" {
return 0
}
if secs, err := strconv.Atoi(h); err == nil {
return time.Duration(secs) * time.Second
}
if t, err := http.ParseTime(h); err == nil {
if d := time.Until(t); d > 0 {
return d
}
}
return 0
}
// webpushTopic derives a short (<=32 char) Web Push Topic header value from
// the notification ID so retries replace, rather than stack, pending pushes.
// Uses base-36 encoding for compactness; the result is URL-safe ASCII.
func webpushTopic(notificationID int64) string {
return "n" + strconv.FormatInt(notificationID, 36)
}
+47
View File
@@ -0,0 +1,47 @@
package push
import (
"context"
"testing"
"time"
)
func TestParseRetryAfter(t *testing.T) {
if parseRetryAfter("120") != 2*time.Minute {
t.Fatal("seconds form")
}
if parseRetryAfter("") != 0 || parseRetryAfter("garbage") != 0 {
t.Fatal("empty/garbage → 0")
}
}
func TestWebPushTopic_ShortAndStable(t *testing.T) {
tp := webpushTopic(123456789)
if len(tp) > 32 || tp == "" {
t.Fatalf("topic %q invalid length", tp)
}
if tp != webpushTopic(123456789) {
t.Fatal("topic must be stable for the same id")
}
}
func TestWebPush_MalformedSubscriptionIsDead(t *testing.T) {
tr := NewWebPushTransport(func(context.Context) WebPushConfig {
return WebPushConfig{VAPIDPublic: "p", VAPIDPrivate: "k", Subject: "mailto:a@b.c"}
})
res, _, _ := tr.Send(context.Background(), "{not json", Payload{})
if res != ResultDead {
t.Fatalf("malformed sub → dead, got %v", res)
}
}
func TestWebPush_UnconfiguredSoftFails(t *testing.T) {
tr := NewWebPushTransport(func(context.Context) WebPushConfig { return WebPushConfig{} })
res, _, _ := tr.Send(context.Background(), `{"endpoint":"https://x","keys":{"p256dh":"a","auth":"b"}}`, Payload{})
if res != ResultSoftFail {
t.Fatalf("unconfigured → soft fail, got %v", res)
}
if tr.Name() != TransportWebPush {
t.Fatalf("expected name %q, got %q", TransportWebPush, tr.Name())
}
}
+79
View File
@@ -0,0 +1,79 @@
package push
import (
"context"
"time"
)
// Transport identifiers (also stored in user_devices.push_transport and
// push_deliveries.transport).
const (
TransportAPNs = "apns"
TransportFCM = "fcm"
TransportWebPush = "webpush"
)
// Delivery statuses.
const (
StatusPending = "pending"
StatusSent = "sent"
StatusFailed = "failed"
StatusSkipped = "skipped"
StatusDead = "dead"
)
// Device is a push-eligible device row.
type Device struct {
UserID int
ProfileID string
DeviceID string
Transport string
Token string // bare token (apns/fcm) or JSON subscription (webpush)
}
// Delivery is one queued push for one device.
type Delivery struct {
ID int64
NotificationID int64
UserID int
ProfileID string
DeviceID string
Transport string
Status string
Attempts int
NotBefore time.Time
}
// Payload is the content sent to a device. Built from a notification row at
// send time so edits/expiry are reflected.
type Payload struct {
NotificationID int64
Title string
Body string
Link string
Category string
}
// SendResult classifies a transport attempt.
type SendResult int
const (
// ResultSent: delivered (or accepted by the provider).
ResultSent SendResult = iota
// ResultSoftFail: retryable (timeout, 5xx, rate-limit).
ResultSoftFail
// ResultDead: token is permanently invalid; prune it.
ResultDead
)
// Transport delivers a payload to one device token. Implementations are
// selected by Device.Transport.
type Transport interface {
// Name returns the transport id (TransportAPNs/FCM/WebPush).
Name() string
// Configured reports whether provider credentials are present.
Configured() bool
// Send delivers payload to token. err is for logging only; the SendResult
// drives queue state. retryAfter (>0) parks the transport when rate-limited.
Send(ctx context.Context, token string, payload Payload) (res SendResult, retryAfter time.Duration, err error)
}
+270
View File
@@ -0,0 +1,270 @@
package push
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/taskmanager"
)
// presenceChecker reports live-connection presence (internal/presence.Registry).
type presenceChecker interface {
Connected(ctx context.Context, userID int) bool
}
// queue is the worker's view of delivery persistence. One Claim call returns a
// batch and an outcome recorder; the prod impl wraps a single tx.
type queue interface {
Claim(ctx context.Context, now time.Time, limit int) ([]claimedDelivery, Outcomes, error)
}
// Outcomes records per-delivery results and commits/rolls back the batch.
type Outcomes interface {
Sent(id int64)
Skipped(id int64, reason string)
Failed(id int64, nextNotBefore time.Time, errMsg string)
Dead(id int64, userID int, deviceID, errMsg string)
Commit(ctx context.Context) error
Rollback(ctx context.Context)
}
// backoffSchedule by attempts already made (0-indexed): 1m, 5m, 30m, then dead.
var backoffSchedule = []time.Duration{time.Minute, 5 * time.Minute, 30 * time.Minute}
// Worker drains due push deliveries in batches.
type Worker struct {
q queue
presence presenceChecker
transports map[string]Transport
batch int
now func() time.Time
parkedUntil map[string]time.Time
}
// NewWorker constructs a Worker. now may be nil (defaults to time.Now).
func NewWorker(q queue, presence presenceChecker, transports []Transport, now func() time.Time) *Worker {
if now == nil {
now = time.Now
}
m := make(map[string]Transport, len(transports))
for _, t := range transports {
m[t.Name()] = t
}
return &Worker{
q: q,
presence: presence,
transports: m,
batch: 100,
now: now,
parkedUntil: map[string]time.Time{},
}
}
// RunOnce processes one batch. Returns the number of deliveries handled.
func (w *Worker) RunOnce(ctx context.Context) (int, error) {
now := w.now()
items, out, err := w.q.Claim(ctx, now, w.batch)
if err != nil {
return 0, err
}
defer out.Rollback(ctx) // no-op after Commit
for _, it := range items {
w.handle(ctx, now, it, out)
}
if err := out.Commit(ctx); err != nil {
return 0, err
}
return len(items), nil
}
func (w *Worker) handle(ctx context.Context, now time.Time, it claimedDelivery, out Outcomes) {
// Presence gate: user actively connected → skip (in-app sufficed).
if w.presence != nil && w.presence.Connected(ctx, it.UserID) {
out.Skipped(it.ID, "user present")
return
}
t := w.transports[it.Transport]
if t == nil || !t.Configured() {
out.Skipped(it.ID, "transport unconfigured")
return
}
if until, ok := w.parkedUntil[it.Transport]; ok && now.Before(until) {
out.Failed(it.ID, until, "transport rate-limited")
return
}
// An empty token is a data integrity problem, not a dead subscription.
// Skip it so we do NOT feed it to the transport (which would ResultDead →
// PruneTokenTx and silently wipe the real token on the device row).
if it.Token == "" {
out.Skipped(it.ID, "empty token")
return
}
res, retryAfter, sendErr := t.Send(ctx, it.Token, it.Payload)
switch res {
case ResultSent:
out.Sent(it.ID)
case ResultDead:
out.Dead(it.ID, it.UserID, it.DeviceID, errString(sendErr))
case ResultSoftFail:
if retryAfter > 0 {
w.parkedUntil[it.Transport] = now.Add(retryAfter)
}
next, dead := nextBackoff(it.Attempts, now, retryAfter)
if dead {
out.Dead(it.ID, it.UserID, it.DeviceID, "max attempts: "+errString(sendErr))
} else {
out.Failed(it.ID, next, errString(sendErr))
}
}
if sendErr != nil {
slog.WarnContext(ctx, "push: send result",
"delivery_id", it.ID,
"transport", it.Transport,
"result", res,
"error", sendErr,
)
}
}
func nextBackoff(attempts int, now time.Time, retryAfter time.Duration) (time.Time, bool) {
if attempts >= len(backoffSchedule) {
return time.Time{}, true // exhausted → dead
}
d := backoffSchedule[attempts]
if retryAfter > d {
d = retryAfter
}
return now.Add(d), false
}
func errString(err error) string {
if err == nil {
return ""
}
return err.Error()
}
// --- store-backed queue adapter ---
type storeQueue struct{ s *Store }
// NewStoreQueue wraps *Store to satisfy the queue interface.
func NewStoreQueue(s *Store) queue { return &storeQueue{s: s} }
func (q *storeQueue) Claim(ctx context.Context, now time.Time, limit int) ([]claimedDelivery, Outcomes, error) {
conn, tx, items, err := q.s.ClaimDue(ctx, now, limit)
if err != nil {
return nil, nil, err
}
return items, &txOutcomes{ctx: ctx, s: q.s, tx: tx, conn: conn}, nil
}
type txOutcomes struct {
ctx context.Context
s *Store
tx pgx.Tx
conn *pgxpool.Conn
done bool
err error
}
func (o *txOutcomes) Sent(id int64) {
o.run(func() error { return o.s.MarkSentTx(o.ctx, o.tx, id) })
}
func (o *txOutcomes) Skipped(id int64, r string) {
o.run(func() error { return o.s.MarkSkippedTx(o.ctx, o.tx, id, r) })
}
func (o *txOutcomes) Failed(id int64, n time.Time, m string) {
o.run(func() error { return o.s.MarkFailedTx(o.ctx, o.tx, id, n, m) })
}
func (o *txOutcomes) Dead(id int64, userID int, deviceID, m string) {
o.run(func() error {
if err := o.s.MarkDeadTx(o.ctx, o.tx, id, m); err != nil {
return err
}
return o.s.PruneTokenTx(o.ctx, o.tx, userID, deviceID)
})
}
// run executes one outcome op, short-circuiting once any op has errored so the
// batch commits atomically. NOTE: if a DB op fails mid-batch, the whole tx rolls
// back and every delivery in the batch is re-claimed next tick — including any
// that already hit the transport, which re-sends them. Duplicate banners are
// mitigated by the per-notification collapse/thread key the transports set.
// If duplicates prove unacceptable, switch to per-delivery commits.
func (o *txOutcomes) run(fn func() error) {
if o.err != nil {
return
}
o.err = fn()
}
func (o *txOutcomes) Commit(ctx context.Context) error {
if o.done {
return nil
}
o.done = true
defer o.conn.Release()
if o.err != nil {
_ = o.tx.Rollback(ctx)
return o.err
}
return o.tx.Commit(ctx)
}
func (o *txOutcomes) Rollback(ctx context.Context) {
if o.done {
return
}
o.done = true
defer o.conn.Release()
_ = o.tx.Rollback(ctx)
}
// --- scheduled task ---
// PushDeliveryTask drains due push deliveries on a short interval.
type PushDeliveryTask struct{ w *Worker }
// NewPushDeliveryTask wraps Worker as a taskmanager.Task.
func NewPushDeliveryTask(w *Worker) *PushDeliveryTask { return &PushDeliveryTask{w: w} }
func (t *PushDeliveryTask) Key() string { return "push_delivery" }
func (t *PushDeliveryTask) Name() string { return "Deliver push notifications" }
func (t *PushDeliveryTask) Description() string {
return "Sends queued push notifications to registered devices"
}
func (t *PushDeliveryTask) Category() taskmanager.TaskCategory {
return taskmanager.TaskCategorySystem
}
func (t *PushDeliveryTask) IsHidden() bool { return false }
func (t *PushDeliveryTask) DefaultTriggers() []taskmanager.TriggerConfig {
return []taskmanager.TriggerConfig{
{Type: taskmanager.TriggerTypeInterval, IntervalMs: 15 * 1000},
}
}
func (t *PushDeliveryTask) Execute(ctx context.Context, progress taskmanager.ProgressReporter) error {
if t.w == nil {
progress.Report(100, "push delivery unavailable")
return nil
}
// Drain until empty or a cap, so a burst clears within one tick.
total := 0
for i := 0; i < 20; i++ {
n, err := t.w.RunOnce(ctx)
if err != nil {
return fmt.Errorf("push delivery: %w", err)
}
total += n
if n == 0 {
break
}
}
progress.Report(100, fmt.Sprintf("processed %d deliveries", total))
return nil
}
+282
View File
@@ -0,0 +1,282 @@
package push
import (
"context"
"errors"
"testing"
"time"
)
type recordedOutcome struct {
kind string // sent|skipped|failed|dead
id int64
next time.Time
msg string
}
type fakeOutcomes struct {
rec []recordedOutcome
committed bool
}
func (f *fakeOutcomes) Sent(id int64) {
f.rec = append(f.rec, recordedOutcome{StatusSent, id, time.Time{}, ""})
}
func (f *fakeOutcomes) Skipped(id int64, r string) {
f.rec = append(f.rec, recordedOutcome{StatusSkipped, id, time.Time{}, r})
}
func (f *fakeOutcomes) Failed(id int64, n time.Time, m string) {
f.rec = append(f.rec, recordedOutcome{StatusFailed, id, n, m})
}
func (f *fakeOutcomes) Dead(id int64, _ int, _, m string) {
f.rec = append(f.rec, recordedOutcome{StatusDead, id, time.Time{}, m})
}
func (f *fakeOutcomes) Commit(context.Context) error { f.committed = true; return nil }
func (f *fakeOutcomes) Rollback(context.Context) {}
type fakeQueue struct {
items []claimedDelivery
out *fakeOutcomes
}
func (q *fakeQueue) Claim(context.Context, time.Time, int) ([]claimedDelivery, Outcomes, error) {
return q.items, q.out, nil
}
type fakeTransport struct {
name string
configured bool
res SendResult
retryAfter time.Duration
err error
calls int
}
func (t *fakeTransport) Name() string { return t.name }
func (t *fakeTransport) Configured() bool { return t.configured }
func (t *fakeTransport) Send(context.Context, string, Payload) (SendResult, time.Duration, error) {
t.calls++
return t.res, t.retryAfter, t.err
}
type fakePresence struct{ connected map[int]bool }
func (p fakePresence) Connected(_ context.Context, u int) bool { return p.connected[u] }
func deliveries(d ...claimedDelivery) []claimedDelivery { return d }
func cd(id int64, userID, attempts int, transport string) claimedDelivery {
return claimedDelivery{
Delivery: Delivery{ID: id, UserID: userID, Attempts: attempts, Transport: transport},
Token: "tok",
}
}
func TestWorker_SentOnSuccess(t *testing.T) {
out := &fakeOutcomes{}
q := &fakeQueue{items: deliveries(cd(1, 7, 0, TransportAPNs)), out: out}
w := NewWorker(q, fakePresence{}, []Transport{
&fakeTransport{name: TransportAPNs, configured: true, res: ResultSent},
}, func() time.Time { return time.Unix(100, 0) })
if _, err := w.RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if len(out.rec) != 1 || out.rec[0].kind != StatusSent || !out.committed {
t.Fatalf("got %+v committed=%v", out.rec, out.committed)
}
}
func TestWorker_SkipsPresentUser(t *testing.T) {
tr := &fakeTransport{name: TransportAPNs, configured: true, res: ResultSent}
out := &fakeOutcomes{}
q := &fakeQueue{items: deliveries(cd(1, 7, 0, TransportAPNs)), out: out}
w := NewWorker(q, fakePresence{connected: map[int]bool{7: true}}, []Transport{tr}, nil)
if _, err := w.RunOnce(context.Background()); err != nil {
t.Fatalf("RunOnce: %v", err)
}
if out.rec[0].kind != StatusSkipped || tr.calls != 0 {
t.Fatalf("present user must skip without send; got %+v calls=%d", out.rec, tr.calls)
}
}
func TestWorker_SkipsUnconfiguredTransport(t *testing.T) {
out := &fakeOutcomes{}
q := &fakeQueue{items: deliveries(cd(1, 7, 0, TransportFCM)), out: out}
w := NewWorker(q, fakePresence{}, []Transport{
&fakeTransport{name: TransportFCM, configured: false},
}, nil)
if _, err := w.RunOnce(context.Background()); err != nil {
t.Fatalf("RunOnce: %v", err)
}
if out.rec[0].kind != StatusSkipped {
t.Fatalf("unconfigured → skipped; got %+v", out.rec)
}
}
func TestWorker_SoftFailBackoffThenDead(t *testing.T) {
now := time.Unix(1000, 0)
// attempts=0 → failed at +1m
out := &fakeOutcomes{}
q := &fakeQueue{items: deliveries(cd(1, 7, 0, TransportAPNs)), out: out}
w := NewWorker(q, fakePresence{}, []Transport{
&fakeTransport{name: TransportAPNs, configured: true, res: ResultSoftFail, err: errors.New("503")},
}, func() time.Time { return now })
if _, err := w.RunOnce(context.Background()); err != nil {
t.Fatalf("RunOnce: %v", err)
}
if out.rec[0].kind != StatusFailed || !out.rec[0].next.Equal(now.Add(time.Minute)) {
t.Fatalf("attempt0 → failed +1m; got %+v", out.rec[0])
}
// attempts=3 (exhausted) → dead
out2 := &fakeOutcomes{}
q2 := &fakeQueue{items: deliveries(cd(2, 7, 3, TransportAPNs)), out: out2}
w2 := NewWorker(q2, fakePresence{}, []Transport{
&fakeTransport{name: TransportAPNs, configured: true, res: ResultSoftFail, err: errors.New("503")},
}, func() time.Time { return now })
if _, err := w2.RunOnce(context.Background()); err != nil {
t.Fatalf("RunOnce: %v", err)
}
if out2.rec[0].kind != StatusDead {
t.Fatalf("exhausted → dead; got %+v", out2.rec[0])
}
}
func TestWorker_DeadTokenOnHardFail(t *testing.T) {
out := &fakeOutcomes{}
q := &fakeQueue{items: deliveries(cd(1, 7, 0, TransportAPNs)), out: out}
w := NewWorker(q, fakePresence{}, []Transport{
&fakeTransport{name: TransportAPNs, configured: true, res: ResultDead, err: errors.New("Unregistered")},
}, nil)
if _, err := w.RunOnce(context.Background()); err != nil {
t.Fatalf("RunOnce: %v", err)
}
if out.rec[0].kind != StatusDead {
t.Fatalf("hard fail → dead; got %+v", out.rec)
}
}
// TestWorker_SkipsUnconfiguredTransport_NoCalls asserts that a delivery whose
// transport is registered but not configured is skipped and Send is never called.
func TestWorker_SkipsUnconfiguredTransport_NoCalls(t *testing.T) {
tr := &fakeTransport{name: TransportFCM, configured: false}
out := &fakeOutcomes{}
q := &fakeQueue{items: deliveries(cd(1, 7, 0, TransportFCM)), out: out}
w := NewWorker(q, fakePresence{}, []Transport{tr}, nil)
if _, err := w.RunOnce(context.Background()); err != nil {
t.Fatalf("RunOnce: %v", err)
}
if out.rec[0].kind != StatusSkipped {
t.Fatalf("unconfigured → skipped; got %+v", out.rec)
}
if tr.calls != 0 {
t.Fatalf("Send must not be called for unconfigured transport; calls=%d", tr.calls)
}
}
// TestWorker_ParkedTransportFails verifies that after a SoftFail with
// retryAfter=2m the transport is parked, and a second delivery in the SAME
// RunOnce is failed via the parked branch without a second Send call.
func TestWorker_ParkedTransportFails(t *testing.T) {
now := time.Unix(1000, 0)
retryAfter := 2 * time.Minute
tr := &fakeTransport{
name: TransportAPNs,
configured: true,
res: ResultSoftFail,
retryAfter: retryAfter,
err: errors.New("rate-limited"),
}
out := &fakeOutcomes{}
q := &fakeQueue{
items: deliveries(
cd(1, 7, 0, TransportAPNs),
cd(2, 8, 0, TransportAPNs),
),
out: out,
}
w := NewWorker(q, fakePresence{}, []Transport{tr}, func() time.Time { return now })
if _, err := w.RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
// Only one Send call: the first delivery hits the transport; the second is
// short-circuited by the parked check.
if tr.calls != 1 {
t.Fatalf("expected exactly 1 Send call; got %d", tr.calls)
}
if len(out.rec) != 2 {
t.Fatalf("expected 2 outcomes; got %d", len(out.rec))
}
wantNext := now.Add(retryAfter) // max(1m, 2m) = 2m for attempts=0
for i, rec := range out.rec {
if rec.kind != StatusFailed {
t.Errorf("outcome[%d]: want failed, got %s", i, rec.kind)
}
if !rec.next.Equal(wantNext) {
t.Errorf("outcome[%d]: want next=%v, got %v", i, wantNext, rec.next)
}
}
}
// TestWorker_EmptyTokenSkippedNotDead asserts that a delivery with an empty
// token is skipped (reason "empty token") without ever calling the transport,
// and is NOT marked dead (which would prune the device-wide token row).
func TestWorker_EmptyTokenSkippedNotDead(t *testing.T) {
tr := &fakeTransport{name: TransportWebPush, configured: true, res: ResultSent}
out := &fakeOutcomes{}
item := claimedDelivery{
Delivery: Delivery{ID: 42, UserID: 7, Attempts: 0, Transport: TransportWebPush},
Token: "", // empty token — the bug scenario
}
q := &fakeQueue{items: deliveries(item), out: out}
w := NewWorker(q, fakePresence{}, []Transport{tr}, nil)
if _, err := w.RunOnce(context.Background()); err != nil {
t.Fatalf("RunOnce: %v", err)
}
if len(out.rec) != 1 {
t.Fatalf("expected 1 outcome; got %d", len(out.rec))
}
rec := out.rec[0]
if rec.kind != StatusSkipped {
t.Errorf("empty token must be skipped, got kind=%s", rec.kind)
}
if rec.msg != "empty token" {
t.Errorf("reason must be %q, got %q", "empty token", rec.msg)
}
if tr.calls != 0 {
t.Errorf("Send must not be called for empty token; calls=%d", tr.calls)
}
}
// TestWorker_IntermediateBackoff checks that attempts=1 produces a 5m backoff
// and attempts=2 produces a 30m backoff (no retryAfter, so schedule wins).
func TestWorker_IntermediateBackoff(t *testing.T) {
now := time.Unix(1000, 0)
cases := []struct {
attempts int
wantAdd time.Duration
}{
{1, 5 * time.Minute},
{2, 30 * time.Minute},
}
for _, tc := range cases {
out := &fakeOutcomes{}
q := &fakeQueue{items: deliveries(cd(1, 7, tc.attempts, TransportAPNs)), out: out}
w := NewWorker(q, fakePresence{}, []Transport{
&fakeTransport{name: TransportAPNs, configured: true, res: ResultSoftFail},
}, func() time.Time { return now })
if _, err := w.RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
wantNext := now.Add(tc.wantAdd)
rec := out.rec[0]
if rec.kind != StatusFailed {
t.Errorf("attempts=%d: want failed, got %s", tc.attempts, rec.kind)
}
if !rec.next.Equal(wantNext) {
t.Errorf("attempts=%d: want next=%v, got %v", tc.attempts, wantNext, rec.next)
}
}
}
+72
View File
@@ -0,0 +1,72 @@
package requests
import (
"context"
"log/slog"
evt "github.com/Silo-Server/silo-server/internal/events"
)
// RequestEventPayload is the data published on ChannelRequests for each
// lifecycle transition of a request.
type RequestEventPayload struct {
RequestID string `json:"request_id"`
UserID int `json:"user_id"`
ProfileID string `json:"profile_id,omitempty"`
Title string `json:"title"`
MediaType MediaType `json:"media_type"`
Quality Quality `json:"quality,omitempty"`
Status Status `json:"status"`
}
// SetEventsHub attaches an event hub so the service publishes lifecycle events.
// Call before the service handles requests; nil hub disables publishing (no-op).
func (s *Service) SetEventsHub(hub *evt.Hub) { s.eventsHub = hub }
// publishApprovalOutcome publishes either "request.approved" or "request.failed"
// based on the aggregate state of req after submitApprovedRequest runs.
// Callers must pass a non-nil req.
func (s *Service) publishApprovalOutcome(ctx context.Context, req *Request) {
if req == nil {
return
}
if req.Outcome == OutcomeFailed {
s.publishRequestEvent(ctx, "request.failed", *req)
} else {
s.publishRequestEvent(ctx, "request.approved", *req)
}
}
// publishRequestEvent publishes a lifecycle event for req onto ChannelRequests.
// It is a no-op when eventsHub is nil, and it never fails the caller — errors
// are logged at Warn level only.
func (s *Service) publishRequestEvent(ctx context.Context, event string, req Request) {
if s.eventsHub == nil {
return
}
payload := RequestEventPayload{
RequestID: req.ID,
UserID: req.RequestedByUserID,
ProfileID: req.RequestedByProfileID,
Title: req.Title,
MediaType: req.MediaType,
Status: req.Status,
}
// Quality lives per-target (a request fans out to one Target per resolved
// quality); surface the first target's quality so the payload field is
// populated rather than always empty.
if len(req.Targets) > 0 {
payload.Quality = req.Targets[0].Quality
}
opts := evt.PublishOptions{
UserID: req.RequestedByUserID,
ProfileID: req.RequestedByProfileID,
}
if err := s.eventsHub.PublishJSON(ctx, evt.ChannelRequests, event, payload, opts); err != nil {
slog.WarnContext(ctx, "requests: failed to publish event",
"event", event,
"request_id", req.ID,
"error", err,
)
}
}
+52 -5
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/Silo-Server/silo-server/internal/access"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/idgen"
"github.com/Silo-Server/silo-server/internal/metadata/tmdb"
"golang.org/x/sync/errgroup"
@@ -47,6 +48,7 @@ type Service struct {
router RequestRouterProvider
entitlements EntitlementResolver
requesterIdentity RequesterIdentityResolver
eventsHub *evt.Hub
Now func() time.Time
}
@@ -455,8 +457,18 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create
}
return nil, err
}
s.publishRequestEvent(ctx, "request.submitted", *req)
if req.Status == StatusApproved {
return s.submitApprovedRequest(ctx, *req, viewer, nil)
result, err := s.submitApprovedRequest(ctx, *req, viewer, nil)
if err != nil {
// Submission hard-failed: still signal the persisted approval so
// consumers aren't left with only request.submitted for an approved
// row. (Soft-fails return no error and are handled below.)
s.publishRequestEvent(ctx, "request.approved", *req)
return nil, err
}
s.publishApprovalOutcome(ctx, result)
return result, nil
}
return req, nil
}
@@ -592,7 +604,15 @@ func (s *Service) Approve(ctx context.Context, viewer Viewer, id string) (*Reque
if err != nil {
return nil, err
}
return s.submitApprovedRequest(ctx, *approved, viewer, nil)
result, err := s.submitApprovedRequest(ctx, *approved, viewer, nil)
if err != nil {
// Submission hard-failed: still signal the persisted approval (soft-fails
// return no error and are handled by publishApprovalOutcome below).
s.publishRequestEvent(ctx, "request.approved", *approved)
return nil, err
}
s.publishApprovalOutcome(ctx, result)
return result, nil
}
func (s *Service) Decline(ctx context.Context, viewer Viewer, id, reason string) (*Request, error) {
@@ -614,7 +634,12 @@ func (s *Service) Decline(ctx context.Context, viewer Viewer, id, reason string)
strings.TrimSpace(req.IntegrationKind) != "" {
return nil, ErrInvalidState
}
return s.store.SetOutcome(ctx, req.ID, OutcomeDeclined, viewer, reason)
declined, err := s.store.SetOutcome(ctx, req.ID, OutcomeDeclined, viewer, reason)
if err != nil {
return nil, err
}
s.publishRequestEvent(ctx, "request.declined", *declined)
return declined, nil
}
// Cancel withdraws a request that has not yet been submitted to a downstream
@@ -647,7 +672,12 @@ func (s *Service) Cancel(ctx context.Context, viewer Viewer, id, reason string)
strings.TrimSpace(req.IntegrationKind) != "" {
return nil, ErrInvalidState
}
return s.store.SetOutcome(ctx, req.ID, OutcomeCancelled, viewer, reason)
cancelled, err := s.store.SetOutcome(ctx, req.ID, OutcomeCancelled, viewer, reason)
if err != nil {
return nil, err
}
s.publishRequestEvent(ctx, "request.cancelled", *cancelled)
return cancelled, nil
}
func (s *Service) Retry(ctx context.Context, viewer Viewer, id string) (*Request, error) {
@@ -1529,6 +1559,9 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request, fc *fulfill
if _, err := s.store.SetStatus(ctx, req.ID, StatusCompleted, Viewer{}); err != nil {
return reconcileUnchanged, err
}
completed := req
completed.Status = StatusCompleted
s.publishRequestEvent(ctx, "request.completed", completed)
return reconcileCompleted, nil
}
}
@@ -1540,6 +1573,7 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request, fc *fulfill
}
switch {
case updated.Outcome == OutcomeFailed:
s.publishRequestEvent(ctx, "request.failed", *updated)
return reconcileFailed, nil
case updated.Status == StatusQueued:
return reconcileSubmitted, nil
@@ -1596,7 +1630,8 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request, fc *fulfill
if newStatus == "" || newStatus == target.Status {
continue
}
if _, err := s.store.UpdateTargetStatus(ctx, target.ID, newStatus, "", st.ExternalStatus, st.Message, Viewer{}); err != nil {
updatedReq, err := s.store.UpdateTargetStatus(ctx, target.ID, newStatus, "", st.ExternalStatus, st.Message, Viewer{})
if err != nil {
return reconcileUnchanged, err
}
switch newStatus {
@@ -1611,6 +1646,18 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request, fc *fulfill
change = reconcileFailed
}
}
// Publish lifecycle events keyed on the AGGREGATE request state, not the
// individual target transition. This prevents double-publish when multiple
// targets change in the same cycle and avoids spurious "failed" events while
// sibling targets are still active.
if updatedReq != nil {
switch {
case updatedReq.Status == StatusCompleted:
s.publishRequestEvent(ctx, "request.completed", *updatedReq)
case updatedReq.Outcome == OutcomeFailed:
s.publishRequestEvent(ctx, "request.failed", *updatedReq)
}
}
}
return change, nil
}
+235
View File
@@ -2,6 +2,7 @@ package requests
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -9,6 +10,7 @@ import (
"testing"
"time"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/metadata/tmdb"
)
@@ -2649,6 +2651,73 @@ func (f *fakeRequesterIdentity) ResolveRequester(_ context.Context, userID int)
return f.email, f.username, f.err
}
// TestApprove_PublishesRequestApproved checks that approving a pending request
// publishes a "request.approved" event on ChannelRequests with the correct
// request_id and user_id in the payload when submission succeeds.
func TestApprove_PublishesRequestApproved(t *testing.T) {
store := newFakeStore()
store.integrations = []Integration{routerInst("router-1")}
store.requests["req-1"] = &Request{
ID: "req-1",
MediaType: MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
Status: StatusPending,
Outcome: OutcomeActive,
RequestedByUserID: 42,
RequestedByProfileID: "profile-42",
}
service := newTestService(store)
service.SetRouterProvider(&fakeRouterProvider{})
hub := evt.NewHub("test", nil)
service.SetEventsHub(hub)
ch, unsub := hub.Subscribe()
defer unsub()
_, _ = service.Approve(context.Background(), Viewer{UserID: 99, IsAdmin: true}, "req-1")
select {
case env := <-ch:
if env.Channel != evt.ChannelRequests {
t.Fatalf("channel = %q, want %q", env.Channel, evt.ChannelRequests)
}
if env.Event != "request.approved" {
t.Fatalf("event = %q, want request.approved", env.Event)
}
var payload RequestEventPayload
if err := json.Unmarshal(env.Data, &payload); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if payload.RequestID != "req-1" {
t.Fatalf("request_id = %q, want req-1", payload.RequestID)
}
if payload.UserID != 42 {
t.Fatalf("user_id = %d, want 42", payload.UserID)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for request.approved event")
}
}
// TestPublish_NilHubIsNoOp verifies that operating without SetEventsHub does
// not panic and the Approve call succeeds normally.
func TestPublish_NilHubIsNoOp(t *testing.T) {
store := newFakeStore()
store.requests["req-2"] = &Request{
ID: "req-2",
MediaType: MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
Status: StatusPending,
Outcome: OutcomeActive,
RequestedByUserID: 7,
}
service := newTestService(store)
// No hub attached — must not panic.
_, _ = service.Approve(context.Background(), Viewer{UserID: 99, IsAdmin: true}, "req-2")
}
func TestSubmitApprovedPopulatesRequesterIdentity(t *testing.T) {
store := newFakeStore()
store.integrations = []Integration{routerInstOn("router-1", 1)}
@@ -2666,3 +2735,169 @@ func TestSubmitApprovedPopulatesRequesterIdentity(t *testing.T) {
t.Fatalf("descriptor identity = %q/%q, want u@example.com/bob", router.gotRequesterEmail, router.gotRequesterUsername)
}
}
// TestReconcile_PartialTargetFailureDoesNotPublishFailed verifies that when one
// target fails but a sibling is still downloading, the aggregate request is NOT
// terminal — so no "request.failed" event is published during that reconcile cycle.
func TestReconcile_PartialTargetFailureDoesNotPublishFailed(t *testing.T) {
store := newFakeStore()
store.integrations = []Integration{routerInst("router-1")}
store.requests["req-1"] = &Request{
ID: "req-1", MediaType: MediaTypeMovie, TMDBID: 550,
Status: StatusDownloading, Outcome: OutcomeActive,
}
// Two targets: 1080p is queued (the one that will transition to failed this
// cycle), 2160p is already downloading (still active — keeps the aggregate live).
if _, err := store.CreateTarget(context.Background(), Target{
RequestID: "req-1", IntegrationID: "router-1",
Quality: Quality1080p, Status: StatusQueued, ExternalID: "ext-hd",
}); err != nil {
t.Fatalf("seed 1080p target: %v", err)
}
if _, err := store.CreateTarget(context.Background(), Target{
RequestID: "req-1", IntegrationID: "router-1",
Quality: Quality2160p, Status: StatusDownloading, ExternalID: "ext-uhd",
}); err != nil {
t.Fatalf("seed 2160p target: %v", err)
}
// Provider reports only the 1080p target as failed; 2160p is omitted (still
// downloading from prior cycle — no status update from provider this cycle).
router := &fakeRouterProvider{statuses: []RouterTargetStatus{{
Quality: Quality1080p,
ConnectionID: "router-1",
Status: StatusFailed,
Message: "indexer rejected",
}}}
service := newTestService(store)
service.SetRouterProvider(router)
hub := evt.NewHub("test", nil)
service.SetEventsHub(hub)
ch, unsub := hub.Subscribe()
defer unsub()
store.candidates = []*Request{store.requests["req-1"]}
result, err := service.ReconcileRequests(context.Background(), 100)
if err != nil {
t.Fatalf("ReconcileRequests: %v", err)
}
if result.Failed != 1 {
t.Fatalf("failed = %d, want 1 (one target failed)", result.Failed)
}
// The aggregate should still be active (one target still downloading).
req, _ := store.GetRequest(context.Background(), "req-1")
if req.Outcome != OutcomeActive {
t.Fatalf("aggregate outcome = %q, want active (sibling still downloading)", req.Outcome)
}
// No "request.failed" event must have been published.
select {
case env := <-ch:
if env.Event == "request.failed" {
t.Fatalf("got spurious request.failed event while aggregate is still active")
}
// Any other event (e.g. none expected here) would also be odd, but the key
// invariant is that request.failed must not fire.
default:
// No event published — correct.
}
}
// TestApprove_SoftFailPublishesRequestFailed verifies that when the admin
// approves a request but submitApprovedRequest soft-fails (no integrations
// configured), the event published is "request.failed", not "request.approved".
func TestApprove_SoftFailPublishesRequestFailed(t *testing.T) {
store := newFakeStore()
store.requests["req-fail"] = &Request{
ID: "req-fail",
MediaType: MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
Status: StatusPending,
Outcome: OutcomeActive,
RequestedByUserID: 7,
RequestedByProfileID: "profile-7",
}
// No router provider and no integrations → markSubmissionFailed is called,
// which sets Outcome = OutcomeFailed. publishApprovalOutcome must then
// emit "request.failed", not "request.approved".
service := newTestService(store)
hub := evt.NewHub("test", nil)
service.SetEventsHub(hub)
ch, unsub := hub.Subscribe()
defer unsub()
_, _ = service.Approve(context.Background(), Viewer{UserID: 99, IsAdmin: true}, "req-fail")
select {
case env := <-ch:
if env.Channel != evt.ChannelRequests {
t.Fatalf("channel = %q, want %q", env.Channel, evt.ChannelRequests)
}
if env.Event != "request.failed" {
t.Fatalf("event = %q, want request.failed (soft-fail must not publish request.approved)", env.Event)
}
var payload RequestEventPayload
if err := json.Unmarshal(env.Data, &payload); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if payload.RequestID != "req-fail" {
t.Fatalf("request_id = %q, want req-fail", payload.RequestID)
}
if payload.UserID != 7 {
t.Fatalf("user_id = %d, want 7", payload.UserID)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for request.failed event")
}
}
// TestCreateRequest_AutoApprovePublishesApproved verifies that when auto-approval
// is enabled and submission succeeds, CreateRequest publishes both
// "request.submitted" and "request.approved" events (in that order).
func TestCreateRequest_AutoApprovePublishesApproved(t *testing.T) {
store := newFakeStore()
store.settings.RequestsEnabled = true
store.settings.GlobalAutoApprovalEnabled = true
store.integrations = []Integration{autoApproveRouterInst("router-1", "radarr-key")}
service := newTestService(store)
service.SetRouterProvider(&fakeRouterProvider{})
hub := evt.NewHub("test", nil)
service.SetEventsHub(hub)
ch, unsub := hub.Subscribe()
defer unsub()
req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{
MediaType: MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
})
if err != nil {
t.Fatalf("CreateRequest: %v", err)
}
if req.Status != StatusQueued {
t.Fatalf("status = %q, want queued (auto-approved and submitted)", req.Status)
}
// Collect events — expect exactly two: submitted then approved.
var events []string
timeout := time.After(time.Second)
for len(events) < 2 {
select {
case env := <-ch:
events = append(events, env.Event)
case <-timeout:
t.Fatalf("timed out; got events: %v", events)
}
}
if events[0] != "request.submitted" {
t.Fatalf("events[0] = %q, want request.submitted", events[0])
}
if events[1] != "request.approved" {
t.Fatalf("events[1] = %q, want request.approved (auto-approve succeeded)", events[1])
}
}
@@ -0,0 +1,54 @@
package tasks
import (
"context"
"fmt"
"time"
"github.com/Silo-Server/silo-server/internal/taskmanager"
)
// NotificationDigester sends the daily digest to opted-in users.
type NotificationDigester interface {
RunDailyDigest(ctx context.Context, since time.Time) error
}
// NotificationsDigestTask sends daily digest notifications on a daily schedule.
type NotificationsDigestTask struct {
digester NotificationDigester
}
// NewNotificationsDigestTask creates a digest task backed by digester.
func NewNotificationsDigestTask(digester NotificationDigester) *NotificationsDigestTask {
return &NotificationsDigestTask{digester: digester}
}
func (t *NotificationsDigestTask) Key() string { return "notifications_digest" }
func (t *NotificationsDigestTask) Name() string { return "Send notification digests" }
func (t *NotificationsDigestTask) Description() string {
return "Sends a daily digest notification summarizing new catalog additions to opted-in users"
}
func (t *NotificationsDigestTask) Category() taskmanager.TaskCategory {
return taskmanager.TaskCategorySystem
}
func (t *NotificationsDigestTask) IsHidden() bool { return false }
func (t *NotificationsDigestTask) DefaultTriggers() []taskmanager.TriggerConfig {
return []taskmanager.TriggerConfig{
{Type: taskmanager.TriggerTypeDaily, TimeOfDay: "08:00"},
}
}
func (t *NotificationsDigestTask) Execute(ctx context.Context, progress taskmanager.ProgressReporter) error {
progress.Report(0, "Sending notification digests")
if t.digester == nil {
progress.Report(100, "Notification digest unavailable")
return nil
}
since := time.Now().Add(-24 * time.Hour)
if err := t.digester.RunDailyDigest(ctx, since); err != nil {
return fmt.Errorf("notifications digest: %w", err)
}
progress.Report(100, "Notification digests sent")
return nil
}
@@ -0,0 +1,100 @@
package tasks
import (
"context"
"errors"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/taskmanager"
)
type fakeNotificationDigester struct {
calls int
lastSince time.Time
err error
}
func (f *fakeNotificationDigester) RunDailyDigest(_ context.Context, since time.Time) error {
f.calls++
f.lastSince = since
return f.err
}
func TestNotificationsDigestTask_Metadata(t *testing.T) {
task := NewNotificationsDigestTask(&fakeNotificationDigester{})
if task.Key() != "notifications_digest" {
t.Fatalf("Key() = %q, want notifications_digest", task.Key())
}
if task.Name() == "" {
t.Fatal("Name() should not be empty")
}
if task.Description() == "" {
t.Fatal("Description() should not be empty")
}
if task.Category() != taskmanager.TaskCategorySystem {
t.Fatalf("Category() = %q, want %q", task.Category(), taskmanager.TaskCategorySystem)
}
if task.IsHidden() {
t.Fatal("IsHidden() = true, want false")
}
triggers := task.DefaultTriggers()
if len(triggers) != 1 {
t.Fatalf("DefaultTriggers() length = %d, want 1", len(triggers))
}
if triggers[0].Type != taskmanager.TriggerTypeDaily {
t.Fatalf("trigger type = %q, want daily", triggers[0].Type)
}
if triggers[0].TimeOfDay != "08:00" {
t.Fatalf("TimeOfDay = %q, want 08:00", triggers[0].TimeOfDay)
}
}
func TestNotificationsDigestTask_Execute(t *testing.T) {
digester := &fakeNotificationDigester{}
task := NewNotificationsDigestTask(digester)
progress := &notificationsProgress{}
before := time.Now()
if err := task.Execute(context.Background(), progress); err != nil {
t.Fatalf("Execute returned error: %v", err)
}
after := time.Now()
if digester.calls != 1 {
t.Fatalf("RunDailyDigest calls = %d, want 1", digester.calls)
}
// since should be ~24h ago
wantSince := before.Add(-24 * time.Hour)
if digester.lastSince.Before(wantSince.Add(-time.Second)) || digester.lastSince.After(after.Add(-24*time.Hour+time.Second)) {
t.Errorf("since = %v, want ~24h before now", digester.lastSince)
}
if len(progress.reports) == 0 {
t.Fatal("expected progress reports")
}
}
func TestNotificationsDigestTask_NilDigesterIsNoOp(t *testing.T) {
task := NewNotificationsDigestTask(nil)
progress := &notificationsProgress{}
if err := task.Execute(context.Background(), progress); err != nil {
t.Fatalf("Execute with nil digester returned error: %v", err)
}
if len(progress.reports) == 0 {
t.Fatal("expected at least one progress report")
}
}
func TestNotificationsDigestTask_DigestError(t *testing.T) {
wantErr := errors.New("digest failed")
task := NewNotificationsDigestTask(&fakeNotificationDigester{err: wantErr})
progress := &notificationsProgress{}
err := task.Execute(context.Background(), progress)
if !errors.Is(err, wantErr) {
t.Fatalf("Execute error = %v, want %v", err, wantErr)
}
}
@@ -0,0 +1,72 @@
package tasks
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/Silo-Server/silo-server/internal/taskmanager"
)
// NotificationPurger deletes old notifications rows.
type NotificationPurger interface {
PurgeOld(ctx context.Context, dismissedBefore, allBefore time.Time) (int64, error)
}
// PushPurger deletes terminal push deliveries older than a cutoff.
type PushPurger interface {
PurgeTerminal(ctx context.Context, before time.Time) (int64, error)
}
// NotificationsRetentionTask removes old notification rows on a daily schedule.
type NotificationsRetentionTask struct {
store NotificationPurger
pushPurger PushPurger
}
// NewNotificationsRetentionTask creates a retention task backed by store.
// pushPurger may be nil, in which case push delivery purging is skipped.
func NewNotificationsRetentionTask(store NotificationPurger, pushPurger PushPurger) *NotificationsRetentionTask {
return &NotificationsRetentionTask{store: store, pushPurger: pushPurger}
}
func (t *NotificationsRetentionTask) Key() string { return "notifications_retention" }
func (t *NotificationsRetentionTask) Name() string { return "Purge old notifications" }
func (t *NotificationsRetentionTask) Description() string {
return "Removes dismissed and expired notification rows to keep the table lean"
}
func (t *NotificationsRetentionTask) Category() taskmanager.TaskCategory {
return taskmanager.TaskCategorySystem
}
func (t *NotificationsRetentionTask) IsHidden() bool { return false }
func (t *NotificationsRetentionTask) DefaultTriggers() []taskmanager.TriggerConfig {
return []taskmanager.TriggerConfig{
{Type: taskmanager.TriggerTypeInterval, IntervalMs: 24 * 60 * 60 * 1000},
}
}
func (t *NotificationsRetentionTask) Execute(ctx context.Context, progress taskmanager.ProgressReporter) error {
progress.Report(0, "Purging old notifications")
if t.store == nil {
progress.Report(100, "Notification retention unavailable")
return nil
}
now := time.Now()
deleted, err := t.store.PurgeOld(ctx, now.Add(-30*24*time.Hour), now.Add(-90*24*time.Hour))
if err != nil {
return fmt.Errorf("notifications retention: %w", err)
}
pushDeleted := int64(0)
if t.pushPurger != nil {
if n, err := t.pushPurger.PurgeTerminal(ctx, time.Now().AddDate(0, 0, -7)); err != nil {
slog.WarnContext(ctx, "push: purge terminal deliveries failed", "error", err)
} else if n > 0 {
slog.InfoContext(ctx, "push: purged terminal deliveries", "count", n)
pushDeleted = n
}
}
progress.Report(100, fmt.Sprintf("Purged %d old notification rows, %d push deliveries", deleted, pushDeleted))
return nil
}
@@ -0,0 +1,185 @@
package tasks
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/taskmanager"
)
type fakeNotificationPurger struct {
calls int
lastDismissed time.Time
lastAll time.Time
returnCount int64
err error
}
func (f *fakeNotificationPurger) PurgeOld(_ context.Context, dismissedBefore, allBefore time.Time) (int64, error) {
f.calls++
f.lastDismissed = dismissedBefore
f.lastAll = allBefore
return f.returnCount, f.err
}
type fakePushPurger struct {
calls int
lastBefore time.Time
returnCount int64
err error
}
func (f *fakePushPurger) PurgeTerminal(_ context.Context, before time.Time) (int64, error) {
f.calls++
f.lastBefore = before
return f.returnCount, f.err
}
func TestNotificationsRetentionTask_Metadata(t *testing.T) {
task := NewNotificationsRetentionTask(&fakeNotificationPurger{}, nil)
if task.Key() != "notifications_retention" {
t.Fatalf("Key() = %q, want notifications_retention", task.Key())
}
if task.Name() == "" {
t.Fatal("Name() should not be empty")
}
if task.Description() == "" {
t.Fatal("Description() should not be empty")
}
if task.Category() != taskmanager.TaskCategorySystem {
t.Fatalf("Category() = %q, want %q", task.Category(), taskmanager.TaskCategorySystem)
}
if task.IsHidden() {
t.Fatal("IsHidden() = true, want false")
}
triggers := task.DefaultTriggers()
if len(triggers) != 1 {
t.Fatalf("DefaultTriggers() length = %d, want 1", len(triggers))
}
if triggers[0].Type != taskmanager.TriggerTypeInterval {
t.Fatalf("trigger type = %q, want interval", triggers[0].Type)
}
wantMs := int64(24 * 60 * 60 * 1000)
if triggers[0].IntervalMs != wantMs {
t.Fatalf("IntervalMs = %d, want %d", triggers[0].IntervalMs, wantMs)
}
}
func TestNotificationsRetentionTask_Execute(t *testing.T) {
purger := &fakeNotificationPurger{returnCount: 42}
task := NewNotificationsRetentionTask(purger, nil)
progress := &notificationsProgress{}
before := time.Now()
if err := task.Execute(context.Background(), progress); err != nil {
t.Fatalf("Execute returned error: %v", err)
}
after := time.Now()
if purger.calls != 1 {
t.Fatalf("PurgeOld calls = %d, want 1", purger.calls)
}
// dismissedBefore should be ~30 days ago
wantDismissed := before.Add(-30 * 24 * time.Hour)
if purger.lastDismissed.Before(wantDismissed.Add(-time.Second)) || purger.lastDismissed.After(after.Add(-30*24*time.Hour+time.Second)) {
t.Errorf("dismissedBefore = %v, want ~30 days before now", purger.lastDismissed)
}
// allBefore should be ~90 days ago
wantAll := before.Add(-90 * 24 * time.Hour)
if purger.lastAll.Before(wantAll.Add(-time.Second)) || purger.lastAll.After(after.Add(-90*24*time.Hour+time.Second)) {
t.Errorf("allBefore = %v, want ~90 days before now", purger.lastAll)
}
if len(progress.reports) == 0 {
t.Fatal("expected progress reports")
}
}
func TestNotificationsRetentionTask_NilStorerIsNoOp(t *testing.T) {
task := NewNotificationsRetentionTask(nil, nil)
progress := &notificationsProgress{}
if err := task.Execute(context.Background(), progress); err != nil {
t.Fatalf("Execute with nil store returned error: %v", err)
}
if len(progress.reports) == 0 {
t.Fatal("expected at least one progress report")
}
}
func TestNotificationsRetentionTask_PurgeError(t *testing.T) {
wantErr := errors.New("db down")
task := NewNotificationsRetentionTask(&fakeNotificationPurger{err: wantErr}, nil)
progress := &notificationsProgress{}
err := task.Execute(context.Background(), progress)
if !errors.Is(err, wantErr) {
t.Fatalf("Execute error = %v, want %v", err, wantErr)
}
}
func TestNotificationsRetentionTask_PushPurgerInvoked(t *testing.T) {
notifPurger := &fakeNotificationPurger{returnCount: 5}
pushPurger := &fakePushPurger{returnCount: 3}
task := NewNotificationsRetentionTask(notifPurger, pushPurger)
progress := &notificationsProgress{}
before := time.Now()
if err := task.Execute(context.Background(), progress); err != nil {
t.Fatalf("Execute returned error: %v", err)
}
after := time.Now()
if pushPurger.calls != 1 {
t.Fatalf("PurgeTerminal calls = %d, want 1", pushPurger.calls)
}
// cutoff should be ~7 days ago
wantCutoff := before.AddDate(0, 0, -7)
if pushPurger.lastBefore.Before(wantCutoff.Add(-time.Second)) || pushPurger.lastBefore.After(after.AddDate(0, 0, -7).Add(time.Second)) {
t.Errorf("PurgeTerminal before = %v, want ~7 days before now", pushPurger.lastBefore)
}
}
func TestNotificationsRetentionTask_NilPushPurgerIsNoOp(t *testing.T) {
notifPurger := &fakeNotificationPurger{returnCount: 2}
task := NewNotificationsRetentionTask(notifPurger, nil)
progress := &notificationsProgress{}
// Must not panic when pushPurger is nil.
if err := task.Execute(context.Background(), progress); err != nil {
t.Fatalf("Execute with nil pushPurger returned error: %v", err)
}
if notifPurger.calls != 1 {
t.Fatalf("PurgeOld calls = %d, want 1", notifPurger.calls)
}
}
func TestNotificationsRetentionTask_PushPurgerError(t *testing.T) {
notifPurger := &fakeNotificationPurger{returnCount: 1}
pushPurger := &fakePushPurger{err: errors.New("push db error")}
task := NewNotificationsRetentionTask(notifPurger, pushPurger)
progress := &notificationsProgress{}
// Push purge errors are logged as warnings, not propagated.
if err := task.Execute(context.Background(), progress); err != nil {
t.Fatalf("Execute returned error on push purge failure: %v", err)
}
if pushPurger.calls != 1 {
t.Fatalf("PurgeTerminal calls = %d, want 1", pushPurger.calls)
}
}
// notificationsProgress is a shared fake ProgressReporter for notifications task tests.
type notificationsProgress struct {
reports []string
}
func (p *notificationsProgress) Report(_ float64, msg string) {
p.reports = append(p.reports, msg)
}
func (p *notificationsProgress) SetResultData(_ json.RawMessage) {}
@@ -0,0 +1,53 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE public.notifications (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
profile_id text NULL,
category text NOT NULL CHECK (category IN ('request','content','announcement','system','admin')),
type text NOT NULL,
title text NOT NULL,
body text NOT NULL DEFAULT '',
link text NULL,
item_id text NULL,
source_event text NULL,
dedup_ref text NULL,
created_at timestamptz NOT NULL DEFAULT now(),
read_at timestamptz NULL,
dismissed_at timestamptz NULL,
expires_at timestamptz NULL,
CONSTRAINT notifications_profile_fkey FOREIGN KEY (user_id, profile_id) REFERENCES public.user_profiles(user_id, id) ON DELETE CASCADE
);
CREATE INDEX notifications_inbox_idx
ON public.notifications (user_id, created_at DESC)
WHERE dismissed_at IS NULL;
CREATE UNIQUE INDEX notifications_dedup_idx
ON public.notifications (user_id, type, dedup_ref)
WHERE dedup_ref IS NOT NULL;
CREATE TABLE public.notification_preferences (
user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
category text NOT NULL CHECK (category IN ('request','content','system','admin','content_digest')),
enabled boolean NOT NULL,
PRIMARY KEY (user_id, category)
);
CREATE TABLE public.announcements (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
body text NOT NULL DEFAULT '',
audience jsonb NOT NULL,
created_by integer NULL REFERENCES public.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NULL
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE public.announcements;
DROP TABLE public.notification_preferences;
DROP TABLE public.notifications;
-- +goose StatementEnd
@@ -0,0 +1,11 @@
-- +goose Up
-- +goose StatementBegin
CREATE INDEX user_watchlist_media_item_idx ON public.user_watchlist (media_item_id);
CREATE INDEX user_favorites_media_item_idx ON public.user_favorites (media_item_id);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX public.user_favorites_media_item_idx;
DROP INDEX public.user_watchlist_media_item_idx;
-- +goose StatementEnd
@@ -0,0 +1,39 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE public.user_devices
ADD COLUMN push_token text NULL,
ADD COLUMN push_transport text NULL,
ADD COLUMN push_enabled boolean NOT NULL DEFAULT true,
ADD COLUMN push_token_at timestamptz NULL,
ADD COLUMN push_failures integer NOT NULL DEFAULT 0;
CREATE TABLE public.push_deliveries (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
notification_id bigint NOT NULL REFERENCES public.notifications(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
device_id text NOT NULL,
transport text NOT NULL,
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','sent','failed','skipped','dead')),
attempts integer NOT NULL DEFAULT 0,
not_before timestamptz NOT NULL DEFAULT now(),
last_error text NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX push_deliveries_claim_idx
ON public.push_deliveries (not_before)
WHERE status IN ('pending','failed');
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE public.push_deliveries;
ALTER TABLE public.user_devices
DROP COLUMN push_token,
DROP COLUMN push_transport,
DROP COLUMN push_enabled,
DROP COLUMN push_token_at,
DROP COLUMN push_failures;
-- +goose StatementEnd
@@ -0,0 +1,15 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE public.push_deliveries ADD COLUMN profile_id text NULL;
-- +goose StatementEnd
-- +goose StatementBegin
ALTER TABLE public.push_deliveries ADD CONSTRAINT push_deliveries_profile_fkey FOREIGN KEY (user_id, profile_id) REFERENCES public.user_profiles(user_id, id) ON DELETE CASCADE;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE public.push_deliveries DROP CONSTRAINT IF EXISTS push_deliveries_profile_fkey;
-- +goose StatementEnd
-- +goose StatementBegin
ALTER TABLE public.push_deliveries DROP COLUMN profile_id;
-- +goose StatementEnd
+6
View File
@@ -35,6 +35,7 @@ import EbookReader from "@/pages/EbookReader";
import PersonDetail from "@/pages/PersonDetail";
import Collections from "@/pages/Collections";
import CollectionEditor from "@/pages/CollectionEditor";
import Notifications from "@/pages/Notifications";
import Requests from "@/pages/Requests";
import RequestBrowse from "@/pages/RequestBrowse";
import RequestDetail from "@/pages/RequestDetail";
@@ -57,6 +58,7 @@ import AdminMaintenance from "@/pages/AdminMaintenance";
import AdminApiKeys from "@/pages/AdminApiKeys";
import AdminSubtitles from "@/pages/AdminSubtitles";
import AdminUserDetail from "@/pages/AdminUserDetail";
import AdminAnnouncements from "@/pages/AdminAnnouncements";
import AdminTasks from "@/pages/AdminTasks";
import AdminTaskDetail from "@/pages/AdminTaskDetail";
import AdminPlugins from "@/pages/AdminPlugins";
@@ -84,6 +86,7 @@ import HomeScreenSettings from "@/pages/settings/HomeScreenSettings";
import ThemeEditorSettings from "@/pages/settings/ThemeEditorSettings";
import CardOverlaySettings from "@/pages/settings/CardOverlaySettings";
import PersonalizeSettings from "@/pages/settings/PersonalizeSettings";
import NotificationsSettings from "@/pages/settings/NotificationsSettings";
import WatchTogetherJoin from "@/pages/WatchTogetherJoin";
import WatchTogetherRoomPage from "@/pages/WatchTogetherRoomPage";
import WatchRoute from "@/pages/WatchRoute";
@@ -392,6 +395,7 @@ function AppRoutes() {
<Route path="history-import" element={<AdminHistoryImport />} />
<Route path="users" element={<AdminUsers />} />
<Route path="users/:id" element={<AdminUserDetail />} />
<Route path="announcements" element={<AdminAnnouncements />} />
<Route path="devices" element={<AdminDevices />} />
<Route path="devices/:userId/:deviceId" element={<AdminDevices />} />
<Route path="nodes" element={<AdminNodes />} />
@@ -439,6 +443,7 @@ function AppRoutes() {
<Route path="home-screen" element={<HomeScreenSettings />} />
<Route path="card-overlays" element={<CardOverlaySettings />} />
<Route path="personalize" element={<PersonalizeSettings />} />
<Route path="notifications" element={<NotificationsSettings />} />
<Route path="*" element={<Navigate to="/settings/playback" replace />} />
</Route>
<Route
@@ -532,6 +537,7 @@ function AppRoutes() {
element={<RecommendationsSection />}
/>
<Route path="/calendar" element={<Calendar />} />
<Route path="/notifications" element={<Notifications />} />
<Route
path="/profile/customize-home"
element={<ProfileCustomizeHome />}
+47 -1
View File
@@ -2221,7 +2221,8 @@ export type EventChannel =
| "tasks"
| "scans"
| "history_import"
| "user_state";
| "user_state"
| "notifications";
export interface EventsHelloMessage {
type: "hello";
@@ -3752,3 +3753,48 @@ export interface FilesystemBrowseResponse {
parent: string;
entries: FilesystemBrowseEntry[];
}
export type NotificationCategory = "request" | "content" | "announcement" | "system" | "admin";
export type NotificationPreferenceCategory = NotificationCategory | "content_digest";
export interface AppNotification {
id: number;
user_id: number;
profile_id?: string;
category: NotificationCategory;
type: string;
title: string;
body: string;
link?: string;
item_id?: string;
created_at: string;
read_at?: string;
expires_at?: string;
}
export interface NotificationListResponse {
items: AppNotification[] | null;
next_cursor: number | null;
}
export interface NotificationPreference {
category: NotificationPreferenceCategory;
enabled: boolean;
}
export interface AnnouncementAudience {
all?: boolean;
user_ids?: number[];
library_ids?: number[];
}
export interface Announcement {
id: number;
title: string;
body: string;
audience: AnnouncementAudience;
created_by?: number;
created_at: string;
expires_at?: string;
}
+2
View File
@@ -23,6 +23,7 @@ import {
Send,
RefreshCw,
SkipForward,
Megaphone,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
@@ -116,6 +117,7 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) {
label: "Users",
items: [
{ label: "Users", icon: Users, href: "/admin/users" },
{ label: "Announcements", icon: Megaphone, href: "/admin/announcements" },
{ label: "Devices", icon: MonitorSmartphone, href: "/admin/devices" },
{ label: "Playback History", icon: History, href: "/admin/history" },
{ label: "History Import", icon: Download, href: "/admin/history-import" },
+117
View File
@@ -0,0 +1,117 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockMarkReadMutate = vi.fn();
vi.mock("@/hooks/queries/notifications", () => ({
useNotificationsList: vi.fn(() => ({ data: undefined })),
useMarkRead: vi.fn(() => ({ mutate: mockMarkReadMutate })),
}));
import { useNotificationsList } from "@/hooks/queries/notifications";
import { AnnouncementBar } from "./AnnouncementBar";
const mockedList = vi.mocked(useNotificationsList);
function makeQueryClient() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}
function renderBar(props: { reserveActivityWidget?: boolean } = {}) {
const qc = makeQueryClient();
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<AnnouncementBar {...props} />
</MemoryRouter>
</QueryClientProvider>,
);
}
function listResult(items: unknown[]) {
return {
data: { pages: [{ items, next_cursor: null }] },
} as unknown as ReturnType<typeof useNotificationsList>;
}
beforeEach(() => {
mockMarkReadMutate.mockClear();
mockedList.mockReturnValue(listResult([]));
});
describe("AnnouncementBar", () => {
it("renders the latest unread announcement with the ANNOUNCEMENT label", () => {
mockedList.mockReturnValue(
listResult([
{
id: 7,
category: "announcement",
title: "New feature launched",
body: "Check out the redesign",
link: "/whats-new",
read_at: null,
created_at: "2026-06-11T10:00:00Z",
},
]),
);
renderBar();
expect(screen.getByText("New feature launched")).toBeInTheDocument();
expect(screen.getByText("ANNOUNCEMENT")).toBeInTheDocument();
});
it("dismiss button calls markRead.mutate with the announcement id", async () => {
mockedList.mockReturnValue(
listResult([
{
id: 7,
category: "announcement",
title: "New feature launched",
body: "Check out the redesign",
link: "/whats-new",
read_at: null,
created_at: "2026-06-11T10:00:00Z",
},
]),
);
const user = userEvent.setup();
renderBar();
await user.click(screen.getByRole("button", { name: "Dismiss" }));
expect(mockMarkReadMutate).toHaveBeenCalledWith({ ids: [7] });
});
it("renders nothing when there is no unread announcement", () => {
mockedList.mockReturnValue(listResult([]));
const { container } = renderBar();
expect(container).toBeEmptyDOMElement();
});
it("shifts the dismiss button with lg:mr-10 when reserveActivityWidget is set", () => {
mockedList.mockReturnValue(
listResult([
{
id: 7,
category: "announcement",
title: "New feature launched",
body: "Check out the redesign",
link: "/whats-new",
read_at: null,
created_at: "2026-06-11T10:00:00Z",
},
]),
);
renderBar({ reserveActivityWidget: true });
expect(screen.getByRole("button", { name: "Dismiss" })).toHaveClass("lg:mr-10");
});
});
+39
View File
@@ -0,0 +1,39 @@
import { Megaphone } from "lucide-react";
import { NotificationStripBar } from "@/components/NotificationStripBar";
import { useMarkRead, useNotificationsList } from "@/hooks/queries/notifications";
interface AnnouncementBarProps {
/**
* When true, shift the dismiss button left on large screens so it clears the
* admin-only ServerActivity widget pinned at `fixed top-5 right-5` (which
* floats over the top-right corner of the page content).
*/
reserveActivityWidget?: boolean;
}
export function AnnouncementBar({ reserveActivityWidget }: AnnouncementBarProps = {}) {
// Always refetch when the home page mounts so a just-published announcement
// shows the moment you land on the app, without a manual reload.
const list = useNotificationsList(
{ unread: true, category: "announcement" },
{ staleTime: 0, refetchOnMount: "always" },
);
const markRead = useMarkRead();
const items = (list.data?.pages ?? []).flatMap((p) => p.items ?? []);
const item = items[0];
if (!item) return null;
return (
<NotificationStripBar
item={item}
icon={Megaphone}
label="ANNOUNCEMENT"
variant="announcement"
reserveActivityWidget={reserveActivityWidget}
onDismiss={(id) => markRead.mutate({ ids: [id] })}
/>
);
}
+24
View File
@@ -11,6 +11,7 @@ import { getProfileMenuSide, isSidebarExpanded } from "./AppSidebar.logic";
const mockLogout = vi.fn();
const mockClearProfile = vi.fn();
const mockTogglePin = vi.fn();
const mockUnreadCount = vi.hoisted(() => ({ data: 5 as number }));
vi.mock("@/hooks/useAuth", () => ({
useAuth: () => ({
@@ -55,6 +56,10 @@ vi.mock("@/hooks/queries/useRequests", () => ({
}),
}));
vi.mock("@/hooks/queries/notifications", () => ({
useUnreadCount: () => mockUnreadCount,
}));
vi.mock("@/hooks/useViewTransition", () => ({
useViewTransitionNavigate: () => vi.fn(),
}));
@@ -107,6 +112,7 @@ describe("AppSidebar", () => {
mockLogout.mockReset();
mockClearProfile.mockReset();
mockTogglePin.mockReset();
mockUnreadCount.data = 5;
});
it("uses the cinema highlight text color for active catalog source links", () => {
@@ -172,4 +178,22 @@ describe("AppSidebar", () => {
expect(markup).toContain("mx-auto h-10 w-10 justify-center px-0");
});
it("renders a notifications nav link with an unread badge when count > 0", () => {
const markup = renderSidebar("/");
expect(markup).toContain('href="/notifications"');
expect(markup).toContain("Notifications");
expect(markup).toContain(">5<");
});
it("does not render an unread badge when the count is 0", () => {
mockUnreadCount.data = 0;
const markup = renderSidebar("/");
// Badge classes should not be present when count is 0
expect(markup).not.toContain("rounded-full px-0.5");
// Badge should not render "0" content
expect(markup).not.toContain(">0<");
});
});
+27
View File
@@ -8,6 +8,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useCurrentProfile } from "@/hooks/useCurrentProfile";
import { navigateToPluginRoute } from "@/lib/buildPluginHref";
import { useUserLibraries } from "@/hooks/queries/libraries";
import { useUnreadCount } from "@/hooks/queries/notifications";
import { usePluginSettingsList } from "@/hooks/queries/pluginSettings";
import { useRequestFeatureStatus } from "@/hooks/queries/useRequests";
import { useSidebarPins, useToggleSidebarPin } from "@/hooks/queries/sidebarPins";
@@ -53,6 +54,7 @@ import {
Puzzle,
BookHeadphones,
Send,
Bell,
} from "lucide-react";
import { useTheme } from "@/hooks/useTheme";
import { CURATED_THEME_IDS, THEMES } from "@/lib/themes";
@@ -169,6 +171,7 @@ export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebar
const { theme, setTheme, previewTheme, resetPreviewTheme } = useTheme();
const isAdmin = user?.role === "admin";
const { data: libraries } = useUserLibraries();
const { data: unreadCount = 0 } = useUnreadCount();
const { pins } = useSidebarPins();
const { togglePin } = useToggleSidebarPin();
const { data: pluginSettings } = usePluginSettingsList();
@@ -643,6 +646,30 @@ export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebar
<SidebarLabel show={showLabels}>History</SidebarLabel>
</ViewTransitionLink>
</li>
<li>
<ViewTransitionLink
to="/notifications"
onClick={onNavigate}
className={navLinkClass("/notifications")}
aria-current={isActive("/notifications") ? "page" : undefined}
>
{isActive("/notifications") && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<span className="relative shrink-0">
<Bell className="h-[18px] w-[18px] shrink-0" />
{unreadCount > 0 && (
<span className="bg-primary text-primary-foreground absolute -top-1.5 -right-1.5 flex h-3.5 min-w-3.5 items-center justify-center rounded-full px-0.5 text-[9px] leading-none">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</span>
<SidebarLabel show={showLabels}>Notifications</SidebarLabel>
</ViewTransitionLink>
</li>
</ul>
</div>
+4
View File
@@ -13,12 +13,15 @@ import { buildQueryCatalogHref, parseCatalogSearchParams } from "@/pages/catalog
import type { ReactNode } from "react";
import { useWatchPlaybackController } from "@/playback/watchPlaybackContext";
import { useAudiobookPlaybackController } from "@/pages/audiobooks/player/audiobookPlaybackContext";
import { useNotificationsLive } from "@/hooks/useNotificationsLive";
import { NotificationBell } from "@/components/NotificationBell";
interface LayoutProps {
children: ReactNode;
}
export default function Layout({ children }: LayoutProps) {
useNotificationsLive();
const location = useLocation();
const [mobileOpen, setMobileOpen] = useState(false);
// Tracks whether the mobile header should slide off-screen on scroll.
@@ -155,6 +158,7 @@ export default function Layout({ children }: LayoutProps) {
>
<Search className="h-5 w-5" />
</ViewTransitionLink>
<NotificationBell />
{isAdmin && <ServerActivity hideWhenEmpty />}
<Link
to="/settings/playback"
@@ -0,0 +1,253 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockMarkReadMutate = vi.fn();
const mockDismissMutate = vi.fn();
vi.mock("@/hooks/queries/notifications", () => ({
useUnreadCount: vi.fn(() => ({ data: 0 })),
useNotificationsList: vi.fn(() => ({ data: undefined })),
useMarkRead: vi.fn(() => ({ mutate: mockMarkReadMutate })),
useDismissNotification: vi.fn(() => ({ mutate: mockDismissMutate })),
}));
// Radix Popover needs a pointer-events-capable env; jsdom provides enough.
// We also need @radix-ui/react-popover to work in jsdom.
// Use the actual Popover so open/close logic fires onOpenChange.
import { useUnreadCount, useNotificationsList } from "@/hooks/queries/notifications";
import { NotificationBell } from "./NotificationBell";
const mockedUnreadCount = vi.mocked(useUnreadCount);
const mockedList = vi.mocked(useNotificationsList);
function makeQueryClient() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}
function renderBell() {
const qc = makeQueryClient();
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<NotificationBell />
</MemoryRouter>
</QueryClientProvider>,
);
}
beforeEach(() => {
mockMarkReadMutate.mockClear();
mockedUnreadCount.mockReturnValue({ data: 0 } as ReturnType<typeof useUnreadCount>);
mockedList.mockReturnValue({ data: undefined } as unknown as ReturnType<
typeof useNotificationsList
>);
});
describe("NotificationBell", () => {
describe("badge", () => {
it("hides badge when unread count is 0", () => {
mockedUnreadCount.mockReturnValue({ data: 0 } as ReturnType<typeof useUnreadCount>);
renderBell();
// Badge should not be rendered at all
expect(screen.queryByText("0")).not.toBeInTheDocument();
});
it("shows badge with count '3' when unread count is 3", () => {
mockedUnreadCount.mockReturnValue({ data: 3 } as ReturnType<typeof useUnreadCount>);
renderBell();
expect(screen.getByText("3")).toBeInTheDocument();
});
it("shows badge '99+' when unread count is 120", () => {
mockedUnreadCount.mockReturnValue({ data: 120 } as ReturnType<typeof useUnreadCount>);
renderBell();
expect(screen.getByText("99+")).toBeInTheDocument();
});
});
describe("popover content", () => {
it("renders item titles and 'View all' link on open", async () => {
mockedList.mockReturnValue({
data: {
pages: [
{
items: [
{
id: 1,
title: "Movie Released",
body: "Dune 3 is out",
link: "/item/dune3",
read_at: null,
},
{ id: 2, title: "Request Approved", body: "", link: null, read_at: "2024-01-01" },
],
next_cursor: null,
},
],
},
} as unknown as ReturnType<typeof useNotificationsList>);
const user = userEvent.setup();
renderBell();
await user.click(screen.getByRole("button", { name: "Notifications" }));
expect(screen.getByText("Movie Released")).toBeInTheDocument();
expect(screen.getByText("Request Approved")).toBeInTheDocument();
expect(screen.getByText("View all")).toBeInTheDocument();
});
it("dismiss X calls dismiss.mutate with the notification id", async () => {
mockedList.mockReturnValue({
data: {
pages: [
{
items: [{ id: 7, title: "Movie Released", body: "", link: "/item/x", read_at: null }],
next_cursor: null,
},
],
},
} as unknown as ReturnType<typeof useNotificationsList>);
const user = userEvent.setup();
renderBell();
await user.click(screen.getByRole("button", { name: "Notifications" }));
await user.click(screen.getByRole("button", { name: "Dismiss notification" }));
expect(mockDismissMutate).toHaveBeenCalledWith(7);
});
it("pins announcements above other notifications", async () => {
mockedList.mockReturnValue({
data: {
pages: [
{
items: [
{
id: 9,
title: "Recent Request",
body: "",
link: null,
read_at: null,
category: "request",
},
{
id: 2,
title: "Server Notice",
body: "",
link: null,
read_at: null,
category: "announcement",
},
],
next_cursor: null,
},
],
},
} as unknown as ReturnType<typeof useNotificationsList>);
const user = userEvent.setup();
renderBell();
await user.click(screen.getByRole("button", { name: "Notifications" }));
const announcement = screen.getByText("Server Notice");
const request = screen.getByText("Recent Request");
// The announcement should appear before the request in document order.
expect(
announcement.compareDocumentPosition(request) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("shows empty state when no items", async () => {
mockedList.mockReturnValue({
data: { pages: [{ items: [], next_cursor: null }] },
} as unknown as ReturnType<typeof useNotificationsList>);
const user = userEvent.setup();
renderBell();
await user.click(screen.getByRole("button", { name: "Notifications" }));
expect(screen.getByText("You're all caught up")).toBeInTheDocument();
});
it("'Mark all read' calls markRead.mutate with { all: true }", async () => {
mockedUnreadCount.mockReturnValue({ data: 2 } as ReturnType<typeof useUnreadCount>);
mockedList.mockReturnValue({
data: { pages: [{ items: [], next_cursor: null }] },
} as unknown as ReturnType<typeof useNotificationsList>);
const user = userEvent.setup();
renderBell();
await user.click(screen.getByRole("button", { name: "Notifications" }));
await user.click(screen.getByRole("button", { name: "Mark all read" }));
expect(mockMarkReadMutate).toHaveBeenCalledWith({ all: true });
});
it("item without link routes to /notifications", async () => {
mockedList.mockReturnValue({
data: {
pages: [
{
items: [{ id: 3, title: "No Link Item", body: "", link: null, read_at: null }],
next_cursor: null,
},
],
},
} as unknown as ReturnType<typeof useNotificationsList>);
const user = userEvent.setup();
renderBell();
await user.click(screen.getByRole("button", { name: "Notifications" }));
const link = screen.getByRole("link", { name: /No Link Item/ });
expect(link).toHaveAttribute("href", "/notifications");
});
it("item with link uses provided link", async () => {
mockedList.mockReturnValue({
data: {
pages: [
{
items: [
{ id: 4, title: "With Link Item", body: "", link: "/item/42", read_at: null },
],
next_cursor: null,
},
],
},
} as unknown as ReturnType<typeof useNotificationsList>);
const user = userEvent.setup();
renderBell();
await user.click(screen.getByRole("button", { name: "Notifications" }));
const link = screen.getByRole("link", { name: /With Link Item/ });
expect(link).toHaveAttribute("href", "/item/42");
});
it("'View all' link points to /notifications", async () => {
mockedList.mockReturnValue({
data: { pages: [{ items: [], next_cursor: null }] },
} as unknown as ReturnType<typeof useNotificationsList>);
const user = userEvent.setup();
renderBell();
await user.click(screen.getByRole("button", { name: "Notifications" }));
const viewAll = screen.getByRole("link", { name: "View all" });
expect(viewAll).toHaveAttribute("href", "/notifications");
});
});
});
+99
View File
@@ -0,0 +1,99 @@
import { Bell, X } from "lucide-react";
import { Link } from "react-router";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
useDismissNotification,
useMarkRead,
useNotificationsList,
useUnreadCount,
} from "@/hooks/queries/notifications";
import { partitionAnnouncementsFirst } from "@/lib/notifications";
let bellOpenFlag = false;
/** True while the bell dropdown is open — used to suppress toasts.
* Single-instance only: the bell must be mounted once; a second instance would fight over this flag. */
export function isNotificationDropdownOpen() {
return bellOpenFlag;
}
/** Test-only escape hatch. */
export function setNotificationDropdownOpenForTests(open: boolean) {
bellOpenFlag = open;
}
export function NotificationBell() {
const { data: count = 0 } = useUnreadCount();
const list = useNotificationsList({});
const markRead = useMarkRead();
const dismiss = useDismissNotification();
// Pin announcements above other notifications, then take the latest 10.
const loaded = (list.data?.pages ?? []).flatMap((p) => p.items ?? []);
const items = partitionAnnouncementsFirst(loaded).slice(0, 10);
return (
<Popover
onOpenChange={(open) => {
bellOpenFlag = open;
}}
>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Notifications" className="relative">
<Bell className="h-5 w-5" />
{count > 0 && (
<Badge className="absolute -top-1 -right-1 h-4 min-w-4 px-1 text-[10px]">
{count > 99 ? "99+" : count}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="text-sm font-medium">Notifications</span>
<Button
variant="ghost"
size="sm"
disabled={count === 0}
onClick={() => markRead.mutate({ all: true })}
>
Mark all read
</Button>
</div>
<ul className="max-h-96 list-none overflow-y-auto">
{items.length === 0 && (
<li className="text-muted-foreground px-3 py-6 text-center text-sm">
You&apos;re all caught up
</li>
)}
{items.map((n) => (
<li key={n.id} className={`flex items-start ${n.read_at ? "opacity-60" : ""}`}>
<Link
to={n.link ?? "/notifications"}
className="hover:bg-accent block min-w-0 flex-1 px-3 py-2"
>
<div className="truncate text-sm font-medium">{n.title}</div>
{n.body && <div className="text-muted-foreground truncate text-xs">{n.body}</div>}
</Link>
<button
type="button"
aria-label="Dismiss notification"
className="text-muted-foreground hover:text-foreground mt-1.5 mr-1 shrink-0 rounded-md p-1.5"
onClick={() => dismiss.mutate(n.id)}
>
<X className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
<div className="border-t px-3 py-2 text-center">
<Link to="/notifications" className="text-primary text-sm">
View all
</Link>
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,146 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockMarkReadMutate = vi.fn();
vi.mock("@/hooks/queries/notifications", () => ({
useNotificationsList: vi.fn(() => ({ data: undefined })),
useMarkRead: vi.fn(() => ({ mutate: mockMarkReadMutate })),
}));
import { useNotificationsList } from "@/hooks/queries/notifications";
import { NotificationStrip } from "./NotificationStrip";
const mockedList = vi.mocked(useNotificationsList);
function makeQueryClient() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}
function renderStrip(props: { reserveActivityWidget?: boolean } = {}) {
const qc = makeQueryClient();
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<NotificationStrip {...props} />
</MemoryRouter>
</QueryClientProvider>,
);
}
function listResult(items: unknown[]) {
return {
data: { pages: [{ items, next_cursor: null }] },
} as unknown as ReturnType<typeof useNotificationsList>;
}
beforeEach(() => {
mockMarkReadMutate.mockClear();
mockedList.mockReturnValue(listResult([]));
});
describe("NotificationStrip", () => {
it("skips a leading announcement and shows the first non-announcement", () => {
mockedList.mockReturnValue(
listResult([
{
id: 1,
category: "announcement",
title: "Server maintenance",
body: "Down at midnight",
link: null,
read_at: null,
created_at: "2026-06-11T10:00:00Z",
},
{
id: 2,
category: "request",
title: "Request approved",
body: "Dune 3 is on the way",
link: "/requests/2",
read_at: null,
created_at: "2026-06-11T09:00:00Z",
},
]),
);
renderStrip();
expect(screen.getByText("Request approved")).toBeInTheDocument();
expect(screen.queryByText("Server maintenance")).not.toBeInTheDocument();
});
it("dismiss button calls markRead.mutate with the item id", async () => {
mockedList.mockReturnValue(
listResult([
{
id: 2,
category: "request",
title: "Request approved",
body: "Dune 3 is on the way",
link: "/requests/2",
read_at: null,
created_at: "2026-06-11T09:00:00Z",
},
]),
);
const user = userEvent.setup();
renderStrip();
await user.click(screen.getByRole("button", { name: "Dismiss" }));
expect(mockMarkReadMutate).toHaveBeenCalledWith({ ids: [2] });
});
it("renders nothing when the only unread is an announcement", () => {
mockedList.mockReturnValue(
listResult([
{
id: 1,
category: "announcement",
title: "Server maintenance",
body: "Down at midnight",
link: null,
read_at: null,
created_at: "2026-06-11T10:00:00Z",
},
]),
);
const { container } = renderStrip();
expect(container).toBeEmptyDOMElement();
});
it("renders nothing when there are no unread notifications", () => {
mockedList.mockReturnValue(listResult([]));
const { container } = renderStrip();
expect(container).toBeEmptyDOMElement();
});
it("shifts the dismiss button with lg:mr-10 when reserveActivityWidget is set", () => {
mockedList.mockReturnValue(
listResult([
{
id: 2,
category: "request",
title: "Request approved",
body: "Dune 3 is on the way",
link: "/requests/2",
read_at: null,
created_at: "2026-06-11T09:00:00Z",
},
]),
);
renderStrip({ reserveActivityWidget: true });
expect(screen.getByRole("button", { name: "Dismiss" })).toHaveClass("lg:mr-10");
});
});
+34
View File
@@ -0,0 +1,34 @@
import { Bell } from "lucide-react";
import { NotificationStripBar } from "@/components/NotificationStripBar";
import { useMarkRead, useNotificationsList } from "@/hooks/queries/notifications";
interface NotificationStripProps {
/**
* When true, shift the dismiss button left on large screens so it clears the
* admin-only ServerActivity widget pinned at `fixed top-5 right-5`.
*/
reserveActivityWidget?: boolean;
}
export function NotificationStrip({ reserveActivityWidget }: NotificationStripProps = {}) {
// Always refetch on home mount so newly pushed notifications appear without a
// manual reload (matches AnnouncementBar).
const list = useNotificationsList({ unread: true }, { staleTime: 0, refetchOnMount: "always" });
const markRead = useMarkRead();
const items = (list.data?.pages ?? []).flatMap((p) => p.items ?? []);
const item = items.find((n) => n.category !== "announcement");
if (!item) return null;
return (
<NotificationStripBar
item={item}
icon={Bell}
variant="default"
reserveActivityWidget={reserveActivityWidget}
onDismiss={(id) => markRead.mutate({ ids: [id] })}
/>
);
}
@@ -0,0 +1,85 @@
import type { LucideIcon } from "lucide-react";
import { X } from "lucide-react";
import { Link } from "react-router";
import { Button } from "@/components/ui/button";
import { notificationTimeAgo } from "@/lib/notifications";
import type { AppNotification } from "@/api/types";
interface NotificationStripBarProps {
/** The notification to render in the bar. */
item: AppNotification;
/** Leading icon for the bar. */
icon: LucideIcon;
/** Optional eyebrow label rendered before the title (e.g. "ANNOUNCEMENT"). */
label?: string;
/**
* Visual treatment. "announcement" uses the primary accent styling; "default"
* uses the plain surface styling.
*/
variant: "announcement" | "default";
/**
* When true, shift the dismiss button left on large screens so it clears the
* admin-only ServerActivity widget pinned at `fixed top-5 right-5` (which
* floats over the top-right corner of the page content).
*/
reserveActivityWidget?: boolean;
/** Called when the bar is opened (Link click) or dismissed (✕ click). */
onDismiss: (id: number) => void;
}
export function NotificationStripBar({
item,
icon: Icon,
label,
variant,
reserveActivityWidget,
onDismiss,
}: NotificationStripBarProps) {
const when =
notificationTimeAgo(item.created_at) ?? new Date(item.created_at).toLocaleDateString();
const isAnnouncement = variant === "announcement";
const containerClass = isAnnouncement
? "border-primary/30 bg-primary/10 text-primary"
: "border-border bg-surface";
const iconClass = isAnnouncement ? "h-4 w-4 shrink-0" : "text-muted-foreground h-4 w-4 shrink-0";
const dismissClass = isAnnouncement
? `text-primary hover:text-primary ${reserveActivityWidget ? "lg:mr-10" : ""}`
: reserveActivityWidget
? "lg:mr-10"
: undefined;
const titleClass = isAnnouncement
? "text-foreground shrink-0 font-medium"
: "shrink-0 font-medium";
return (
<div className={`${containerClass} flex items-center gap-3 rounded-lg border px-4 py-2`}>
<Link
to={item.link ?? "/notifications"}
onClick={() => onDismiss(item.id)}
className="flex min-w-0 flex-1 items-center gap-3"
>
<Icon className={iconClass} />
{label && <span className="shrink-0 text-xs font-semibold tracking-wide">{label}</span>}
<span className={titleClass}>{item.title}</span>
{item.body && (
<span className="text-muted-foreground min-w-0 flex-1 truncate text-xs">{item.body}</span>
)}
{when && <span className="text-muted-foreground shrink-0 text-xs">{when}</span>}
</Link>
<Button
variant="ghost"
size="icon-xs"
aria-label="Dismiss"
className={dismissClass}
onClick={(e) => {
e.stopPropagation();
onDismiss(item.id);
}}
>
<X className="h-3 w-3" />
</Button>
</div>
);
}
+9
View File
@@ -313,6 +313,15 @@ export const themeKeys = {
catalogIndex: () => ["theme", "catalog"] as const,
};
export const notificationKeys = {
all: ["notifications"] as const,
list: (filters: { unread?: boolean; category?: string }) =>
[...notificationKeys.all, "list", filters] as const,
unreadCount: () => [...notificationKeys.all, "unread-count"] as const,
preferences: () => [...notificationKeys.all, "preferences"] as const,
announcements: () => ["admin", "announcements"] as const,
};
export const adminKeys = {
users: () => ["admin", "users"] as const,
userDetail: (userId: number) => ["admin", "users", userId] as const,
+175
View File
@@ -0,0 +1,175 @@
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/api/client";
import type { Announcement, NotificationListResponse, NotificationPreference } from "@/api/types";
import { notificationKeys } from "./keys";
import { toast } from "sonner";
const PAGE_SIZE = 25;
interface AnnouncementsResponse {
items: Announcement[];
}
interface NotificationPreferencesResponse {
preferences: NotificationPreference[];
}
interface UnreadCountResponse {
count: number;
}
export function useNotificationsList(
filters: { unread?: boolean; category?: string } = {},
options?: { staleTime?: number; refetchOnMount?: boolean | "always" },
) {
return useInfiniteQuery({
queryKey: notificationKeys.list(filters),
queryFn: ({ pageParam }: { pageParam: number }) => {
const params = new URLSearchParams();
params.set("limit", String(PAGE_SIZE));
if (filters.unread) params.set("unread", "1");
if (filters.category) params.set("category", filters.category);
if (pageParam) params.set("cursor", String(pageParam));
return api<NotificationListResponse>(`/notifications?${params}`);
},
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
staleTime: options?.staleTime ?? 2 * 60 * 1000,
refetchOnMount: options?.refetchOnMount ?? true,
});
}
export function useUnreadCount() {
return useQuery({
queryKey: notificationKeys.unreadCount(),
queryFn: () => api<UnreadCountResponse>("/notifications/unread-count"),
select: (d) => d.count,
});
}
export function useMarkRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: { ids?: number[]; all?: boolean }) =>
api("/notifications/read", {
method: "POST",
body: JSON.stringify(body),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: notificationKeys.all });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to mark read");
},
});
}
export function useDismissNotification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
api(`/notifications/${id}/dismiss`, {
method: "POST",
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: notificationKeys.all });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to dismiss notification");
},
});
}
export function useNotificationPreferences() {
return useQuery({
queryKey: notificationKeys.preferences(),
queryFn: () => api<NotificationPreferencesResponse>("/notifications/preferences"),
select: (d) => d.preferences,
});
}
export function useSetNotificationPreferences() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: { preferences: NotificationPreference[] }) =>
api("/notifications/preferences", {
method: "PUT",
body: JSON.stringify(body),
}),
onMutate: async (body) => {
await queryClient.cancelQueries({ queryKey: notificationKeys.preferences() });
const previous = queryClient.getQueryData(notificationKeys.preferences());
queryClient.setQueryData(
notificationKeys.preferences(),
(old: { preferences: NotificationPreference[] } | undefined) => {
if (!old) return old;
return {
preferences: old.preferences.map(
(p) => body.preferences.find((b) => b.category === p.category) ?? p,
),
};
},
);
return { previous };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: notificationKeys.preferences() });
},
onError: (err, _variables, context) => {
if (context?.previous)
queryClient.setQueryData(notificationKeys.preferences(), context.previous);
toast.error(err instanceof Error ? err.message : "Failed to save preferences");
},
});
}
export function useAnnouncements() {
return useQuery({
queryKey: notificationKeys.announcements(),
queryFn: () => api<AnnouncementsResponse>("/admin/announcements"),
select: (d) => d.items ?? [],
});
}
export function useCreateAnnouncement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: Omit<Announcement, "id" | "created_at" | "created_by">) =>
api<Announcement>("/admin/announcements", {
method: "POST",
body: JSON.stringify(body),
}),
onSuccess: () => {
toast.success("Announcement published");
// The admin announcements list lives under a separate ["admin",...] key,
// so invalidate it explicitly. Also invalidate the user-facing
// notification tree so the publisher's own home bar/inbox/bell/count
// refresh without a manual reload.
queryClient.invalidateQueries({ queryKey: notificationKeys.announcements() });
queryClient.invalidateQueries({ queryKey: notificationKeys.all });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to publish announcement");
},
});
}
export function useDeleteAnnouncement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
api(`/admin/announcements/${id}`, {
method: "DELETE",
}),
onSuccess: () => {
toast.success("Announcement deleted");
// Clear it from the admin list (separate ["admin",...] key) and every
// user-facing notification view.
queryClient.invalidateQueries({ queryKey: notificationKeys.announcements() });
queryClient.invalidateQueries({ queryKey: notificationKeys.all });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to delete announcement");
},
});
}
+5
View File
@@ -338,6 +338,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setProfileToken(profileToken ?? null);
storage.set(storage.KEYS.CURRENT_PROFILE, JSON.stringify(p));
setProfile(p);
// Switching profiles changes the active-profile scope for every
// profile-scoped query (notifications, home, watchlist, ...). Drop the
// cache so the new profile fetches its own data instead of showing the
// previous profile's cached results (mirrors logout's queryClient.clear()).
queryClient.clear();
}, []);
useEffect(() => {
+164
View File
@@ -0,0 +1,164 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
const { capturedHandlers, toastMock, profileState, bellState } = vi.hoisted(() => ({
capturedHandlers: {} as { onEvent?: (m: unknown) => void; onSnapshot?: (m: unknown) => void },
toastMock: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
profileState: {
profile: { id: "p-1", is_child: false } as { id: string; is_child: boolean } | null,
},
bellState: { open: false },
}));
vi.mock("@/components/NotificationBell", () => ({
isNotificationDropdownOpen: () => bellState.open,
setNotificationDropdownOpenForTests: (v: boolean) => {
bellState.open = v;
},
}));
vi.mock("@/components/realtimeEventsContext", () => ({
useEventChannel: (
_c: string,
handlers?: { onEvent?: (m: unknown) => void; onSnapshot?: (m: unknown) => void },
) => {
capturedHandlers.onEvent = handlers?.onEvent;
capturedHandlers.onSnapshot = handlers?.onSnapshot;
},
}));
vi.mock("sonner", () => ({ toast: toastMock }));
vi.mock("@/hooks/useAuth", () => ({ useAuth: () => profileState }));
import { notificationKeys } from "@/hooks/queries/keys";
import { useNotificationsLive } from "./useNotificationsLive";
function setup(initialCount = 1) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
queryClient.setQueryData(notificationKeys.unreadCount(), { count: initialCount });
renderHook(() => useNotificationsLive(), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
});
return queryClient;
}
const frame = (data: object) => ({
type: "event",
channel: "notifications",
event: "notification.created",
data,
});
beforeEach(() => {
toastMock.mockClear();
profileState.profile = { id: "p-1", is_child: false };
bellState.open = false;
});
describe("useNotificationsLive", () => {
it("toasts and bumps unread count on notification.created", () => {
const qc = setup(1);
capturedHandlers.onEvent?.(
frame({
id: 5,
category: "request",
title: "Request approved",
body: "Dune",
profile_id: "p-1",
}),
);
expect(toastMock).toHaveBeenCalledWith(
"Request approved",
expect.objectContaining({ description: "Dune" }),
);
expect(qc.getQueryData(notificationKeys.unreadCount())).toEqual({ count: 2 });
});
it("counts user-wide frames (no profile_id)", () => {
const qc = setup(0);
capturedHandlers.onEvent?.(
frame({ id: 8, category: "announcement", title: "Maintenance", body: "" }),
);
expect(qc.getQueryData(notificationKeys.unreadCount())).toEqual({ count: 1 });
});
it("ignores frames for another profile", () => {
const qc = setup(1);
capturedHandlers.onEvent?.(
frame({ id: 6, category: "content", title: "New content", body: "", profile_id: "p-other" }),
);
expect(toastMock).not.toHaveBeenCalled();
expect(qc.getQueryData(notificationKeys.unreadCount())).toEqual({ count: 1 });
});
it("suppresses restricted categories on child profiles", () => {
profileState.profile = { id: "p-1", is_child: true };
setup(0);
capturedHandlers.onEvent?.(
frame({ id: 7, category: "request", title: "Request approved", body: "" }),
);
expect(toastMock).not.toHaveBeenCalled();
});
it("ignores non-created events", () => {
setup(0);
capturedHandlers.onEvent?.({
type: "event",
channel: "notifications",
event: "something.else",
data: { id: 1 },
});
expect(toastMock).not.toHaveBeenCalled();
});
it("refetches active notification lists when an announcement arrives", () => {
const qc = setup(0);
const spy = vi.spyOn(qc, "invalidateQueries");
capturedHandlers.onEvent?.(
frame({
id: 11,
category: "announcement",
title: "Maintenance",
body: "",
profile_id: "p-1",
}),
);
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ refetchType: "active" }));
});
it("only marks lists stale (no refetch) for routine notifications", () => {
const qc = setup(0);
const spy = vi.spyOn(qc, "invalidateQueries");
capturedHandlers.onEvent?.(
frame({ id: 12, category: "request", title: "Approved", body: "", profile_id: "p-1" }),
);
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ refetchType: "none" }));
});
it("seeds unread count from snapshot", () => {
const qc = setup(1);
capturedHandlers.onSnapshot?.({
type: "snapshot",
channel: "notifications",
data: { unread_count: 9 },
});
expect(qc.getQueryData(notificationKeys.unreadCount())).toEqual({ count: 9 });
});
it("suppresses toast but still bumps unread count while bell dropdown is open", () => {
bellState.open = true;
const qc = setup(1);
capturedHandlers.onEvent?.(
frame({
id: 10,
category: "content",
title: "New Release",
body: "Arrival",
profile_id: "p-1",
}),
);
expect(toastMock).not.toHaveBeenCalled();
expect(qc.getQueryData(notificationKeys.unreadCount())).toEqual({ count: 2 });
});
});
+59
View File
@@ -0,0 +1,59 @@
import { useMemo } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import type { AppNotification, EventsEventMessage, EventsSnapshotMessage } from "@/api/types";
import { useEventChannel } from "@/components/realtimeEventsContext";
import { isNotificationDropdownOpen } from "@/components/NotificationBell";
import { notificationKeys } from "@/hooks/queries/keys";
import { useAuth } from "@/hooks/useAuth";
const CHILD_HIDDEN_CATEGORIES = new Set(["request", "system", "admin"]);
/**
* Subscribes to the notifications WS channel: fires a toast for live
* notifications addressed to the active profile, keeps the unread-count
* cache current, and seeds it from the subscribe snapshot.
* Mount exactly once inside the authenticated layout.
*/
export function useNotificationsLive() {
const queryClient = useQueryClient();
const { profile } = useAuth();
const profileId = profile?.id;
const isChild = profile?.is_child ?? false;
const handlers = useMemo(
() => ({
onEvent: (message: unknown) => {
const f = message as EventsEventMessage<AppNotification>;
if (f.event !== "notification.created" || !f.data) return;
const n = f.data;
if (n.profile_id && n.profile_id !== profileId) return;
if (isChild && CHILD_HIDDEN_CATEGORIES.has(n.category)) return;
queryClient.setQueryData<{ count: number }>(notificationKeys.unreadCount(), (prev) => ({
count: (prev?.count ?? 0) + 1,
}));
// Announcements are system-wide notices we want surfaced immediately, so
// refetch the active notification lists (home bar, inbox, bell) the
// moment one arrives. Routine notifications only mark the lists stale to
// avoid disrupting a list the user is currently reading.
void queryClient.invalidateQueries({
queryKey: [...notificationKeys.all, "list"],
refetchType: n.category === "announcement" ? "active" : "none",
});
if (!isNotificationDropdownOpen()) {
toast(n.title, { description: n.body || undefined });
}
},
onSnapshot: (message: unknown) => {
const f = message as EventsSnapshotMessage<{ unread_count?: number }>;
if (typeof f.data?.unread_count !== "number") return;
queryClient.setQueryData(notificationKeys.unreadCount(), { count: f.data.unread_count });
},
}),
[queryClient, profileId, isChild],
);
useEventChannel("notifications", handlers);
}
+23
View File
@@ -0,0 +1,23 @@
import { timeAgo } from "@/lib/timeAgo";
/**
* Relative time for the notifications UI, without the shared util's "Added "
* prefix (e.g. "1 minute ago" instead of "Added 1 minute ago"). Returns null
* for dates the shared util can't format (invalid or older than 30 days).
*/
export function notificationTimeAgo(iso: string): string | null {
const formatted = timeAgo(iso);
if (formatted === null) return null;
return formatted.replace(/^Added /, "");
}
/**
* Returns the given items with announcements first, then the rest, preserving
* the original relative order within each group (stable partition).
*/
export function partitionAnnouncementsFirst<T extends { category?: string }>(items: T[]): T[] {
return [
...items.filter((n) => n.category === "announcement"),
...items.filter((n) => n.category !== "announcement"),
];
}
+232
View File
@@ -0,0 +1,232 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Announcement } from "@/api/types";
// Radix ScrollArea requires ResizeObserver — polyfill for jsdom
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
// ─── Mock data ───────────────────────────────────────────────────────────────
const ANNOUNCEMENTS: Announcement[] = [
{
id: 1,
title: "Welcome",
body: "Hello everyone",
audience: { all: true },
created_at: "2026-01-01T00:00:00Z",
},
{
id: 2,
title: "User notice",
body: "For some users",
audience: { user_ids: [10, 20] },
created_at: "2026-01-02T00:00:00Z",
},
{
id: 3,
title: "Library alert",
body: "For Movies lib",
audience: { library_ids: [5] },
created_at: "2026-01-03T00:00:00Z",
},
];
// ─── Hook mocks ───────────────────────────────────────────────────────────────
const createAnnouncementMutateMock = vi.fn();
const deleteAnnouncementMutateMock = vi.fn();
const useAnnouncementsMock = vi.fn();
vi.mock("@/hooks/queries/notifications", () => ({
useAnnouncements: () => useAnnouncementsMock(),
useCreateAnnouncement: () => ({
mutate: createAnnouncementMutateMock,
isPending: false,
}),
useDeleteAnnouncement: () => ({
mutate: deleteAnnouncementMutateMock,
isPending: false,
}),
}));
vi.mock("@/hooks/queries/admin/users", () => ({
useAdminUsers: () => ({
data: [
{ id: 10, username: "alice", email: "alice@example.com" },
{ id: 20, username: "bob", email: "bob@example.com" },
],
}),
}));
vi.mock("@/hooks/queries/admin/libraries", () => ({
useAdminLibraries: () => ({
data: [
{ id: 5, name: "Movies" },
{ id: 6, name: "TV Shows" },
],
}),
}));
// ─── Helpers ─────────────────────────────────────────────────────────────────
function makeQueryClient() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}
// We import AdminAnnouncements after mocks are declared (vi.mock is hoisted)
import AdminAnnouncements from "./AdminAnnouncements";
function renderPage() {
const qc = makeQueryClient();
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<AdminAnnouncements />
</MemoryRouter>
</QueryClientProvider>,
);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("AdminAnnouncements", () => {
beforeEach(() => {
createAnnouncementMutateMock.mockReset();
deleteAnnouncementMutateMock.mockReset();
useAnnouncementsMock.mockReturnValue({
data: ANNOUNCEMENTS,
isLoading: false,
});
});
// ── 1. Table renders rows with audience summaries ──────────────────────────
it("renders rows with audience summaries: Everyone, N user(s), Libraries: names", () => {
renderPage();
expect(screen.getByText("Welcome")).toBeInTheDocument();
expect(screen.getByText("User notice")).toBeInTheDocument();
expect(screen.getByText("Library alert")).toBeInTheDocument();
expect(screen.getByText("Everyone")).toBeInTheDocument();
expect(screen.getByText("2 user(s)")).toBeInTheDocument();
expect(screen.getByText("Libraries: Movies")).toBeInTheDocument();
});
// ── 2a. Submit disabled with empty title ───────────────────────────────────
it("Publish button is disabled when title is empty", async () => {
const user = userEvent.setup();
renderPage();
await user.click(screen.getByRole("button", { name: /new announcement/i }));
const publishBtn = screen.getByRole("button", { name: /publish/i });
expect(publishBtn).toBeDisabled();
});
// ── 2b. Submit disabled when Specific users selected but none checked ───────
it("Publish button is disabled when Specific users mode has no users checked", async () => {
const user = userEvent.setup();
renderPage();
await user.click(screen.getByRole("button", { name: /new announcement/i }));
// Type a title so that title validation passes
await user.type(screen.getByLabelText(/title/i), "Test announcement");
// Select "Specific users" radio
await user.click(screen.getByRole("radio", { name: /specific users/i }));
// No users are checked → submit should still be disabled
const publishBtn = screen.getByRole("button", { name: /publish/i });
expect(publishBtn).toBeDisabled();
});
// ── 3. Submitting Everyone announcement calls create with correct payload ───
it("submitting an Everyone announcement calls create mutate with audience:{all:true} and no expires_at key", async () => {
const user = userEvent.setup();
renderPage();
await user.click(screen.getByRole("button", { name: /new announcement/i }));
await user.type(screen.getByLabelText(/title/i), "Hello");
await user.type(screen.getByLabelText(/body/i), "World");
// audience mode is Everyone by default
await user.click(screen.getByRole("button", { name: /publish/i }));
expect(createAnnouncementMutateMock).toHaveBeenCalledOnce();
const [payload] = createAnnouncementMutateMock.mock.calls[0] as [
Record<string, unknown>,
...unknown[],
];
expect(payload.title).toBe("Hello");
expect(payload.body).toBe("World");
expect(payload.audience).toEqual({ all: true });
expect(Object.keys(payload)).not.toContain("expires_at");
});
// ── 4. Libraries mode → audience:{library_ids:[id]} only ──────────────────
it("selecting Libraries mode and one library produces audience:{library_ids:[id]} only", async () => {
const user = userEvent.setup();
renderPage();
await user.click(screen.getByRole("button", { name: /new announcement/i }));
await user.type(screen.getByLabelText(/title/i), "Lib notice");
await user.click(screen.getByRole("radio", { name: /libraries/i }));
// Check "Movies" checkbox
await user.click(screen.getByRole("checkbox", { name: /movies/i }));
await user.click(screen.getByRole("button", { name: /publish/i }));
expect(createAnnouncementMutateMock).toHaveBeenCalledOnce();
const [payload] = createAnnouncementMutateMock.mock.calls[0] as [
Record<string, unknown>,
...unknown[],
];
const audience = payload.audience as Record<string, unknown>;
expect(audience).toEqual({ library_ids: [5] });
expect(Object.keys(audience)).not.toContain("all");
expect(Object.keys(audience)).not.toContain("user_ids");
});
// ── 5. Delete confirm calls delete mutate with the row id ─────────────────
it("clicking delete then confirming calls useDeleteAnnouncement.mutate with the announcement id", async () => {
const user = userEvent.setup();
renderPage();
// Click delete button on the first row (id=1 "Welcome")
const deleteBtn = screen.getByRole("button", { name: /delete announcement "Welcome"/i });
await user.click(deleteBtn);
// Confirm dialog should appear — click the destructive confirm button
// The ConfirmDialog renders an AlertDialog; find the "Delete" action button
const confirmBtn = screen.getByRole("button", { name: /^delete$/i });
await user.click(confirmBtn);
expect(deleteAnnouncementMutateMock).toHaveBeenCalledWith(1);
});
// ── 6. Empty state renders when no announcements ───────────────────────────
it("shows an empty state message when there are no announcements", () => {
useAnnouncementsMock.mockReturnValue({ data: [], isLoading: false });
renderPage();
expect(screen.getByText(/no announcements yet/i)).toBeInTheDocument();
});
});
+388
View File
@@ -0,0 +1,388 @@
import { useState } from "react";
import type { FormEvent } from "react";
import {
useAnnouncements,
useCreateAnnouncement,
useDeleteAnnouncement,
} from "@/hooks/queries/notifications";
import { useAdminUsers } from "@/hooks/queries/admin/users";
import { useAdminLibraries } from "@/hooks/queries/admin/libraries";
import type { AnnouncementAudience } from "@/api/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { Skeleton } from "@/components/ui/skeleton";
import { Megaphone, Plus, Trash2 } from "lucide-react";
// ─── Date helpers (same pattern as AdminUsers) ────────────────────────────────
function parseTime(value?: string | null): number | null {
const ts = Date.parse(value ?? "");
return Number.isNaN(ts) ? null : ts;
}
function formatDateTime(value?: string | null, fallback = "-"): string {
const ts = parseTime(value);
if (ts === null) return fallback;
return new Date(ts).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
// ─── Audience summary ─────────────────────────────────────────────────────────
type AudienceMode = "all" | "users" | "libraries";
function audienceSummary(
audience: AnnouncementAudience,
libraryNames: Map<number, string>,
): string {
if (audience.all) return "Everyone";
if (audience.user_ids && audience.user_ids.length > 0) {
return `${audience.user_ids.length} user(s)`;
}
if (audience.library_ids && audience.library_ids.length > 0) {
const names = audience.library_ids.map((id) => libraryNames.get(id) ?? String(id));
return `Libraries: ${names.join(", ")}`;
}
return "—";
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function AdminAnnouncements() {
const { data: announcements = [], isLoading } = useAnnouncements();
const { data: libraries = [] } = useAdminLibraries();
const deleteMutation = useDeleteAnnouncement();
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const libraryNames = new Map(libraries.map((l) => [l.id, l.name]));
if (isLoading) {
return (
<div className="space-y-3">
<Skeleton className="h-10 w-full rounded-lg" />
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
);
}
return (
<div className="space-y-6">
<ConfirmDialog
open={confirmDeleteId !== null}
onOpenChange={(open) => {
if (!open) setConfirmDeleteId(null);
}}
title="Delete announcement?"
description="Deleting also dismisses unread copies from user inboxes."
confirmLabel="Delete"
variant="destructive"
onConfirm={() => {
if (confirmDeleteId !== null) deleteMutation.mutate(confirmDeleteId);
setConfirmDeleteId(null);
}}
/>
<div className="page-header">
<div className="space-y-3">
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Announcements</h1>
<p className="page-subtitle text-sm sm:text-base">
Send a message to your users — for downtime, new features, or anything they should know.
It appears in a highlighted bar at the top of their home page and in their notification
inbox, and stays until they dismiss it (or it expires).
</p>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="mr-1 h-4 w-4" /> New announcement
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>New announcement</DialogTitle>
</DialogHeader>
<CreateAnnouncementForm onClose={() => setCreateOpen(false)} />
</DialogContent>
</Dialog>
</div>
{announcements.length === 0 ? (
<div className="text-muted-foreground flex flex-col items-center gap-3 py-16">
<Megaphone className="h-10 w-10 opacity-40" />
<p className="text-sm">No announcements yet.</p>
<p className="text-xs opacity-60">
Create an announcement to broadcast a message to users.
</p>
</div>
) : (
<div className="surface-panel overflow-x-auto rounded-2xl border-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Audience</TableHead>
<TableHead>Created</TableHead>
<TableHead>Expires</TableHead>
<TableHead className="w-20">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{announcements.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">{a.title}</TableCell>
<TableCell>{audienceSummary(a.audience, libraryNames)}</TableCell>
<TableCell title={a.created_at}>{formatDateTime(a.created_at)}</TableCell>
<TableCell>{a.expires_at ? formatDateTime(a.expires_at) : "—"}</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label={`Delete announcement "${a.title}"`}
onClick={() => setConfirmDeleteId(a.id)}
>
<Trash2 className="h-3 w-3" aria-hidden="true" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
);
}
// ─── Create form ──────────────────────────────────────────────────────────────
function CreateAnnouncementForm({ onClose }: { onClose: () => void }) {
const { data: users = [] } = useAdminUsers();
const { data: libraries = [] } = useAdminLibraries();
const createMutation = useCreateAnnouncement();
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [mode, setMode] = useState<AudienceMode>("all");
const [selectedUserIds, setSelectedUserIds] = useState<Set<number>>(new Set());
const [selectedLibraryIds, setSelectedLibraryIds] = useState<Set<number>>(new Set());
const [expiresAt, setExpiresAt] = useState("");
const titleId = "ann-title";
const bodyId = "ann-body";
const expiresId = "ann-expires";
const isValid =
title.trim().length > 0 &&
(mode === "all" ||
(mode === "users" && selectedUserIds.size > 0) ||
(mode === "libraries" && selectedLibraryIds.size > 0));
function buildAudience(): AnnouncementAudience {
if (mode === "all") return { all: true };
if (mode === "users") return { user_ids: [...selectedUserIds] };
return { library_ids: [...selectedLibraryIds] };
}
function toggleUser(id: number) {
setSelectedUserIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function toggleLibrary(id: number) {
setSelectedLibraryIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!isValid) return;
const payload: {
title: string;
body: string;
audience: AnnouncementAudience;
expires_at?: string;
} = {
title: title.trim(),
body,
audience: buildAudience(),
};
if (expiresAt) {
payload.expires_at = new Date(expiresAt).toISOString();
}
createMutation.mutate(payload as Parameters<typeof createMutation.mutate>[0], {
onSuccess: onClose,
});
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Title */}
<div className="space-y-1.5">
<Label htmlFor={titleId}>Title *</Label>
<Input
id={titleId}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Scheduled maintenance Sunday night"
required
/>
<p className="text-muted-foreground text-xs">
Shown in bold at the top of the announcement.
</p>
</div>
{/* Body */}
<div className="space-y-1.5">
<Label htmlFor={bodyId}>Body</Label>
<textarea
id={bodyId}
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Optional details, e.g. Silo will be offline 1–2am while we upgrade."
rows={3}
className="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm shadow-sm focus-visible:ring-1 focus-visible:outline-none"
/>
<p className="text-muted-foreground text-xs">Optional. Plain text shown under the title.</p>
</div>
{/* Audience */}
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Who sees it?</legend>
<div className="space-y-1.5">
{(
[
{ value: "all", label: "Everyone", help: "Every user on this server." },
{ value: "users", label: "Specific users", help: "Only the people you pick below." },
{
value: "libraries",
label: "Libraries",
help: "Users with access to the chosen libraries.",
},
] as { value: AudienceMode; label: string; help: string }[]
).map(({ value, label, help }) => (
<label key={value} className="flex cursor-pointer items-start gap-2 text-sm">
<input
type="radio"
name="audience-mode"
value={value}
checked={mode === value}
onChange={() => setMode(value)}
className="accent-primary mt-0.5"
/>
<span>
{label}
<span className="text-muted-foreground block text-xs">{help}</span>
</span>
</label>
))}
</div>
{/* User list */}
{mode === "users" && (
<div className="border-input max-h-40 overflow-y-auto rounded-md border p-2">
<div className="space-y-1">
{users.map((u) => (
<label key={u.id} className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
checked={selectedUserIds.has(u.id)}
onChange={() => toggleUser(u.id)}
className="accent-primary"
/>
<span>
{u.username}
{u.email ? ` (${u.email})` : ""}
</span>
</label>
))}
{users.length === 0 && (
<p className="text-muted-foreground text-xs">No users found.</p>
)}
</div>
</div>
)}
{/* Library list */}
{mode === "libraries" && (
<div className="border-input max-h-40 overflow-y-auto rounded-md border p-2">
<div className="space-y-1">
{libraries.map((lib) => (
<label key={lib.id} className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
checked={selectedLibraryIds.has(lib.id)}
onChange={() => toggleLibrary(lib.id)}
className="accent-primary"
/>
{lib.name}
</label>
))}
{libraries.length === 0 && (
<p className="text-muted-foreground text-xs">No libraries found.</p>
)}
</div>
</div>
)}
</fieldset>
{/* Expires */}
<div className="space-y-1.5">
<Label htmlFor={expiresId}>Expires (optional)</Label>
<Input
id={expiresId}
type="datetime-local"
value={expiresAt}
onChange={(e) => setExpiresAt(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
After this time the announcement disappears from inboxes automatically. Leave blank to
keep it until each user dismisses it.
</p>
</div>
{/* Submit */}
<div className="border-border border-t pt-4">
<Button type="submit" className="w-full" disabled={!isValid || createMutation.isPending}>
{createMutation.isPending ? "Publishing…" : "Publish"}
</Button>
</div>
</form>
);
}
+4
View File
@@ -23,6 +23,10 @@ vi.mock("@/hooks/useDocumentTitle", () => ({
useDocumentTitle: vi.fn(),
}));
vi.mock("@/hooks/useAuth", () => ({
useAuth: () => ({ user: { role: "user" } }),
}));
vi.mock("react-router", () => ({
Link: ({ children }: { children: ReactNode }) => <a>{children}</a>,
}));
+7
View File
@@ -3,11 +3,14 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "react-router";
import { LayoutDashboard } from "lucide-react";
import { AnnouncementBar } from "@/components/AnnouncementBar";
import HeroBanner from "@/components/HeroBanner";
import { NotificationStrip } from "@/components/NotificationStrip";
import SectionRow from "@/components/SectionRow";
import TasteSeedBanner from "@/components/TasteSeedBanner";
import { Skeleton } from "@/components/ui/skeleton";
import type { HomeSectionItemsResponse, ResolvedSection } from "@/api/types";
import { useAuth } from "@/hooks/useAuth";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { HERO_BANNER_SIZE } from "@/lib/design-system";
import { sectionKeys } from "@/hooks/queries/keys";
@@ -32,6 +35,8 @@ function useHomeRefreshSignal() {
export default function Home() {
const queryClient = useQueryClient();
const { user } = useAuth();
const isAdmin = user?.role === "admin";
const { data, isLoading, isError, refetch } = useHomeLayout();
const { data: homeRefreshSignal = 0 } = useHomeRefreshSignal();
const [loadedSections, setLoadedSections] = useState<Map<string, ResolvedSection>>(new Map());
@@ -194,6 +199,8 @@ export default function Home() {
<>
<h1 className="sr-only">Home</h1>
<div className={`space-y-10 ${hasHeroSlot ? "pb-2" : "pt-6 pb-2"}`}>
<AnnouncementBar reserveActivityWidget={isAdmin} />
<NotificationStrip reserveActivityWidget={isAdmin} />
{heroSlot}
<TasteSeedBanner />
+361
View File
@@ -0,0 +1,361 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
// ---------------------------------------------------------------------------
// Hoisted mutable mock state
// ---------------------------------------------------------------------------
const mocks = vi.hoisted(() => ({
listPages: [] as Array<{
items: Array<{
id: number;
title: string;
body: string;
category: string;
created_at: string;
read_at?: string;
link?: string;
}>;
}>,
hasNextPage: false,
fetchNextPage: vi.fn(),
markReadMutate: vi.fn(),
dismissMutate: vi.fn(),
unreadCount: 0,
listFilters: {} as { unread?: boolean; category?: string },
userRole: "user" as string,
}));
vi.mock("@/hooks/queries/notifications", () => ({
useNotificationsList: (filters: { unread?: boolean; category?: string } = {}) => {
mocks.listFilters = filters;
return {
data: { pages: mocks.listPages },
hasNextPage: mocks.hasNextPage,
fetchNextPage: mocks.fetchNextPage,
isLoading: false,
};
},
useMarkRead: () => ({
mutate: mocks.markReadMutate,
isPending: false,
}),
useDismissNotification: () => ({
mutate: mocks.dismissMutate,
isPending: false,
}),
useUnreadCount: () => ({ data: mocks.unreadCount }),
}));
vi.mock("@/hooks/useAuth", () => ({
useAuth: () => ({
user: { id: 1, username: "alice", role: mocks.userRole },
}),
}));
vi.mock("@/hooks/useDocumentTitle", () => ({
useDocumentTitle: () => undefined,
}));
// ---------------------------------------------------------------------------
import Notifications from "./Notifications";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeNotification(
id: number,
overrides: Partial<{
title: string;
body: string;
category: string;
read_at: string;
link: string;
created_at: string;
}> = {},
) {
return {
id,
user_id: 1,
category: "system" as const,
type: "test",
title: `Title ${id}`,
body: `Body ${id}`,
created_at: new Date(Date.now() - 60_000).toISOString(),
...overrides,
};
}
async function mount(root: Root, container: HTMLElement, ui: ReactNode) {
await act(async () => {
root.render(ui);
await Promise.resolve();
});
return container;
}
function wrap(ui: ReactNode) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return (
<QueryClientProvider client={queryClient}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>
);
}
// ---------------------------------------------------------------------------
describe("Notifications page", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
mocks.listPages = [];
mocks.hasNextPage = false;
mocks.fetchNextPage.mockReset();
mocks.markReadMutate.mockReset();
mocks.dismissMutate.mockReset();
mocks.unreadCount = 0;
mocks.listFilters = {};
mocks.userRole = "user";
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
});
// -------------------------------------------------------------------------
// Test 1: renders rows from two pages; unread row gets data-unread="true"
// -------------------------------------------------------------------------
it("renders rows with titles/bodies from two pages; unread row gets data-unread", async () => {
mocks.listPages = [
{ items: [makeNotification(1), makeNotification(2, { read_at: new Date().toISOString() })] },
{ items: [makeNotification(3)] },
];
const el = await mount(root, container, wrap(<Notifications />));
expect(el.textContent).toContain("Title 1");
expect(el.textContent).toContain("Body 1");
expect(el.textContent).toContain("Title 2");
expect(el.textContent).toContain("Title 3");
const unreadRows = el.querySelectorAll('[data-unread="true"]');
// Rows 1 and 3 are unread; row 2 has read_at set
expect(unreadRows.length).toBe(2);
});
it("pins announcements to the top of the All view", async () => {
mocks.listPages = [
{
items: [
makeNotification(5, { category: "request", title: "Request Title" }),
makeNotification(3, { category: "announcement", title: "Announcement Title" }),
],
},
];
const el = await mount(root, container, wrap(<Notifications />));
const rows = Array.from(el.querySelectorAll("li"));
const annIdx = rows.findIndex((r) => r.textContent?.includes("Announcement Title"));
const reqIdx = rows.findIndex((r) => r.textContent?.includes("Request Title"));
expect(annIdx).toBeGreaterThanOrEqual(0);
expect(reqIdx).toBeGreaterThanOrEqual(0);
// Announcement is pinned above the request even though it came later in the page.
expect(annIdx).toBeLessThan(reqIdx);
});
// -------------------------------------------------------------------------
// Test 2: admin tab hidden for non-admin; shown for admin
// -------------------------------------------------------------------------
it("hides Admin tab for non-admin users", async () => {
mocks.userRole = "user";
mocks.listPages = [{ items: [] }];
const el = await mount(root, container, wrap(<Notifications />));
const buttons = Array.from(el.querySelectorAll("button")).map((b) => b.textContent ?? "");
expect(buttons.some((t) => t.includes("Admin"))).toBe(false);
});
it("shows Admin tab for admin users", async () => {
mocks.userRole = "admin";
mocks.listPages = [{ items: [] }];
const el = await mount(root, container, wrap(<Notifications />));
const buttons = Array.from(el.querySelectorAll("button")).map((b) => b.textContent ?? "");
expect(buttons.some((t) => t.includes("Admin"))).toBe(true);
});
// -------------------------------------------------------------------------
// Test 3: switching to Requests tab calls useNotificationsList with {category:"request"}
// -------------------------------------------------------------------------
it("switching to Requests tab filters by category:request", async () => {
mocks.listPages = [{ items: [] }];
const el = await mount(root, container, wrap(<Notifications />));
// Default: no category filter
expect(mocks.listFilters.category).toBeUndefined();
// Find and click the Requests tab
const requestsBtn = Array.from(el.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Requests",
);
expect(requestsBtn).toBeDefined();
await act(async () => {
requestsBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
expect(mocks.listFilters.category).toBe("request");
});
// -------------------------------------------------------------------------
// Test 4: mark-on-view: called once with unread ids; re-render does not repeat
// -------------------------------------------------------------------------
it("calls markRead.mutate once with unread ids from page 1; ref guard prevents repeat", async () => {
const readAt = new Date().toISOString();
mocks.listPages = [
{
items: [
makeNotification(1), // unread
makeNotification(2, { read_at: readAt }), // already read
],
},
];
const el = await mount(root, container, wrap(<Notifications />));
expect(mocks.markReadMutate).toHaveBeenCalledTimes(1);
expect(mocks.markReadMutate).toHaveBeenCalledWith({ ids: [1] });
// Force a re-render by toggling a tab back and forth; mutate must NOT be called again
const allBtn = Array.from(el.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "All",
);
if (allBtn) {
await act(async () => {
allBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
}
expect(mocks.markReadMutate).toHaveBeenCalledTimes(1);
});
// -------------------------------------------------------------------------
// Test 5: dismiss button calls dismiss mutate with the row id
// -------------------------------------------------------------------------
it("dismiss button calls dismissMutate with the notification id", async () => {
mocks.listPages = [{ items: [makeNotification(42)] }];
const el = await mount(root, container, wrap(<Notifications />));
const dismissBtn = el.querySelector('[aria-label="Dismiss"]');
expect(dismissBtn).not.toBeNull();
await act(async () => {
dismissBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
expect(mocks.dismissMutate).toHaveBeenCalledWith(42);
});
// -------------------------------------------------------------------------
// Test 6: "Load more" visible when hasNextPage; calls fetchNextPage
// -------------------------------------------------------------------------
it("shows Load more button when hasNextPage and calls fetchNextPage", async () => {
mocks.hasNextPage = true;
mocks.listPages = [{ items: [makeNotification(1)] }];
const el = await mount(root, container, wrap(<Notifications />));
const loadMoreBtn = Array.from(el.querySelectorAll("button")).find((b) =>
b.textContent?.includes("Load more"),
);
expect(loadMoreBtn).toBeDefined();
await act(async () => {
loadMoreBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
expect(mocks.fetchNextPage).toHaveBeenCalledTimes(1);
});
// -------------------------------------------------------------------------
// Test 7: relative time displays without "Added" prefix
// -------------------------------------------------------------------------
it("shows relative time without Added prefix (e.g., '1 minute ago')", async () => {
const now = Date.now();
mocks.listPages = [
{
items: [makeNotification(1, { created_at: new Date(now - 60_000).toISOString() })],
},
];
const el = await mount(root, container, wrap(<Notifications />));
expect(el.textContent).toContain("1 minute ago");
expect(el.textContent).not.toContain("Added 1 minute ago");
});
// -------------------------------------------------------------------------
// Test 8: tab switch resets mark-on-view and marks unread in new tab
// -------------------------------------------------------------------------
it("resets mark-on-view per tab; marks unread when switching to Requests", async () => {
// All tab has notification 1 (unread)
mocks.listPages = [
{
items: [makeNotification(1)],
},
];
const el = await mount(root, container, wrap(<Notifications />));
// All tab: marks notification 1
expect(mocks.markReadMutate).toHaveBeenCalledTimes(1);
expect(mocks.markReadMutate).toHaveBeenCalledWith({ ids: [1] });
// Switch to Requests tab with a different unread notification (9)
mocks.listPages = [
{
items: [makeNotification(9)],
},
];
const requestsBtn = Array.from(el.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Requests",
);
expect(requestsBtn).toBeDefined();
await act(async () => {
requestsBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
// Requests tab: marks notification 9 (called a second time)
expect(mocks.markReadMutate).toHaveBeenCalledTimes(2);
expect(mocks.markReadMutate).toHaveBeenLastCalledWith({ ids: [9] });
});
});
+188
View File
@@ -0,0 +1,188 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
useNotificationsList,
useMarkRead,
useDismissNotification,
useUnreadCount,
} from "@/hooks/queries/notifications";
import { useAuth } from "@/hooks/useAuth";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { notificationTimeAgo, partitionAnnouncementsFirst } from "@/lib/notifications";
import type { AppNotification } from "@/api/types";
// ---------------------------------------------------------------------------
// Category tab definitions
// ---------------------------------------------------------------------------
type CategoryValue = "" | "request" | "content" | "announcement" | "system" | "admin";
interface Tab {
label: string;
value: CategoryValue;
adminOnly?: boolean;
}
const TABS: Tab[] = [
{ label: "All", value: "" },
{ label: "Requests", value: "request" },
{ label: "Content", value: "content" },
{ label: "Announcements", value: "announcement" },
{ label: "System", value: "system" },
{ label: "Admin", value: "admin", adminOnly: true },
];
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function Notifications() {
const { user } = useAuth();
const [activeTab, setActiveTab] = useState<CategoryValue>("");
useDocumentTitle("Notifications");
const filters = activeTab === "" ? {} : { category: activeTab };
const list = useNotificationsList(filters);
const markRead = useMarkRead();
const dismiss = useDismissNotification();
const { data: unreadCount = 0 } = useUnreadCount();
// Mark-on-view: fire once when page 1 arrives, reset when tab changes
const firstPage = list.data?.pages?.[0];
const markedRef = useRef(false);
const prevTabRef = useRef(activeTab);
useEffect(() => {
// Reset marked flag when tab changes
if (prevTabRef.current !== activeTab) {
prevTabRef.current = activeTab;
markedRef.current = false;
}
}, [activeTab]);
useEffect(() => {
if (markedRef.current || !firstPage) return;
const unreadIds = (firstPage.items ?? []).filter((n) => !n.read_at).map((n) => n.id);
if (unreadIds.length > 0) markRead.mutate({ ids: unreadIds });
markedRef.current = true;
}, [firstPage, markRead]);
const visibleTabs = TABS.filter((t) => !t.adminOnly || user?.role === "admin");
const loaded: AppNotification[] = (list.data?.pages ?? []).flatMap((p) => p.items ?? []);
// Pin announcements to the top of the mixed "All" view so system messages sit
// above other notifications. The dedicated "Announcements" tab is unaffected.
// A stable partition preserves recency within each group.
const allItems: AppNotification[] =
activeTab === "" ? partitionAnnouncementsFirst(loaded) : loaded;
const isEmpty = allItems.length === 0;
return (
<div className="page-shell space-y-6 py-4 sm:py-6">
{/* Header row */}
<div className="page-header">
<h1 className="page-title text-[clamp(1.75rem,4vw,2.75rem)]">Notifications</h1>
<Button
variant="outline"
size="sm"
disabled={unreadCount === 0}
onClick={() => markRead.mutate({ all: true })}
>
Mark all read
</Button>
</div>
{/* Category tabs */}
<div className="flex flex-wrap gap-1.5" role="tablist">
{visibleTabs.map((tab) => (
<button
key={tab.value}
type="button"
role="tab"
aria-selected={activeTab === tab.value}
className={[
"rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors",
activeTab === tab.value
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground",
].join(" ")}
onClick={() => setActiveTab(tab.value)}
>
{tab.label}
</button>
))}
</div>
{/* List */}
{isEmpty ? (
<div className="surface-panel flex flex-col items-center justify-center gap-3 rounded-[2rem] py-16 text-center">
<p className="text-sm font-medium">
{activeTab === "" ? "No notifications yet" : "Nothing in this category"}
</p>
</div>
) : (
<ul className="space-y-2">
{allItems.map((n) => {
const isUnread = !n.read_at;
const relTime = notificationTimeAgo(n.created_at) ?? n.created_at;
const inner = (
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug font-medium">{n.title}</p>
{n.body ? <p className="text-muted-foreground mt-0.5 text-xs">{n.body}</p> : null}
<p className="text-muted-foreground mt-1 text-xs">{relTime}</p>
</div>
);
return (
<li
key={n.id}
data-unread={isUnread ? "true" : undefined}
className={[
"surface-panel group relative flex items-start gap-3 rounded-[1.25rem] px-4 py-3 transition-colors",
isUnread ? "bg-primary/5" : "",
].join(" ")}
>
{n.link ? (
<Link
to={n.link}
className="min-w-0 flex-1 after:absolute after:inset-0 after:rounded-[1.25rem]"
>
{inner}
</Link>
) : (
inner
)}
<button
type="button"
aria-label="Dismiss"
className="relative z-10 mt-0.5 ml-auto flex-shrink-0 rounded-md p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
dismiss.mutate(n.id);
}}
>
<X className="h-3.5 w-3.5" />
</button>
</li>
);
})}
</ul>
)}
{/* Load more */}
{list.hasNextPage ? (
<div className="flex justify-center">
<Button variant="outline" size="sm" onClick={() => list.fetchNextPage()}>
Load more
</Button>
</div>
) : null}
</div>
);
}
+12
View File
@@ -13,6 +13,7 @@ import {
Users,
Server,
Sparkles,
Bell,
} from "lucide-react";
// Sparkles is used by the Personalization nav entry below.
import type { LucideIcon } from "lucide-react";
@@ -95,6 +96,17 @@ const NAV_SECTIONS: NavSection[] = [
},
],
},
{
label: "Notifications",
items: [
{
path: "notifications",
label: "Notifications",
icon: Bell,
description: "Choose which notifications you receive",
},
],
},
{
label: "Library & Data",
items: [
@@ -159,7 +159,7 @@ export function SettingField({
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className="max-w-md"
placeholder={hint}
placeholder={sensitiveConfigured ? "configured" : hint}
aria-describedby={hint && type === "duration" ? hintId : undefined}
/>
{hint && type === "duration" && (
@@ -0,0 +1,192 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { NotificationPreference } from "@/api/types";
const mutateMock = vi.fn();
const mocks = vi.hoisted(() => ({
useAuth: vi.fn(),
useNotificationPreferences: vi.fn(),
useSetNotificationPreferences: vi.fn(),
}));
vi.mock("@/hooks/useAuth", () => ({
useAuth: (...args: unknown[]) => mocks.useAuth(...args),
}));
vi.mock("@/hooks/queries/notifications", () => ({
useNotificationPreferences: (...args: unknown[]) => mocks.useNotificationPreferences(...args),
useSetNotificationPreferences: (...args: unknown[]) =>
mocks.useSetNotificationPreferences(...args),
}));
import NotificationsSettings from "./NotificationsSettings";
function makePreference(
category: NotificationPreference["category"],
enabled: boolean,
): NotificationPreference {
return { category, enabled };
}
const ALL_PREFS: NotificationPreference[] = [
makePreference("request", true),
makePreference("content", true),
makePreference("system", true),
makePreference("admin", true),
makePreference("content_digest", false),
];
describe("NotificationsSettings", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
mocks.useAuth.mockReset();
mocks.useNotificationPreferences.mockReset();
mocks.useSetNotificationPreferences.mockReset();
mutateMock.mockReset();
mocks.useSetNotificationPreferences.mockReturnValue({
isPending: false,
mutate: mutateMock,
});
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
});
async function render(ui: ReactNode) {
await act(async () => {
root.render(ui);
});
}
it("renders 5 toggle rows for admin users", async () => {
mocks.useAuth.mockReturnValue({ user: { role: "admin" } });
mocks.useNotificationPreferences.mockReturnValue({
data: ALL_PREFS,
isLoading: false,
});
await render(<NotificationsSettings />);
const categoryGroup = container.querySelector("section")!;
const switches = categoryGroup.querySelectorAll('[role="switch"]');
expect(switches).toHaveLength(5);
expect(container.textContent).toContain("Admin alerts");
});
it("renders 4 toggle rows for non-admin users (admin alerts hidden)", async () => {
mocks.useAuth.mockReturnValue({ user: { role: "user" } });
mocks.useNotificationPreferences.mockReturnValue({
data: ALL_PREFS.filter((p) => p.category !== "admin"),
isLoading: false,
});
await render(<NotificationsSettings />);
const categoryGroup = container.querySelector("section")!;
const switches = categoryGroup.querySelectorAll('[role="switch"]');
expect(switches).toHaveLength(4);
expect(container.textContent).not.toContain("Admin alerts");
});
it("calls setPreferences with [{category:'content',enabled:false}] when toggling off an enabled row", async () => {
mocks.useAuth.mockReturnValue({ user: { role: "user" } });
mocks.useNotificationPreferences.mockReturnValue({
data: ALL_PREFS,
isLoading: false,
});
await render(<NotificationsSettings />);
// SettingRow renders a <Label htmlFor={id}> and a <Switch id={id}>.
// Find the label containing "New content", get its htmlFor, then find the switch.
const labels = Array.from(container.querySelectorAll("label"));
const contentLabel = labels.find((l) => l.textContent?.trim() === "New content");
expect(contentLabel).toBeDefined();
const forId = contentLabel!.getAttribute("for");
expect(forId).toBeTruthy();
const contentSwitch = container.querySelector(`[id="${forId}"]`) as HTMLButtonElement | null;
expect(contentSwitch).not.toBeNull();
await act(async () => {
contentSwitch!.click();
});
expect(mutateMock).toHaveBeenCalledWith({
preferences: [{ category: "content", enabled: false }],
});
});
it("reflects enabled=false for content_digest from server prefs", async () => {
mocks.useAuth.mockReturnValue({ user: { role: "user" } });
mocks.useNotificationPreferences.mockReturnValue({
data: ALL_PREFS,
isLoading: false,
});
await render(<NotificationsSettings />);
const labels = Array.from(container.querySelectorAll("label"));
const digestLabel = labels.find((l) => l.textContent?.trim() === "Daily digest");
expect(digestLabel).toBeDefined();
const forId = digestLabel!.getAttribute("for");
expect(forId).toBeTruthy();
const digestSwitch = container.querySelector(`[id="${forId}"]`) as HTMLButtonElement | null;
expect(digestSwitch).not.toBeNull();
// aria-checked="false" means the switch is off (enabled=false from mocked prefs)
expect(digestSwitch!.getAttribute("aria-checked")).toBe("false");
});
it("renders all switches disabled while loading", async () => {
mocks.useAuth.mockReturnValue({ user: { role: "user" } });
mocks.useNotificationPreferences.mockReturnValue({
data: undefined,
isLoading: true,
});
await render(<NotificationsSettings />);
const categoryGroup = container.querySelector("section")!;
const switches = Array.from(
categoryGroup.querySelectorAll('[role="switch"]'),
) as HTMLButtonElement[];
expect(switches.length).toBeGreaterThan(0);
for (const sw of switches) {
expect(sw.disabled).toBe(true);
}
});
it("shows the announcements footer note", async () => {
mocks.useAuth.mockReturnValue({ user: { role: "user" } });
mocks.useNotificationPreferences.mockReturnValue({ data: [], isLoading: false });
await render(<NotificationsSettings />);
expect(container.textContent).toContain(
"Announcements from your server admin can’t be turned off.",
);
});
});
@@ -0,0 +1,98 @@
import { useAuth } from "@/hooks/useAuth";
import {
useNotificationPreferences,
useSetNotificationPreferences,
} from "@/hooks/queries/notifications";
import { SettingsGroup } from "@/components/settings/SettingsGroup";
import { SettingRow } from "@/components/settings/SettingRow";
import { Switch } from "@/components/ui/switch";
import type { NotificationPreferenceCategory } from "@/api/types";
const PREF_META: {
category: NotificationPreferenceCategory;
label: string;
description: string;
adminOnly?: boolean;
}[] = [
{
category: "request",
label: "Requests",
description: "Updates when your requests are approved, declined, or ready",
},
{
category: "content",
label: "New content",
description: "New arrivals matching your watchlist, favorites, and shows in progress",
},
{
category: "system",
label: "Account & security",
description: "Password changes and account notices",
},
{
category: "admin",
label: "Admin alerts",
description: "Failed jobs and scans",
adminOnly: true,
},
{
category: "content_digest",
label: "Daily digest",
description: "One daily summary of everything added to your libraries",
},
];
export default function NotificationsSettings() {
const { user } = useAuth();
const { data: preferences, isLoading } = useNotificationPreferences();
const setPreferences = useSetNotificationPreferences();
const isAdmin = user?.role === "admin";
const visibleMeta = PREF_META.filter((m) => !m.adminOnly || isAdmin);
function getEnabled(category: NotificationPreferenceCategory): boolean {
if (!preferences) return true;
const entry = preferences.find((p) => p.category === category);
return entry ? entry.enabled : true;
}
function handleToggle(category: NotificationPreferenceCategory, enabled: boolean) {
setPreferences.mutate({ preferences: [{ category, enabled }] });
}
return (
<div className="space-y-6">
<div className="space-y-4">
<div className="space-y-3">
<h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">Notifications</h2>
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
Choose which notifications you receive.
</p>
</div>
</div>
<SettingsGroup title="Notifications">
{visibleMeta.map((meta) => (
<SettingRow
key={meta.category}
label={meta.label}
description={meta.description}
control={(id) => (
<Switch
id={id}
checked={getEnabled(meta.category)}
disabled={isLoading || setPreferences.isPending}
onCheckedChange={(checked) => handleToggle(meta.category, checked)}
/>
)}
/>
))}
</SettingsGroup>
<p className="text-muted-foreground px-1 text-[13px] leading-relaxed">
Announcements from your server admin can&rsquo;t be turned off.
</p>
</div>
);
}