Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67209e38e0 | ||
|
|
35b8b9e44e | ||
|
|
be704c3a06 | ||
|
|
dcce156d4a | ||
|
|
dcb8ce8afe | ||
|
|
93eb23d2ec | ||
|
|
20b75732ac | ||
|
|
c47c3dc621 | ||
|
|
84704a073c | ||
|
|
e2865c070b | ||
|
|
fd6797d907 | ||
|
|
fede2a986f | ||
|
|
c2a33c7e35 | ||
|
|
0506336625 | ||
|
|
19a0e62f0d | ||
|
|
af575d7470 | ||
|
|
3b3e9ed7b9 | ||
|
|
d9ddfdb879 | ||
|
|
3c0fd5f897 | ||
|
|
d2c8be9162 | ||
|
|
9a1b4ca3d7 | ||
|
|
4eb8c06023 | ||
|
|
dd54e90894 | ||
|
|
abb080c6ed | ||
|
|
f8b7cb0605 | ||
|
|
ed1ab977b3 | ||
|
|
2fe7c8683c | ||
|
|
7d0154852d | ||
|
|
e70bccceb7 | ||
|
|
615d35c131 | ||
|
|
bbc865ce53 | ||
|
|
7649b30f77 | ||
|
|
088250f6ea | ||
|
|
ea049c3d1f | ||
|
|
0b8b67f6c4 | ||
|
|
bb620a16ab | ||
|
|
f9c5c13a1a | ||
|
|
06d11fa062 | ||
|
|
addeb47807 | ||
|
|
e7d7fd87d6 | ||
|
|
84f027aa07 | ||
|
|
afa6025a2a | ||
|
|
6a5f61dd39 | ||
|
|
0b96771964 | ||
|
|
497fd6ee71 | ||
|
|
bde9d563ab | ||
|
|
f25cbb27aa | ||
|
|
bf9746ce32 | ||
|
|
1fa00b784f | ||
|
|
4d3f366dd2 | ||
|
|
d5791ed541 | ||
|
|
0a52b47e4e | ||
|
|
418b7347ca | ||
|
|
75b4e1c72f | ||
|
|
6e817706f1 | ||
|
|
404f782ee2 | ||
|
|
ab7c081ecc | ||
|
|
01f828a11a | ||
|
|
a79eb85ca0 | ||
|
|
5711771c9e | ||
|
|
cdbfcbe9af | ||
|
|
ea92eb6c69 | ||
|
|
f7866a78d5 | ||
|
|
59cfb01e71 | ||
|
|
88f0228968 | ||
|
|
4be3f498cf | ||
|
|
ac4a44c62c | ||
|
|
a724ac9c55 | ||
|
|
abca92eec5 | ||
|
|
30f59accd7 | ||
|
|
c24df8b3bd | ||
|
|
1c823aa507 | ||
|
|
c7079000a8 | ||
|
|
9ce999a61d | ||
|
|
a638494540 | ||
|
|
b5b3cccc18 | ||
|
|
2e9435d69e | ||
|
|
f4fe1f7d94 | ||
|
|
1094ecb523 | ||
|
|
4f80010de5 | ||
|
|
229379e0a6 | ||
|
|
954d26360f | ||
|
|
09f202f9f7 | ||
|
|
833ce25178 | ||
|
|
a047519230 | ||
|
|
7c65c7b4dc | ||
|
|
98ed20444a | ||
|
|
e59343968d | ||
|
|
0add71fbd8 | ||
|
|
8d5e0fec85 | ||
|
|
423e2c02ae | ||
|
|
58fbc8f23c | ||
|
|
45995f659c | ||
|
|
1756f54e21 | ||
|
|
5150fec3e3 | ||
|
|
bf880b292b | ||
|
|
db22615874 | ||
|
|
cfbd95e31c | ||
|
|
70e8190348 | ||
|
|
9626fd8f13 |
@@ -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.
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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(), ¬ifications.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(), ¬ifications.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(), ¬ifications.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(), ¬ifications.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(), ¬ifications.Notification{ID: 1, UserID: 0, Title: "x", Category: notifications.CategoryContent})
|
||||
if len(src.enqueued) != 0 {
|
||||
t.Fatal("nil/zero-user enqueues nothing")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 := ¬ificationsProgress{}
|
||||
|
||||
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 := ¬ificationsProgress{}
|
||||
|
||||
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 := ¬ificationsProgress{}
|
||||
|
||||
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 := ¬ificationsProgress{}
|
||||
|
||||
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 := ¬ificationsProgress{}
|
||||
|
||||
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 := ¬ificationsProgress{}
|
||||
|
||||
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 := ¬ificationsProgress{}
|
||||
|
||||
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 := ¬ificationsProgress{}
|
||||
|
||||
// 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 := ¬ificationsProgress{}
|
||||
|
||||
// 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
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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] })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"),
|
||||
];
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
}));
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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] });
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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’t be turned off.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user