Compare commits
103
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 | ||
|
|
0eb87fa0f5 | ||
|
|
fadd8ff456 | ||
|
|
a17529fd6f |
+118
-19
@@ -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"
|
||||
@@ -120,7 +122,7 @@ func resolvePluginCacheDir() string {
|
||||
return filepath.Join(os.TempDir(), "silo-plugins")
|
||||
}
|
||||
|
||||
func buildBaseHandler(format string, level slog.Level) slog.Handler {
|
||||
func buildBaseHandler(format string, level slog.Leveler) slog.Handler {
|
||||
opts := &slog.HandlerOptions{Level: level}
|
||||
if strings.EqualFold(format, "json") {
|
||||
return slog.NewJSONHandler(os.Stderr, opts)
|
||||
@@ -128,6 +130,19 @@ func buildBaseHandler(format string, level slog.Level) slog.Handler {
|
||||
return slog.NewTextHandler(os.Stderr, opts)
|
||||
}
|
||||
|
||||
func parseLogLevel(level string) slog.Level {
|
||||
switch strings.ToLower(level) {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
func mustGetSetting(store interface {
|
||||
Get(context.Context, string) (string, error)
|
||||
}, ctx context.Context, key, fallback string) string {
|
||||
@@ -144,8 +159,7 @@ func configureOperationalLogging(
|
||||
settingsRepo catalog.SettingsStore,
|
||||
redisCfg config.RedisConfig,
|
||||
logStreamHub *logstream.Hub,
|
||||
baseHandler slog.Handler,
|
||||
logQuiet string,
|
||||
filteredHandler slog.Handler,
|
||||
nodeID string,
|
||||
) (opslog.Writer, *opslog.Repo, *partman.Manager) {
|
||||
if err := opslog.SeedDefaults(ctx, settingsRepo); err != nil {
|
||||
@@ -182,7 +196,6 @@ func configureOperationalLogging(
|
||||
opsCaptureLevel = slog.LevelError
|
||||
}
|
||||
|
||||
filteredHandler := logfilter.New(baseHandler, logQuiet)
|
||||
slog.SetDefault(slog.New(opslog.NewHandler(filteredHandler, operationalWriter, opsCaptureLevel, nodeID)))
|
||||
|
||||
return operationalWriter, opslog.NewRepo(pool), opsPM
|
||||
@@ -457,20 +470,14 @@ func main() {
|
||||
log.Fatalf("config validation: %v", err)
|
||||
}
|
||||
|
||||
// Step 10: Configure log level
|
||||
var slogLevel slog.Level
|
||||
switch strings.ToLower(cfg.Server.LogLevel) {
|
||||
case "debug":
|
||||
slogLevel = slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
slogLevel = slog.LevelWarn
|
||||
case "error":
|
||||
slogLevel = slog.LevelError
|
||||
default:
|
||||
slogLevel = slog.LevelInfo
|
||||
}
|
||||
baseHandler := buildBaseHandler(cfg.Server.LogFormat, slogLevel)
|
||||
slog.SetDefault(slog.New(logfilter.New(baseHandler, cfg.Server.LogQuiet)))
|
||||
// Step 10: Configure log level. The level var and quiet filter are
|
||||
// shared with the operational-logging handler chain and hot-reloaded by
|
||||
// the config watcher in integrated mode.
|
||||
logLevelVar := new(slog.LevelVar)
|
||||
logLevelVar.Set(parseLogLevel(cfg.Server.LogLevel))
|
||||
baseHandler := buildBaseHandler(cfg.Server.LogFormat, logLevelVar)
|
||||
quietFilter := logfilter.New(baseHandler, cfg.Server.LogQuiet)
|
||||
slog.SetDefault(slog.New(quietFilter))
|
||||
|
||||
mode := cfg.Server.Mode
|
||||
maybeApplyPostgresTuning(ctx, pool, cfg.Database.MaxConnections, mode)
|
||||
@@ -491,8 +498,19 @@ 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, baseHandler, cfg.Server.LogQuiet, nodeID)
|
||||
operationalWriter, opsRepo, opsPM := configureOperationalLogging(appCtx, pool, settingsRepo, cfg.Redis, logStreamHub, quietFilter, nodeID)
|
||||
defer func() {
|
||||
if err := eventBus.Close(); err != nil {
|
||||
slog.Warn("event bus close error", "error", err)
|
||||
@@ -512,6 +530,7 @@ func main() {
|
||||
Mode: cfg.Server.Mode,
|
||||
DatabaseURL: cfg.Database.URL,
|
||||
JFListen: cfg.JellyfinCompat.Listen,
|
||||
RedisURL: bc.RedisURL,
|
||||
}
|
||||
watcher := nodeconfig.NewWatcher(pool, dataCipher, eventBus, bootstrap)
|
||||
if err := watcher.Start(appCtx); err != nil {
|
||||
@@ -553,6 +572,29 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
// Hot-reload config watcher for integrated/api mode. Reloads on
|
||||
// EventSettingsChanged (Redis) with a 60s poll fallback, so settings
|
||||
// changes apply without restart even on Redis-less deployments. The
|
||||
// watcher's config supersedes the startup snapshot from here on.
|
||||
configWatcher := nodeconfig.NewWatcher(pool, dataCipher, eventBus, nodeconfig.BootstrapOverrides{
|
||||
Listen: bc.Listen,
|
||||
Mode: bc.Mode,
|
||||
DatabaseURL: bc.DatabaseURL,
|
||||
JFListen: bc.JFListen,
|
||||
RedisURL: bc.RedisURL,
|
||||
})
|
||||
if err := configWatcher.Start(appCtx); err != nil {
|
||||
log.Fatalf("config watcher start: %v", err)
|
||||
}
|
||||
cfg = configWatcher.Config()
|
||||
|
||||
// Apply server.log_level / server.log_quiet changes live. Both feed the
|
||||
// shared level var and quiet filter inside the default logger chain.
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
logLevelVar.Set(parseLogLevel(updated.Server.LogLevel))
|
||||
quietFilter.SetQuiet(updated.Server.LogQuiet)
|
||||
})
|
||||
|
||||
// Determine which components to initialize based on mode.
|
||||
needsS3 := mode == "integrated" || mode == "api"
|
||||
needsScanner := mode == "integrated" || mode == "api"
|
||||
@@ -568,6 +610,8 @@ func main() {
|
||||
|
||||
deps := api.Dependencies{
|
||||
Config: cfg,
|
||||
LiveConfig: configWatcher.Config,
|
||||
OnConfigChange: configWatcher.OnChange,
|
||||
BootstrapSensitiveConfigured: bootstrapSensitiveConfigured,
|
||||
BootstrapSensitiveValues: bootstrapSensitiveValues,
|
||||
AppContext: appCtx,
|
||||
@@ -588,7 +632,37 @@ func main() {
|
||||
restartReqCh <- struct{}{}
|
||||
return nil
|
||||
},
|
||||
OnServerSettingUpdated: func(_ context.Context, _, _ string) {
|
||||
// Nudge the hot-reload watcher so same-process settings changes
|
||||
// apply immediately even without Redis (the event bus is a no-op
|
||||
// then, leaving only the 60s poll).
|
||||
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 {
|
||||
@@ -707,6 +781,9 @@ func main() {
|
||||
|
||||
ffprobePath := scanner.FFprobePathFromFFmpeg(cfg.Playback.FFmpegPath)
|
||||
s := scanner.NewScanner(fileRepo, ffprobePath, deps.S3Public, cfg.Scanner.Workers, cfg.Scanner.EmptyTrashAfterScan)
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
s.SetWorkers(updated.Scanner.Workers)
|
||||
})
|
||||
deps.Scanner = s
|
||||
deps.ProbeEnsurer = scanner.NewPlaybackProbeEnsurer(fileRepo, ffprobePath, 10*time.Second)
|
||||
slog.Info("scanner initialized")
|
||||
@@ -1044,6 +1121,9 @@ func main() {
|
||||
imageCacher := imagecache.New(deps.S3Public)
|
||||
metadataService.SetImageCacher(imageCacher)
|
||||
metadataService.SetAutoCacheImages(cfg.Metadata.CacheImages)
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
metadataService.SetAutoCacheImages(updated.Metadata.CacheImages)
|
||||
})
|
||||
if deps.Scanner != nil {
|
||||
deps.Scanner.SetImageCacher(imageCacher)
|
||||
}
|
||||
@@ -1061,6 +1141,10 @@ func main() {
|
||||
}
|
||||
|
||||
matchWorker = metadata.NewMatchWorker(metadataService, deps.FileRepo, cfg.Matcher.Workers, cfg.Matcher.BatchSize, 30*time.Second)
|
||||
mwForReload := matchWorker
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
mwForReload.SetConcurrency(updated.Matcher.Workers, updated.Matcher.BatchSize)
|
||||
})
|
||||
matchWorker.SetRealtimeHub(deps.RealtimeHub)
|
||||
if movieQueueRepo != nil {
|
||||
matchWorker.SetMovieFileClaimer(movieQueueRepo)
|
||||
@@ -1495,6 +1579,10 @@ func main() {
|
||||
deps.UserCollectionSync = userSync
|
||||
deps.UserCollectionScheduler = userCollectionScheduler
|
||||
deps.MDBListClient = mdblist.NewClient(cfg.MDBListAPIKey, nil)
|
||||
mdblistForReload := deps.MDBListClient
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
mdblistForReload.SetAPIKey(updated.MDBListAPIKey)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1554,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(
|
||||
@@ -1612,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()
|
||||
@@ -1631,6 +1723,9 @@ func main() {
|
||||
cfg.Auth.AccessTokenExpiry,
|
||||
cfg.Auth.RefreshTokenExpiry,
|
||||
)
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
absJWTService.SetExpiries(updated.Auth.AccessTokenExpiry, updated.Auth.RefreshTokenExpiry)
|
||||
})
|
||||
absAuthSvc := auth.NewService(
|
||||
auth.NewLocalProvider(absUserRepo, absSessionRepo),
|
||||
absJWTService,
|
||||
@@ -1895,6 +1990,7 @@ func main() {
|
||||
if (mode == "integrated" || mode == "api") && cfg.JellyfinCompat.Listen != "" {
|
||||
compatDeps := jellycompat.Dependencies{
|
||||
Config: cfg,
|
||||
LiveConfig: configWatcher.Config,
|
||||
DB: deps.DB,
|
||||
SecretCipher: dataCipher,
|
||||
ClientIPResolver: ipResolver,
|
||||
@@ -1962,6 +2058,9 @@ func main() {
|
||||
cfg.Auth.AccessTokenExpiry,
|
||||
cfg.Auth.RefreshTokenExpiry,
|
||||
)
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
jwtService.SetExpiries(updated.Auth.AccessTokenExpiry, updated.Auth.RefreshTokenExpiry)
|
||||
})
|
||||
provider := auth.NewLocalProvider(userRepo, sessionRepo)
|
||||
compatDeps.AuthService = auth.NewService(provider, jwtService, sessionRepo, userRepo, nil, nil, nil)
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -56,8 +57,11 @@ func (c Config) ASRConfigured() bool { return c.asrBaseURL() != "" && c.ASRModel
|
||||
// Client is a minimal OpenAI-compatible API client with shared retry/backoff
|
||||
// conventions (429 with Retry-After, 5xx, transport errors, and gateway "200
|
||||
// with embedded error object" responses are retried; other 4xx fail fast).
|
||||
// The config is held behind an atomic pointer so admin settings changes apply
|
||||
// to subsequent requests without rebuilding the client; each request snapshots
|
||||
// the config once so its URL, key, and model stay consistent.
|
||||
type Client struct {
|
||||
cfg Config
|
||||
cfg atomic.Pointer[Config]
|
||||
// chatHTTP caps a single chat completion at 10 minutes. asrHTTP has no
|
||||
// client-level timeout: a transcription upload's deadline is set per request
|
||||
// (sized to the chunk duration), which a client timeout would silently cap.
|
||||
@@ -67,11 +71,23 @@ type Client struct {
|
||||
|
||||
// NewClient builds a client from the endpoint config.
|
||||
func NewClient(cfg Config) *Client {
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
c := &Client{
|
||||
chatHTTP: &http.Client{Timeout: 10 * time.Minute},
|
||||
asrHTTP: &http.Client{},
|
||||
}
|
||||
c.UpdateConfig(cfg)
|
||||
return c
|
||||
}
|
||||
|
||||
// UpdateConfig swaps the endpoint config. Safe for concurrent use; in-flight
|
||||
// requests finish with the config they started with.
|
||||
func (c *Client) UpdateConfig(cfg Config) {
|
||||
c.cfg.Store(&cfg)
|
||||
}
|
||||
|
||||
// Config returns a snapshot of the current endpoint config.
|
||||
func (c *Client) Config() Config {
|
||||
return *c.cfg.Load()
|
||||
}
|
||||
|
||||
// Message is one chat-completions message.
|
||||
@@ -107,8 +123,9 @@ type chatCompletionResponse struct {
|
||||
// When jsonObject is true it requests response_format=json_object; providers
|
||||
// that ignore the field still work because the prompt itself demands JSON.
|
||||
func (c *Client) Chat(ctx context.Context, messages []Message, jsonObject bool) (string, error) {
|
||||
cfg := c.Config()
|
||||
reqBody := chatCompletionRequest{
|
||||
Model: c.cfg.ChatModel,
|
||||
Model: cfg.ChatModel,
|
||||
Messages: messages,
|
||||
Temperature: 0.2,
|
||||
}
|
||||
@@ -121,7 +138,7 @@ func (c *Client) Chat(ctx context.Context, messages []Message, jsonObject bool)
|
||||
return "", fmt.Errorf("marshal chat request: %w", err)
|
||||
}
|
||||
|
||||
url := endpointURL(c.cfg.BaseURL, "chat/completions")
|
||||
url := endpointURL(cfg.BaseURL, "chat/completions")
|
||||
|
||||
var content string
|
||||
err = c.doWithRetry(ctx, c.chatHTTP, "chat API",
|
||||
@@ -131,8 +148,8 @@ func (c *Client) Chat(ctx context.Context, messages []Message, jsonObject bool)
|
||||
return nil, fmt.Errorf("create request: %w", reqErr)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
if c.cfg.APIKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
|
||||
if cfg.APIKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||||
}
|
||||
return httpReq, nil
|
||||
},
|
||||
@@ -147,7 +164,7 @@ func (c *Client) Chat(ctx context.Context, messages []Message, jsonObject bool)
|
||||
}
|
||||
if len(parsed.Choices) == 0 || parsed.Choices[0].Message.Content == "" {
|
||||
slog.Warn("AI chat returned no choices, retrying",
|
||||
"model", c.cfg.ChatModel, "response_bytes", len(respBody))
|
||||
"model", cfg.ChatModel, "response_bytes", len(respBody))
|
||||
return fmt.Errorf("chat API returned no choices")
|
||||
}
|
||||
content = parsed.Choices[0].Message.Content
|
||||
|
||||
@@ -142,12 +142,13 @@ func hostMatchesAny(baseURL string, hosts []string) bool {
|
||||
// configured), using the OpenAI-compatible /v1/audio/transcriptions API with
|
||||
// response_format=verbose_json for segment timestamps.
|
||||
func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transcription, error) {
|
||||
if !c.cfg.ASRConfigured() {
|
||||
cfg := c.Config()
|
||||
if !cfg.ASRConfigured() {
|
||||
return nil, fmt.Errorf("transcription endpoint is not configured")
|
||||
}
|
||||
if IsChatOnlyGateway(c.cfg.asrBaseURL()) {
|
||||
if IsChatOnlyGateway(cfg.asrBaseURL()) {
|
||||
return nil, fmt.Errorf("the configured transcription endpoint (%s) cannot produce timestamped transcriptions; "+
|
||||
"set a Whisper-compatible Transcription base URL under Admin Settings → AI Services", c.cfg.asrBaseURL())
|
||||
"set a Whisper-compatible Transcription base URL under Admin Settings → AI Services", cfg.asrBaseURL())
|
||||
}
|
||||
if len(req.Audio) == 0 {
|
||||
return nil, fmt.Errorf("no audio data to transcribe")
|
||||
@@ -160,7 +161,7 @@ func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transc
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
url := endpointURL(c.cfg.asrBaseURL(), "audio/transcriptions")
|
||||
url := endpointURL(cfg.asrBaseURL(), "audio/transcriptions")
|
||||
|
||||
var result *Transcription
|
||||
doErr := c.doWithRetry(ctx, c.asrHTTP, "transcription API",
|
||||
@@ -175,7 +176,7 @@ func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transc
|
||||
return nil, fmt.Errorf("write multipart audio: %w", err)
|
||||
}
|
||||
fields := [][2]string{
|
||||
{"model", c.cfg.ASRModel},
|
||||
{"model", cfg.ASRModel},
|
||||
{"response_format", "verbose_json"},
|
||||
{"temperature", "0"},
|
||||
// Word timings let cue building split paragraph-length
|
||||
@@ -187,7 +188,7 @@ func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transc
|
||||
if req.Language != "" {
|
||||
fields = append(fields, [2]string{"language", req.Language})
|
||||
}
|
||||
if !hostMatchesAny(c.cfg.asrBaseURL(), strictHostedASRHosts) {
|
||||
if !hostMatchesAny(cfg.asrBaseURL(), strictHostedASRHosts) {
|
||||
fields = append(fields, [2]string{"vad_filter", "true"})
|
||||
}
|
||||
for _, f := range fields {
|
||||
@@ -204,7 +205,7 @@ func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transc
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", w.FormDataContentType())
|
||||
if key := c.cfg.asrAPIKey(); key != "" {
|
||||
if key := cfg.asrAPIKey(); key != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+key)
|
||||
}
|
||||
return httpReq, nil
|
||||
@@ -219,7 +220,7 @@ func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transc
|
||||
}
|
||||
if parsed.Segments == nil {
|
||||
return &permanentError{err: fmt.Errorf(
|
||||
"transcription endpoint did not return verbose_json segments (model %q); a verbose_json-capable Whisper endpoint is required", c.cfg.ASRModel)}
|
||||
"transcription endpoint did not return verbose_json segments (model %q); a verbose_json-capable Whisper endpoint is required", cfg.ASRModel)}
|
||||
}
|
||||
out := &Transcription{Language: parsed.Language}
|
||||
for _, s := range *parsed.Segments {
|
||||
|
||||
@@ -27,6 +27,8 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/cache"
|
||||
"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"
|
||||
@@ -529,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")
|
||||
@@ -1217,6 +1228,9 @@ func (h *AdminHandler) HandleGetSensitiveStatus(w http.ResponseWriter, r *http.R
|
||||
type adminSettingResponse struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
// RestartRequired reports whether the saved value only takes effect
|
||||
// after a server restart (set on update responses only).
|
||||
RestartRequired bool `json:"restart_required,omitempty"`
|
||||
}
|
||||
|
||||
type adminSettingsListResponse struct {
|
||||
@@ -2145,9 +2159,10 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
|
||||
h.OnServerSettingUpdated(r.Context(), key, req.Value)
|
||||
}
|
||||
|
||||
restartRequired := config.RestartRequired(key)
|
||||
if sensitiveSettingKeys[key] {
|
||||
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key})
|
||||
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, RestartRequired: restartRequired})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value})
|
||||
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value, RestartRequired: restartRequired})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -326,6 +326,47 @@ func TestAdminUpdateSettingRedactsSensitiveSetting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUpdateSettingReportsRestartRequired(t *testing.T) {
|
||||
cases := []struct {
|
||||
key string
|
||||
value string
|
||||
restartRequired bool
|
||||
}{
|
||||
// Infrastructure settings are captured at startup.
|
||||
{key: "database.max_connections", value: "40", restartRequired: true},
|
||||
{key: "s3.public_bucket", value: "assets", restartRequired: true},
|
||||
// Branding is read live from the settings repo per request.
|
||||
{key: "branding.server_name", value: "Casa", restartRequired: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.key, func(t *testing.T) {
|
||||
settings := &fakeServerSettingsStore{}
|
||||
handler := &AdminHandler{SettingsRepo: settings}
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/admin/settings/"+tc.key,
|
||||
strings.NewReader(`{"value":"`+tc.value+`"}`),
|
||||
)
|
||||
req = withChiParam(req, "key", tc.key)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleUpdateSetting(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp adminSettingResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.RestartRequired != tc.restartRequired {
|
||||
t.Fatalf("restart_required = %v, want %v", resp.RestartRequired, tc.restartRequired)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func performSettingsCheckRequest(
|
||||
t *testing.T,
|
||||
handler *AdminHandler,
|
||||
|
||||
@@ -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
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"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"
|
||||
@@ -129,11 +130,12 @@ type PlaybackHandler struct {
|
||||
RealtimeHub *playback.RealtimeHub
|
||||
CommandTracker *playback.CommandTracker
|
||||
CommandDispatcher *playback.CommandDispatcher
|
||||
FFmpegPath string
|
||||
HWAccel string
|
||||
HWDevice string
|
||||
TranscodeDir string
|
||||
FFmpegLogSink playback.FFmpegLogSink
|
||||
// PlaybackConfig returns the current playback config (ffmpeg path,
|
||||
// hwaccel, transcode dir). Wired to the live config in integrated mode
|
||||
// so admin changes apply to newly started transcodes. Read it through
|
||||
// playbackConfig(), which falls back to defaults when unset.
|
||||
PlaybackConfig func() config.PlaybackConfig
|
||||
FFmpegLogSink playback.FFmpegLogSink
|
||||
transcodeMu sync.RWMutex
|
||||
transcodes map[string]*playback.TranscodeSession
|
||||
realtimeCommandMu sync.Mutex
|
||||
@@ -156,7 +158,6 @@ type sessionExpirationHookSetter interface {
|
||||
func NewPlaybackHandler(sessionMgr SessionManagerInterface, opts ...FilePathResolver) *PlaybackHandler {
|
||||
h := &PlaybackHandler{
|
||||
sessionMgr: sessionMgr,
|
||||
TranscodeDir: filepath.Join(os.TempDir(), "silo-transcode"),
|
||||
transcodes: make(map[string]*playback.TranscodeSession),
|
||||
realtimeCommands: make(map[string]playbackCommandRecord),
|
||||
}
|
||||
@@ -179,6 +180,19 @@ func (h *PlaybackHandler) SetProfileRefreshRequester(requester ProfileRefreshReq
|
||||
h.profileRefreshRequester = requester
|
||||
}
|
||||
|
||||
// playbackConfig returns the current playback config, falling back to the
|
||||
// same defaults as config loading (transcode enabled, temp transcode dir)
|
||||
// when no provider is wired (tests, minimal setups).
|
||||
func (h *PlaybackHandler) playbackConfig() config.PlaybackConfig {
|
||||
if h.PlaybackConfig != nil {
|
||||
return h.PlaybackConfig()
|
||||
}
|
||||
return config.PlaybackConfig{
|
||||
TranscodeEnabled: true,
|
||||
TranscodeDir: filepath.Join(os.TempDir(), "silo-transcode"),
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupOrphanedTranscodes removes stale per-session temp directories for
|
||||
// transcodes that are no longer tracked in memory.
|
||||
func (h *PlaybackHandler) CleanupOrphanedTranscodes() (int, error) {
|
||||
@@ -189,7 +203,7 @@ func (h *PlaybackHandler) CleanupOrphanedTranscodes() (int, error) {
|
||||
}
|
||||
h.transcodeMu.RUnlock()
|
||||
|
||||
return playback.CleanupOrphanedTranscodeDirs(h.TranscodeDir, active)
|
||||
return playback.CleanupOrphanedTranscodeDirs(h.playbackConfig().TranscodeDir, active)
|
||||
}
|
||||
|
||||
// playbackThresholds reads the playback.watched_threshold and
|
||||
@@ -614,9 +628,9 @@ func normalizeAudioTrackIndex(file *models.MediaFile, audioTrackIndex int) int {
|
||||
return directPlayAudioTrackIndex(file)
|
||||
}
|
||||
|
||||
func playbackAdminSettingsFromRequest(ctx context.Context, repo PlaybackSettingsReader) playback.AdminSettings {
|
||||
func playbackAdminSettingsFromRequest(ctx context.Context, repo PlaybackSettingsReader, transcodeEnabled bool) playback.AdminSettings {
|
||||
settings := playback.AdminSettings{
|
||||
TranscodeEnabled: true,
|
||||
TranscodeEnabled: transcodeEnabled,
|
||||
}
|
||||
if repo != nil {
|
||||
if v, _ := repo.Get(ctx, "allow_4k_transcode"); v == "true" {
|
||||
@@ -661,7 +675,7 @@ func (h *PlaybackHandler) resolveCapabilityPlaybackSelection(
|
||||
}
|
||||
|
||||
audioTrackIndex = normalizeAudioTrackIndex(requestedFile, audioTrackIndex)
|
||||
adminSettings := playbackAdminSettingsFromRequest(ctx, h.SettingsRepo)
|
||||
adminSettings := playbackAdminSettingsFromRequest(ctx, h.SettingsRepo, h.playbackConfig().TranscodeEnabled)
|
||||
method, transcodeAudio := resolvePlaybackMethodForFile(requestedFile, req, audioTrackIndex, adminSettings)
|
||||
|
||||
if requestedFile.Resolution == "2160p" &&
|
||||
@@ -1430,10 +1444,14 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
|
||||
// subtitleURLExt returns the URL file extension for a subtitle codec.
|
||||
// ASS/SSA tracks get ".ass" so the frontend can request raw ASS data for
|
||||
// client-side rendering (JASSUB); all other text formats get ".vtt".
|
||||
// client-side rendering (JASSUB); PGS tracks get ".sup" for client-side
|
||||
// bitmap rendering (libpgs); all other text formats get ".vtt".
|
||||
func subtitleURLExt(codec string) string {
|
||||
if playback.IsASS(codec) {
|
||||
switch {
|
||||
case playback.IsASS(codec):
|
||||
return ".ass"
|
||||
case playback.IsPGS(codec):
|
||||
return ".sup"
|
||||
}
|
||||
return ".vtt"
|
||||
}
|
||||
@@ -1460,7 +1478,10 @@ func buildSubtitleURLs(sessionID string, file *models.MediaFile, downloaded []su
|
||||
|
||||
embeddedOffset := len(file.ExternalSubtitles)
|
||||
for i, track := range file.SubtitleTracks {
|
||||
if playback.NeedsBurnIn(track.Codec) {
|
||||
// PGS bitmap tracks are deliverable as .sup streams for
|
||||
// client-side rendering; DVD/DVB bitmap tracks still have no
|
||||
// non-burn-in delivery path, so they stay hidden.
|
||||
if playback.NeedsBurnIn(track.Codec) && !playback.IsPGS(track.Codec) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -2064,7 +2085,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
TargetCodecAudio: req.TargetCodecAudio,
|
||||
TargetBitrateKbps: req.TargetBitrateKbps,
|
||||
SegmentDuration: req.SegmentDuration,
|
||||
HWAccel: h.HWAccel,
|
||||
HWAccel: h.playbackConfig().HWAccel,
|
||||
AudioTrackIndex: session.AudioTrackIndex,
|
||||
SubtitleTrackIndex: req.SubtitleTrackIndex,
|
||||
SubtitleBurnIn: req.SubtitleBurnIn,
|
||||
@@ -2130,14 +2151,17 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
"No transcode node is available and local transcode fallback is disabled")
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(h.TranscodeDir, 0o755); err != nil {
|
||||
// Snapshot once so the directory, ffmpeg path, and hwaccel of this
|
||||
// session stay consistent even if the config reloads mid-start.
|
||||
playbackCfg := h.playbackConfig()
|
||||
if err := os.MkdirAll(playbackCfg.TranscodeDir, 0o755); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare transcode directory")
|
||||
return
|
||||
}
|
||||
|
||||
transcodeSession, err := playback.StartTranscode(context.WithoutCancel(r.Context()), playback.TranscodeOpts{
|
||||
InputPath: file.FilePath,
|
||||
OutputDir: filepath.Join(h.TranscodeDir, req.SessionID),
|
||||
OutputDir: filepath.Join(playbackCfg.TranscodeDir, req.SessionID),
|
||||
SessionID: req.SessionID,
|
||||
SourceVideoCodec: file.CodecVideo,
|
||||
SeekSeconds: req.SeekSeconds,
|
||||
@@ -2147,9 +2171,9 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
TargetCodecAudio: req.TargetCodecAudio,
|
||||
TargetBitrateKbps: req.TargetBitrateKbps,
|
||||
SegmentDuration: req.SegmentDuration,
|
||||
FFmpegPath: h.FFmpegPath,
|
||||
HWAccel: h.HWAccel,
|
||||
HWDevice: h.HWDevice,
|
||||
FFmpegPath: playbackCfg.FFmpegPath,
|
||||
HWAccel: playbackCfg.HWAccel,
|
||||
HWDevice: playbackCfg.HWDevice,
|
||||
AudioTrackIndex: session.AudioTrackIndex,
|
||||
SubtitleTrackIndex: req.SubtitleTrackIndex,
|
||||
SubtitleBurnIn: req.SubtitleBurnIn,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestSubtitleURLExt(t *testing.T) {
|
||||
cases := []struct {
|
||||
codec string
|
||||
want string
|
||||
}{
|
||||
{"ass", ".ass"},
|
||||
{"ssa", ".ass"},
|
||||
{"pgs", ".sup"},
|
||||
{"hdmv_pgs_subtitle", ".sup"},
|
||||
{"subrip", ".vtt"},
|
||||
{"srt", ".vtt"},
|
||||
{"", ".vtt"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := subtitleURLExt(tc.codec); got != tc.want {
|
||||
t.Errorf("subtitleURLExt(%q) = %q, want %q", tc.codec, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSubtitleURLs_IncludesPGSButNotOtherBitmaps(t *testing.T) {
|
||||
file := &models.MediaFile{
|
||||
SubtitleTracks: []models.SubtitleTrack{
|
||||
{Index: 0, Language: "en", Codec: "subrip"},
|
||||
{Index: 1, Language: "en", Codec: "hdmv_pgs_subtitle"},
|
||||
{Index: 2, Language: "fr", Codec: "dvd_subtitle"},
|
||||
{Index: 3, Language: "de", Codec: "dvb_subtitle"},
|
||||
},
|
||||
}
|
||||
|
||||
urls := buildSubtitleURLs("sess-1", file, nil)
|
||||
|
||||
if len(urls) != 2 {
|
||||
t.Fatalf("expected 2 subtitle URLs (text + PGS), got %d: %+v", len(urls), urls)
|
||||
}
|
||||
|
||||
srt := urls[0]
|
||||
if srt.Codec != "subrip" || srt.URL != "/stream/sess-1/subtitles/0.vtt" {
|
||||
t.Errorf("unexpected text track entry: %+v", srt)
|
||||
}
|
||||
|
||||
pgs := urls[1]
|
||||
if pgs.Codec != "hdmv_pgs_subtitle" {
|
||||
t.Errorf("expected PGS track to be included, got %+v", pgs)
|
||||
}
|
||||
if pgs.URL != "/stream/sess-1/subtitles/1.sup" {
|
||||
t.Errorf("PGS track should get a .sup URL, got %q", pgs.URL)
|
||||
}
|
||||
if pgs.FontBundleURL != "" {
|
||||
t.Errorf("PGS track must not advertise a font bundle, got %q", pgs.FontBundleURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSubtitleURLs_PGSIndexAccountsForExternalOffset(t *testing.T) {
|
||||
file := &models.MediaFile{
|
||||
ExternalSubtitles: []models.ExternalSubtitle{
|
||||
{Path: "/media/movie.en.srt", Language: "en", Format: "srt"},
|
||||
},
|
||||
SubtitleTracks: []models.SubtitleTrack{
|
||||
{Index: 0, Language: "en", Codec: "pgs"},
|
||||
},
|
||||
}
|
||||
|
||||
urls := buildSubtitleURLs("sess-2", file, nil)
|
||||
|
||||
if len(urls) != 2 {
|
||||
t.Fatalf("expected 2 subtitle URLs, got %d: %+v", len(urls), urls)
|
||||
}
|
||||
pgs := urls[1]
|
||||
if pgs.Index != 1 || pgs.URL != "/stream/sess-2/subtitles/1.sup" {
|
||||
t.Errorf("PGS track index should include the external offset, got %+v", pgs)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
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/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/nodepool"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
@@ -182,6 +183,16 @@ func writePlaybackTestFFmpeg(t *testing.T) string {
|
||||
return path
|
||||
}
|
||||
|
||||
func playbackTestConfig(ffmpegPath, transcodeDir string) func() config.PlaybackConfig {
|
||||
return func() config.PlaybackConfig {
|
||||
return config.PlaybackConfig{
|
||||
FFmpegPath: ffmpegPath,
|
||||
TranscodeDir: transcodeDir,
|
||||
TranscodeEnabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writePlaybackTestMediaFile(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -1173,8 +1184,7 @@ func TestHandleStartTranscode_LocalPathPropagatesSelectedAudioTrack(t *testing.T
|
||||
|
||||
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
handler.FFmpegPath = writePlaybackTestFFmpeg(t)
|
||||
handler.TranscodeDir = t.TempDir()
|
||||
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","audio_track_index":1,"codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`))
|
||||
req = req.WithContext(newAuthorizedPlaybackContext())
|
||||
@@ -1239,8 +1249,7 @@ func TestHandleStartTranscode_MPEG2SeekedCopyRemainsCopyVideo(t *testing.T) {
|
||||
|
||||
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
handler.FFmpegPath = writePlaybackTestFFmpeg(t)
|
||||
handler.TranscodeDir = t.TempDir()
|
||||
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
|
||||
|
||||
transcodeReq := httptest.NewRequest(
|
||||
"POST",
|
||||
@@ -1403,7 +1412,7 @@ func TestHandleStartTranscode_KeepsSessionWhenStartupFailsForNonMissingReason(t
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
handler.SessionSyncer = syncer
|
||||
handler.AdminStore = adminStore
|
||||
handler.TranscodeDir = writePlaybackTestMediaFile(t, "occupied-transcode-dir")
|
||||
handler.PlaybackConfig = playbackTestConfig("", writePlaybackTestMediaFile(t, "occupied-transcode-dir"))
|
||||
|
||||
startReq := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`))
|
||||
startReq = startReq.WithContext(newAuthorizedPlaybackContext())
|
||||
|
||||
@@ -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
|
||||
}())
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
@@ -31,10 +32,20 @@ type StreamHandler struct {
|
||||
EventsHub *evt.Hub
|
||||
AdminStore PlaybackAdminStore
|
||||
SessionSyncer PlaybackSessionSyncer
|
||||
FFmpegPath string
|
||||
SubtitleRepo subtitles.Repository // optional; enables S3-sourced subtitles
|
||||
S3Client subtitles.S3Client // optional; needed for fetching S3 subtitles
|
||||
S3Bucket string // bucket for subtitle storage
|
||||
// PlaybackConfig returns the current playback config; read it through
|
||||
// ffmpegPath(). May be nil (tests).
|
||||
PlaybackConfig func() config.PlaybackConfig
|
||||
SubtitleRepo subtitles.Repository // optional; enables S3-sourced subtitles
|
||||
S3Client subtitles.S3Client // optional; needed for fetching S3 subtitles
|
||||
S3Bucket string // bucket for subtitle storage
|
||||
}
|
||||
|
||||
// ffmpegPath returns the currently configured ffmpeg binary path.
|
||||
func (h *StreamHandler) ffmpegPath() string {
|
||||
if h.PlaybackConfig != nil {
|
||||
return h.PlaybackConfig().FFmpegPath
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// NewStreamHandler creates a new StreamHandler backed by the given session
|
||||
@@ -193,7 +204,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
vttData, err := playback.LoadExternalSubtitleAsVTT(r.Context(), sub.Path, sub.Format, h.FFmpegPath)
|
||||
vttData, err := playback.LoadExternalSubtitleAsVTT(r.Context(), sub.Path, sub.Format, h.ffmpegPath())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error",
|
||||
"Failed to load external subtitle")
|
||||
@@ -208,7 +219,10 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
// Check embedded tracks.
|
||||
if embeddedIndex < len(file.SubtitleTracks) {
|
||||
track := file.SubtitleTracks[embeddedIndex]
|
||||
if playback.NeedsBurnIn(track.Codec) {
|
||||
// PGS is the one bitmap codec we can deliver without burn-in: the
|
||||
// track is copied losslessly into a .sup stream and rendered
|
||||
// client-side. DVD/DVB bitmap subs still require burn-in.
|
||||
if playback.NeedsBurnIn(track.Codec) && !playback.IsPGS(track.Codec) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"Bitmap subtitle tracks cannot be extracted as text")
|
||||
return
|
||||
@@ -324,7 +338,7 @@ func (h *StreamHandler) HandleSubtitleFonts(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
fonts, err := playback.ExtractAttachedSubtitleFonts(r.Context(), file.FilePath, h.FFmpegPath)
|
||||
fonts, err := playback.ExtractAttachedSubtitleFonts(r.Context(), file.FilePath, h.ffmpegPath())
|
||||
if err != nil {
|
||||
slog.WarnContext(r.Context(), "subtitle font extraction failed",
|
||||
"file_id", file.ID,
|
||||
@@ -406,12 +420,24 @@ func (h *StreamHandler) handleTransportStartFailure(ctx context.Context, session
|
||||
func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Request, file *models.MediaFile, embeddedIndex int, session *playback.Session) {
|
||||
track := file.SubtitleTracks[embeddedIndex]
|
||||
outFormat := "vtt"
|
||||
if playback.IsASS(track.Codec) {
|
||||
switch {
|
||||
case playback.IsASS(track.Codec):
|
||||
outFormat = "ass"
|
||||
case playback.IsPGS(track.Codec):
|
||||
outFormat = "sup"
|
||||
}
|
||||
|
||||
seek := subtitleSeekPosition(r, session)
|
||||
duration := subtitleWindowDuration(r)
|
||||
// ASS and PGS are fetched exactly once and consumed whole by their
|
||||
// client-side renderers (JASSUB / libpgs), so they must never be
|
||||
// windowed. Note subtitleSeekPosition falls back to the session's
|
||||
// last reported position even without a ?position= query — relying
|
||||
// on StreamExtractSubtitle's codec guard alone would still log a
|
||||
// misleading nonzero seek here.
|
||||
var seek, duration float64
|
||||
if outFormat == "vtt" {
|
||||
seek = subtitleSeekPosition(r, session)
|
||||
duration = subtitleWindowDuration(r)
|
||||
}
|
||||
slog.InfoContext(r.Context(), "subtitle stream requested",
|
||||
"file_id", file.ID,
|
||||
"embedded_index", embeddedIndex,
|
||||
@@ -422,9 +448,12 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re
|
||||
"duration_seconds", duration,
|
||||
)
|
||||
|
||||
if outFormat == "ass" {
|
||||
switch outFormat {
|
||||
case "ass":
|
||||
w.Header().Set("Content-Type", "text/x-ssa; charset=utf-8")
|
||||
} else {
|
||||
case "sup":
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
default:
|
||||
w.Header().Set("Content-Type", "text/vtt; charset=utf-8")
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
@@ -437,7 +466,7 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re
|
||||
SourceCodec: track.Codec,
|
||||
SeekSeconds: seek,
|
||||
DurationSeconds: duration,
|
||||
FFmpegPath: h.FFmpegPath,
|
||||
FFmpegPath: h.ffmpegPath(),
|
||||
Writer: w,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
+198
-48
@@ -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"
|
||||
@@ -73,7 +75,13 @@ import (
|
||||
|
||||
// Dependencies holds all shared dependencies that handlers need.
|
||||
type Dependencies struct {
|
||||
Config *config.Config
|
||||
Config *config.Config
|
||||
// LiveConfig returns the current hot-reloaded config. May be nil (tests,
|
||||
// worker modes); read through CurrentConfig(), which falls back to Config.
|
||||
LiveConfig func() *config.Config
|
||||
// OnConfigChange registers a callback fired after a live config reload
|
||||
// actually changes the config. May be nil when hot reload is not wired.
|
||||
OnConfigChange func(fn func(old, updated *config.Config))
|
||||
BootstrapSensitiveConfigured map[string]bool
|
||||
BootstrapSensitiveValues map[string]string
|
||||
AppContext context.Context
|
||||
@@ -169,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.
|
||||
@@ -177,6 +202,17 @@ type absHandler interface {
|
||||
Mount(r chi.Router)
|
||||
}
|
||||
|
||||
// CurrentConfig returns the live config when hot reload is wired, falling
|
||||
// back to the startup snapshot otherwise.
|
||||
func (d *Dependencies) CurrentConfig() *config.Config {
|
||||
if d.LiveConfig != nil {
|
||||
if cfg := d.LiveConfig(); cfg != nil {
|
||||
return cfg
|
||||
}
|
||||
}
|
||||
return d.Config
|
||||
}
|
||||
|
||||
// NewRouter creates a chi.Router with all middleware and routes mounted
|
||||
// under /api/v1/. ABS-compat routes (/abs/*, /login, /socket.io/*) are
|
||||
// mounted at the root level when deps.ABSHandler is non-nil.
|
||||
@@ -262,6 +298,12 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
deps.Config.Auth.AccessTokenExpiry,
|
||||
deps.Config.Auth.RefreshTokenExpiry,
|
||||
)
|
||||
if deps.OnConfigChange != nil {
|
||||
jwtForReload := jwtService
|
||||
deps.OnConfigChange(func(_, updated *config.Config) {
|
||||
jwtForReload.SetExpiries(updated.Auth.AccessTokenExpiry, updated.Auth.RefreshTokenExpiry)
|
||||
})
|
||||
}
|
||||
provider := auth.NewLocalProvider(userRepo, sessionRepo)
|
||||
authService = auth.NewService(
|
||||
provider,
|
||||
@@ -474,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))
|
||||
@@ -663,13 +706,13 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
playbackHandler.JWTSecret = deps.Config.Auth.JWTSecret
|
||||
}
|
||||
if deps.Config != nil {
|
||||
playbackHandler.FFmpegPath = deps.Config.Playback.FFmpegPath
|
||||
playbackHandler.HWAccel = deps.Config.Playback.HWAccel
|
||||
playbackHandler.TranscodeDir = deps.Config.Playback.TranscodeDir
|
||||
playbackHandler.PlaybackConfig = func() config.PlaybackConfig {
|
||||
return deps.CurrentConfig().Playback
|
||||
}
|
||||
if cleaned, err := playbackHandler.CleanupOrphanedTranscodes(); err != nil {
|
||||
slog.Warn("playback transcode cleanup failed", "dir", playbackHandler.TranscodeDir, "error", err)
|
||||
slog.Warn("playback transcode cleanup failed", "dir", deps.Config.Playback.TranscodeDir, "error", err)
|
||||
} else if cleaned > 0 {
|
||||
slog.Info("playback transcode cleanup removed orphaned dirs", "dir", playbackHandler.TranscodeDir, "count", cleaned)
|
||||
slog.Info("playback transcode cleanup removed orphaned dirs", "dir", deps.Config.Playback.TranscodeDir, "count", cleaned)
|
||||
}
|
||||
}
|
||||
playbackHandler.ProbeEnsurer = deps.ProbeEnsurer
|
||||
@@ -732,7 +775,9 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
streamHandler.S3Bucket = deps.S3Public.Bucket()
|
||||
}
|
||||
if streamHandler != nil && deps.Config != nil {
|
||||
streamHandler.FFmpegPath = deps.Config.Playback.FFmpegPath
|
||||
streamHandler.PlaybackConfig = func() config.PlaybackConfig {
|
||||
return deps.CurrentConfig().Playback
|
||||
}
|
||||
}
|
||||
|
||||
serverControlHandler := handlers.NewServerControlHandler(deps.RequestServerRestart, playbackCommandDispatcher)
|
||||
@@ -914,58 +959,40 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
// the existing subtitle pipeline with no client changes.
|
||||
// Shared AI endpoint client + dispatch semaphore: subtitle translation/ASR
|
||||
// and metadata translation draw from one client and one concurrency bound.
|
||||
// Connection settings, models, toggles, and quotas hot-reload through
|
||||
// OnConfigChange; only the semaphore size (ai.max_concurrent_jobs) is
|
||||
// fixed at construction.
|
||||
var aiClient *llm.Client
|
||||
var aiSem chan struct{}
|
||||
if deps.Config != nil {
|
||||
aiClient = llm.NewClient(llm.Config{
|
||||
BaseURL: deps.Config.AI.BaseURL,
|
||||
APIKey: deps.Config.AI.APIKey,
|
||||
ChatModel: deps.Config.AI.ChatModel,
|
||||
ASRBaseURL: deps.Config.AI.ASRBaseURL,
|
||||
ASRAPIKey: deps.Config.AI.ASRAPIKey,
|
||||
ASRModel: deps.Config.AI.ASRModel,
|
||||
})
|
||||
aiClient = llm.NewClient(llmConfigFromServer(deps.Config))
|
||||
aiSem = jobrunner.NewSemaphore(deps.Config.AI.MaxConcurrentJobs)
|
||||
if deps.OnConfigChange != nil {
|
||||
clientForReload := aiClient
|
||||
deps.OnConfigChange(func(_, updated *config.Config) {
|
||||
clientForReload.UpdateConfig(llmConfigFromServer(updated))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var subtitleAIHandler *handlers.SubtitleAIHandler
|
||||
if subtitleManager != nil && subtitleRepo != nil && deps.FileRepo != nil && deps.DB != nil && deps.Config != nil {
|
||||
// A chat-only gateway (e.g. OpenRouter) cannot produce timestamped
|
||||
// transcriptions; disable ASR rather than let every job fail. The
|
||||
// settings API rejects such values for the ASR URL, but the chat base
|
||||
// URL legitimately may be one — this catches the blank-ASR-URL
|
||||
// fallback case.
|
||||
transcribeEnabled := deps.Config.SubtitleAI.TranscribeEnabled
|
||||
effectiveASRBase := deps.Config.AI.ASRBaseURL
|
||||
if effectiveASRBase == "" {
|
||||
effectiveASRBase = deps.Config.AI.BaseURL
|
||||
}
|
||||
if transcribeEnabled && llm.IsChatOnlyGateway(effectiveASRBase) {
|
||||
slog.Warn("subtitle transcription disabled: the effective transcription endpoint is a chat-only gateway; "+
|
||||
"set a Whisper-compatible Transcription base URL in AI Services", "endpoint", effectiveASRBase)
|
||||
transcribeEnabled = false
|
||||
}
|
||||
aiCfg := subtitleai.Config{
|
||||
Configured: deps.Config.AI.BaseURL != "",
|
||||
TranslateEnabled: deps.Config.SubtitleAI.Enabled,
|
||||
TranscribeEnabled: transcribeEnabled,
|
||||
ChatModel: deps.Config.AI.ChatModel,
|
||||
ASRModel: deps.Config.AI.ASRModel,
|
||||
BatchSize: deps.Config.SubtitleAI.BatchSize,
|
||||
ContextNeighbors: deps.Config.SubtitleAI.ContextNeighbors,
|
||||
TranscribeQuotaJobs: deps.Config.SubtitleAI.TranscribeQuotaJobs,
|
||||
TranscribeQuotaPeriod: deps.Config.SubtitleAI.TranscribeQuotaPeriod,
|
||||
aiCfg, disabledGateway := effectiveSubtitleAIConfig(deps.Config)
|
||||
if disabledGateway != "" {
|
||||
warnChatOnlyGateway(disabledGateway)
|
||||
}
|
||||
var aiNotifier subtitleai.Notifier
|
||||
if subtitleAINotifier != nil {
|
||||
aiNotifier = subtitleAINotifier
|
||||
}
|
||||
aiTranslator := subtitleai.NewLLMTranslator(aiClient, aiCfg.BatchSize, aiCfg.ContextNeighbors)
|
||||
aiTranscriber := subtitleai.NewWhisperTranscriber(aiClient, deps.Config.Playback.FFmpegPath, deps.Config.SubtitleAI.ASRChunkSeconds)
|
||||
aiService := subtitleai.NewService(
|
||||
deps.AppContext,
|
||||
aiCfg,
|
||||
subtitleai.NewPgJobRepository(deps.DB),
|
||||
subtitleai.NewLLMTranslator(aiClient, aiCfg.BatchSize, aiCfg.ContextNeighbors),
|
||||
subtitleai.NewWhisperTranscriber(aiClient, deps.Config.Playback.FFmpegPath, deps.Config.SubtitleAI.ASRChunkSeconds),
|
||||
aiTranslator,
|
||||
aiTranscriber,
|
||||
subtitleManager,
|
||||
subtitleRepo,
|
||||
deps.FileRepo,
|
||||
@@ -975,6 +1002,21 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
aiSem,
|
||||
)
|
||||
aiService.Recover()
|
||||
if deps.OnConfigChange != nil {
|
||||
deps.OnConfigChange(func(old, updated *config.Config) {
|
||||
newCfg, newDisabled := effectiveSubtitleAIConfig(updated)
|
||||
aiService.UpdateConfig(newCfg)
|
||||
aiTranslator.SetBatching(updated.SubtitleAI.BatchSize, updated.SubtitleAI.ContextNeighbors)
|
||||
aiTranscriber.SetExtraction(updated.Playback.FFmpegPath, updated.SubtitleAI.ASRChunkSeconds)
|
||||
// Warn only when the gateway-disable condition newly appears,
|
||||
// not on every unrelated settings change.
|
||||
if newDisabled != "" && old != nil {
|
||||
if _, oldDisabled := effectiveSubtitleAIConfig(old); oldDisabled == "" {
|
||||
warnChatOnlyGateway(newDisabled)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
subtitleAIHandler = handlers.NewSubtitleAIHandler(aiService)
|
||||
subtitleAIHandler.StoreProvider = deps.UserStoreProvider
|
||||
}
|
||||
@@ -985,12 +1027,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
mtRepo := metadatatranslation.NewPgRepository(deps.DB)
|
||||
mtService := metadatatranslation.NewService(
|
||||
deps.AppContext,
|
||||
metadatatranslation.Config{
|
||||
Enabled: deps.Config.MetadataAI.Enabled,
|
||||
Configured: deps.Config.AI.BaseURL != "",
|
||||
ChatModel: deps.Config.AI.ChatModel,
|
||||
OnView: deps.Config.MetadataAI.OnView,
|
||||
},
|
||||
metadataAIConfigFromServer(deps.Config),
|
||||
mtRepo,
|
||||
mtRepo,
|
||||
&metadatatranslation.CatalogLocalizationStore{
|
||||
@@ -1003,6 +1040,11 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
slog.Default(),
|
||||
)
|
||||
mtService.Recover()
|
||||
if deps.OnConfigChange != nil {
|
||||
deps.OnConfigChange(func(_, updated *config.Config) {
|
||||
mtService.UpdateConfig(metadataAIConfigFromServer(updated))
|
||||
})
|
||||
}
|
||||
metadataAIHandler = handlers.NewMetadataAIHandler(mtService)
|
||||
// Wire the refresh fallback: libraries with auto_translate_metadata get
|
||||
// missing localizations filled after each metadata refresh.
|
||||
@@ -1471,6 +1513,8 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
deps.ScanRegistry,
|
||||
deps.LibraryScanQueue,
|
||||
historyImportSvc,
|
||||
deps.NotificationsService,
|
||||
deps.PresenceRegistry,
|
||||
)
|
||||
r.Get("/events/ws", eventsHandler.HandleWebSocket)
|
||||
}
|
||||
@@ -1619,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) {
|
||||
@@ -2373,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)
|
||||
@@ -2661,3 +2751,63 @@ func (a *traktCollectionAdapter) GetCollectionPreset(ctx context.Context, preset
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// llmConfigFromServer derives the shared AI client config from the server
|
||||
// config. Used at construction and again on every config reload.
|
||||
func llmConfigFromServer(cfg *config.Config) llm.Config {
|
||||
return llm.Config{
|
||||
BaseURL: cfg.AI.BaseURL,
|
||||
APIKey: cfg.AI.APIKey,
|
||||
ChatModel: cfg.AI.ChatModel,
|
||||
ASRBaseURL: cfg.AI.ASRBaseURL,
|
||||
ASRAPIKey: cfg.AI.ASRAPIKey,
|
||||
ASRModel: cfg.AI.ASRModel,
|
||||
}
|
||||
}
|
||||
|
||||
// effectiveSubtitleAIConfig derives the subtitle AI service config from the
|
||||
// server config. A chat-only gateway (e.g. OpenRouter) cannot produce
|
||||
// timestamped transcriptions, so transcription is disabled rather than
|
||||
// letting every job fail; the settings API rejects such values for the ASR
|
||||
// URL, but the chat base URL legitimately may be one — this catches the
|
||||
// blank-ASR-URL fallback case. The second return is the offending endpoint
|
||||
// when that guard fired, empty otherwise.
|
||||
func effectiveSubtitleAIConfig(cfg *config.Config) (subtitleai.Config, string) {
|
||||
transcribeEnabled := cfg.SubtitleAI.TranscribeEnabled
|
||||
effectiveASRBase := cfg.AI.ASRBaseURL
|
||||
if effectiveASRBase == "" {
|
||||
effectiveASRBase = cfg.AI.BaseURL
|
||||
}
|
||||
disabledGateway := ""
|
||||
if transcribeEnabled && llm.IsChatOnlyGateway(effectiveASRBase) {
|
||||
transcribeEnabled = false
|
||||
disabledGateway = effectiveASRBase
|
||||
}
|
||||
return subtitleai.Config{
|
||||
Configured: cfg.AI.BaseURL != "",
|
||||
TranslateEnabled: cfg.SubtitleAI.Enabled,
|
||||
TranscribeEnabled: transcribeEnabled,
|
||||
ChatModel: cfg.AI.ChatModel,
|
||||
ASRModel: cfg.AI.ASRModel,
|
||||
BatchSize: cfg.SubtitleAI.BatchSize,
|
||||
ContextNeighbors: cfg.SubtitleAI.ContextNeighbors,
|
||||
TranscribeQuotaJobs: cfg.SubtitleAI.TranscribeQuotaJobs,
|
||||
TranscribeQuotaPeriod: cfg.SubtitleAI.TranscribeQuotaPeriod,
|
||||
}, disabledGateway
|
||||
}
|
||||
|
||||
func warnChatOnlyGateway(endpoint string) {
|
||||
slog.Warn("subtitle transcription disabled: the effective transcription endpoint is a chat-only gateway; "+
|
||||
"set a Whisper-compatible Transcription base URL in AI Services", "endpoint", endpoint)
|
||||
}
|
||||
|
||||
// metadataAIConfigFromServer derives the metadata translation service config
|
||||
// from the server config. Used at construction and on every config reload.
|
||||
func metadataAIConfigFromServer(cfg *config.Config) metadatatranslation.Config {
|
||||
return metadatatranslation.Config{
|
||||
Enabled: cfg.MetadataAI.Enabled,
|
||||
Configured: cfg.AI.BaseURL != "",
|
||||
ChatModel: cfg.AI.ChatModel,
|
||||
OnView: cfg.MetadataAI.OnView,
|
||||
}
|
||||
}
|
||||
|
||||
+20
-11
@@ -3,6 +3,7 @@ package auth
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -36,29 +37,37 @@ const (
|
||||
const PluginAccessCookieName = "silo_plugin_access"
|
||||
|
||||
// JWTService handles JWT token generation and validation using HMAC-SHA256.
|
||||
// The expiry durations are atomics so they can be hot-reloaded from admin
|
||||
// settings while tokens are being generated; the secret is fixed for the
|
||||
// service lifetime (changing it would invalidate every outstanding token).
|
||||
type JWTService struct {
|
||||
secret []byte
|
||||
accessExpiry time.Duration
|
||||
refreshExpiry time.Duration
|
||||
accessExpiry atomic.Int64 // nanoseconds
|
||||
refreshExpiry atomic.Int64 // nanoseconds
|
||||
}
|
||||
|
||||
// NewJWTService creates a new JWTService with the given secret and expiry durations.
|
||||
func NewJWTService(secret string, accessExpiry, refreshExpiry time.Duration) *JWTService {
|
||||
return &JWTService{
|
||||
secret: []byte(secret),
|
||||
accessExpiry: accessExpiry,
|
||||
refreshExpiry: refreshExpiry,
|
||||
}
|
||||
j := &JWTService{secret: []byte(secret)}
|
||||
j.SetExpiries(accessExpiry, refreshExpiry)
|
||||
return j
|
||||
}
|
||||
|
||||
// SetExpiries updates the token expiry durations. Safe for concurrent use;
|
||||
// applies to tokens generated afterwards.
|
||||
func (j *JWTService) SetExpiries(access, refresh time.Duration) {
|
||||
j.accessExpiry.Store(int64(access))
|
||||
j.refreshExpiry.Store(int64(refresh))
|
||||
}
|
||||
|
||||
// AccessExpiry returns the configured access token expiry duration.
|
||||
func (j *JWTService) AccessExpiry() time.Duration {
|
||||
return j.accessExpiry
|
||||
return time.Duration(j.accessExpiry.Load())
|
||||
}
|
||||
|
||||
// RefreshExpiry returns the configured refresh token expiry duration.
|
||||
func (j *JWTService) RefreshExpiry() time.Duration {
|
||||
return j.refreshExpiry
|
||||
return time.Duration(j.refreshExpiry.Load())
|
||||
}
|
||||
|
||||
// GenerateAccessToken creates a signed JWT access token with the configured
|
||||
@@ -93,11 +102,11 @@ func (j *JWTService) GeneratePluginAccessToken(userID int, role, sessionID strin
|
||||
}
|
||||
|
||||
func (j *JWTService) generateAccessToken(claims Claims) (string, error) {
|
||||
return j.generateToken(claims, TokenTypeAccess, j.accessExpiry)
|
||||
return j.generateToken(claims, TokenTypeAccess, j.AccessExpiry())
|
||||
}
|
||||
|
||||
func (j *JWTService) generateRefreshToken(claims Claims) (string, error) {
|
||||
return j.generateToken(claims, TokenTypeRefresh, j.refreshExpiry)
|
||||
return j.generateToken(claims, TokenTypeRefresh, j.RefreshExpiry())
|
||||
}
|
||||
|
||||
// generateToken creates a signed JWT with the given claims and expiry duration.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -127,16 +127,15 @@ type userDBConfigRaw struct {
|
||||
|
||||
// ScannerConfig holds media scanner settings.
|
||||
type ScannerConfig struct {
|
||||
FileRemovalGrace time.Duration `yaml:"-"`
|
||||
Workers int `yaml:"workers"`
|
||||
MaxConcurrentLibraries int `yaml:"max_concurrent_libraries"`
|
||||
MaxConcurrentScoped int `yaml:"max_concurrent_scoped"`
|
||||
EmptyTrashAfterScan bool `yaml:"-"`
|
||||
Workers int `yaml:"workers"`
|
||||
MaxConcurrentLibraries int `yaml:"max_concurrent_libraries"`
|
||||
MaxConcurrentScoped int `yaml:"max_concurrent_scoped"`
|
||||
EmptyTrashAfterScan bool `yaml:"-"`
|
||||
}
|
||||
|
||||
// scannerConfigRaw is the raw YAML representation with duration strings.
|
||||
type scannerConfigRaw struct {
|
||||
FileRemovalGrace string `yaml:"file_removal_grace"`
|
||||
FileRemovalGrace string `yaml:"file_removal_grace"` // legacy; preserved on YAML import only
|
||||
Workers int `yaml:"workers"`
|
||||
MaxConcurrentLibraries int `yaml:"max_concurrent_libraries"`
|
||||
MaxConcurrentScoped int `yaml:"max_concurrent_scoped"`
|
||||
@@ -165,9 +164,6 @@ type PlaybackConfig struct {
|
||||
ChapterThumbnailExecution string `yaml:"chapter_thumbnail_execution"`
|
||||
ChapterThumbnailNodeCapacity int `yaml:"chapter_thumbnail_node_capacity"`
|
||||
TranscodeEnabled bool `yaml:"transcode_enabled"`
|
||||
AllowHEVCEncoding bool `yaml:"allow_hevc_encoding"`
|
||||
TranscodeAheadSegments int `yaml:"transcode_ahead_segments"`
|
||||
SegmentDuration int `yaml:"segment_duration"`
|
||||
}
|
||||
|
||||
// RedisConfig holds Redis connection settings.
|
||||
@@ -414,7 +410,6 @@ func setDefaults() *configRaw {
|
||||
StaleGraceSeconds: 120,
|
||||
},
|
||||
Scanner: scannerConfigRaw{
|
||||
FileRemovalGrace: "24h",
|
||||
Workers: 8,
|
||||
MaxConcurrentLibraries: 1,
|
||||
MaxConcurrentScoped: 2,
|
||||
@@ -432,9 +427,6 @@ func setDefaults() *configRaw {
|
||||
ChapterThumbnailExecution: "local",
|
||||
ChapterThumbnailNodeCapacity: 1,
|
||||
TranscodeEnabled: true,
|
||||
AllowHEVCEncoding: false,
|
||||
TranscodeAheadSegments: 30,
|
||||
SegmentDuration: 6,
|
||||
},
|
||||
RateLimit: RateLimitConfig{
|
||||
Enabled: true,
|
||||
|
||||
@@ -253,11 +253,6 @@ func LoadFromDB(m map[string]string) (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Scanner.MaxConcurrentScoped = maxConcurrentScoped
|
||||
fileRemovalGrace, err := durationOr(m, "scanner.file_removal_grace", 24*time.Hour)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Scanner.FileRemovalGrace = fileRemovalGrace
|
||||
emptyTrash, err := boolOr(m, "scanner.empty_trash_after_scan", true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -315,21 +310,6 @@ func LoadFromDB(m map[string]string) (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Playback.TranscodeEnabled = transcodeEnabled
|
||||
allowHEVC, err := boolOr(m, "playback.allow_hevc_encoding", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Playback.AllowHEVCEncoding = allowHEVC
|
||||
transcodeAheadSegments, err := intOr(m, "playback.transcode_ahead_segments", 30)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Playback.TranscodeAheadSegments = transcodeAheadSegments
|
||||
segmentDuration, err := intOr(m, "playback.segment_duration", 6)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Playback.SegmentDuration = segmentDuration
|
||||
|
||||
// Redis
|
||||
cfg.Redis.URL = stringOr(m, "redis.url", "")
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package config
|
||||
|
||||
import "strings"
|
||||
|
||||
// restartRequiredKeys lists server_settings keys whose values are captured at
|
||||
// process startup (listeners, connection pools, HTTP clients, worker pools)
|
||||
// and cannot take effect until the server restarts.
|
||||
//
|
||||
// Keys absent from this map and from restartRequiredPrefixes apply without a
|
||||
// restart: they are either read live from the settings repo at request time
|
||||
// (overlays, branding, markers, download.*, ...) or hot-reloaded through the
|
||||
// nodeconfig watcher. When converting a frozen consumer to the live config,
|
||||
// remove its key here — the admin UI restart banner is driven by this
|
||||
// registry.
|
||||
var restartRequiredKeys = map[string]bool{
|
||||
// Process & logging. log_level/log_quiet hot-reload via the config
|
||||
// watcher (shared slog.LevelVar + logfilter.SetQuiet).
|
||||
"server.listen": true,
|
||||
"server.mode": true,
|
||||
"server.log_format": true,
|
||||
"opslog.capture_level": true,
|
||||
|
||||
// Auth. The JWT secret is baked into token services and stream signers;
|
||||
// the expiry durations hot-reload (new tokens only).
|
||||
"auth.jwt_secret": true,
|
||||
|
||||
// Rate limiting gates middleware construction; tier/limit values inside
|
||||
// the dedicated /admin/rate-limits endpoint hot-reload and report their
|
||||
// own restart state.
|
||||
"ratelimit.enabled": true,
|
||||
"ratelimit.backend": true,
|
||||
|
||||
// Playback transcode infrastructure. The playback/stream handlers read
|
||||
// ffmpeg path and hwaccel live (new transcode sessions), but several
|
||||
// startup-built consumers still freeze them (scanner ffprobe, chapter
|
||||
// thumbnails, audiobook enricher) — keep restart-required until those
|
||||
// convert. transcode_dir is fully live (only the playback handler reads
|
||||
// it). The chapter-thumbnail worker pool is sized at construction.
|
||||
"playback.ffmpeg_path": true,
|
||||
"playback.hw_accel": true,
|
||||
"playback.hw_device": true,
|
||||
"playback.chapter_thumbnail_workers": true,
|
||||
|
||||
// Scanner / matcher toggles captured at construction. Worker counts,
|
||||
// batch size, metadata.cache_images, and mdblist.api_key hot-reload via
|
||||
// atomic setters wired to the config watcher.
|
||||
"scanner.max_concurrent_libraries": true,
|
||||
"scanner.max_concurrent_scoped": true,
|
||||
"scanner.empty_trash_after_scan": true,
|
||||
"matcher.enable_tv_series_root_queue": true,
|
||||
"matcher.enable_tv_series_group_queue": true,
|
||||
|
||||
// External API clients built once at startup.
|
||||
"tmdb.api_key": true,
|
||||
|
||||
// Compat listeners and session stores.
|
||||
"audiobookshelf_compat.enabled": true,
|
||||
// public_url / server_name / emulated_server_version are read live per
|
||||
// request; server_id is generate-once and baked into the resource
|
||||
// mapper; the session TTLs are baked into store constructors.
|
||||
"jellyfin_compat.listen": true,
|
||||
"jellyfin_compat.server_id": true,
|
||||
"jellyfin_compat.web_version": true,
|
||||
"jellyfin_compat.web_dir": true,
|
||||
"jellyfin_compat.session_ttl": true,
|
||||
"jellyfin_compat.playback_session_ttl": true,
|
||||
|
||||
// AI: connection settings, models, toggles, and quotas hot-reload via
|
||||
// UpdateConfig/setters wired to the config watcher. Only the dispatch
|
||||
// semaphore (ai.max_concurrent_jobs, plus its legacy alias) is a
|
||||
// fixed-capacity channel sized at construction.
|
||||
"ai.max_concurrent_jobs": true,
|
||||
"subtitle_ai.max_concurrent_jobs": true,
|
||||
}
|
||||
|
||||
// restartRequiredPrefixes covers whole namespaces of infrastructure settings:
|
||||
// connection pools and storage clients that are constructed once at startup.
|
||||
var restartRequiredPrefixes = []string{
|
||||
"database.",
|
||||
"userdb.",
|
||||
"s3.",
|
||||
"redis.",
|
||||
"recommendations.",
|
||||
}
|
||||
|
||||
// RestartRequired reports whether changing the given server_settings key
|
||||
// requires a server restart to take effect.
|
||||
func RestartRequired(key string) bool {
|
||||
if restartRequiredKeys[key] {
|
||||
return true
|
||||
}
|
||||
for _, prefix := range restartRequiredPrefixes {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRestartRequired(t *testing.T) {
|
||||
cases := []struct {
|
||||
key string
|
||||
want bool
|
||||
}{
|
||||
// Explicit infrastructure keys.
|
||||
{"server.listen", true},
|
||||
{"auth.jwt_secret", true},
|
||||
{"ratelimit.backend", true},
|
||||
// Prefix-covered namespaces.
|
||||
{"database.max_connections", true},
|
||||
{"userdb.backend", true},
|
||||
{"s3.public_endpoint", true},
|
||||
{"redis.url", true},
|
||||
{"recommendations.enabled", true},
|
||||
// Legacy AI aliases classify like their canonical keys.
|
||||
{"subtitle_ai.max_concurrent_jobs", true},
|
||||
{"subtitle_ai.base_url", false}, // like ai.base_url — hot-reloaded
|
||||
{"ai.api_key", false},
|
||||
{"subtitle_ai.enabled", false},
|
||||
{"metadata_ai.on_view", false},
|
||||
// Live settings read from the settings repo per request.
|
||||
{"branding.server_name", false},
|
||||
{"overlays.enabled", false},
|
||||
{"markers.mode", false},
|
||||
{"download.enabled", false},
|
||||
{"allow_4k_transcode", false},
|
||||
{"defaults.card_overlays", false},
|
||||
// Unknown keys default to live.
|
||||
{"some.future_setting", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := RestartRequired(tc.key); got != tc.want {
|
||||
t.Errorf("RestartRequired(%q) = %v, want %v", tc.key, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,13 +181,6 @@ func YAMLToSettingsMap(path string) (map[string]string, error) {
|
||||
m["playback.chapter_thumbnail_node_capacity"] = strconv.Itoa(raw.Playback.ChapterThumbnailNodeCapacity)
|
||||
}
|
||||
m["playback.transcode_enabled"] = strconv.FormatBool(raw.Playback.TranscodeEnabled)
|
||||
m["playback.allow_hevc_encoding"] = strconv.FormatBool(raw.Playback.AllowHEVCEncoding)
|
||||
if raw.Playback.TranscodeAheadSegments != 0 {
|
||||
m["playback.transcode_ahead_segments"] = strconv.Itoa(raw.Playback.TranscodeAheadSegments)
|
||||
}
|
||||
if raw.Playback.SegmentDuration != 0 {
|
||||
m["playback.segment_duration"] = strconv.Itoa(raw.Playback.SegmentDuration)
|
||||
}
|
||||
|
||||
// Redis
|
||||
m["redis.url"] = raw.Redis.URL
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -315,7 +315,7 @@ func TestValidatePseudoUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleUsers_ReturnsCallerAsSingleElement(t *testing.T) {
|
||||
h := NewAuthHandler(&config.Config{}, nil, nil)
|
||||
h := NewAuthHandler(func() *config.Config { return &config.Config{} }, nil, nil)
|
||||
session := &Session{PseudoUserID: PseudoUserID(2, "p1"), Username: "admin"}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Users", nil)
|
||||
@@ -342,7 +342,7 @@ func TestHandleUsers_ReturnsCallerAsSingleElement(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleUsers_RequiresSession(t *testing.T) {
|
||||
h := NewAuthHandler(&config.Config{}, nil, nil)
|
||||
h := NewAuthHandler(func() *config.Config { return &config.Config{} }, nil, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleUsers(rec, httptest.NewRequest(http.MethodGet, "/Users", nil))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
|
||||
@@ -115,13 +115,14 @@ type loginResolver interface {
|
||||
|
||||
// AuthHandler serves Jellyfin login/current-user routes.
|
||||
type AuthHandler struct {
|
||||
cfg *config.Config
|
||||
cfg func() *config.Config
|
||||
loginResolver loginResolver
|
||||
authenticator *Authenticator
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new auth handler.
|
||||
func NewAuthHandler(cfg *config.Config, loginResolver loginResolver, authenticator *Authenticator) *AuthHandler {
|
||||
// NewAuthHandler creates a new auth handler. The config provider is invoked
|
||||
// per request so compat setting changes apply without restart.
|
||||
func NewAuthHandler(cfg func() *config.Config, loginResolver loginResolver, authenticator *Authenticator) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
cfg: cfg,
|
||||
loginResolver: loginResolver,
|
||||
@@ -160,7 +161,7 @@ func (h *AuthHandler) HandleAuthenticateByName(w http.ResponseWriter, r *http.Re
|
||||
|
||||
writeJSON(w, http.StatusOK, authenticateByNameResponse{
|
||||
AccessToken: session.Token,
|
||||
ServerID: h.cfg.JellyfinCompat.ServerID,
|
||||
ServerID: h.cfg().JellyfinCompat.ServerID,
|
||||
User: h.userDTO(session),
|
||||
SessionInfo: h.sessionInfo(session),
|
||||
})
|
||||
@@ -225,7 +226,7 @@ func (h *AuthHandler) userDTO(session *Session) userDTOResponse {
|
||||
return userDTOResponse{
|
||||
ID: session.PseudoUserID.String(),
|
||||
Name: name,
|
||||
ServerID: h.cfg.JellyfinCompat.ServerID,
|
||||
ServerID: h.cfg().JellyfinCompat.ServerID,
|
||||
HasPassword: true,
|
||||
HasConfiguredPassword: true,
|
||||
HasConfiguredEasyPassword: false,
|
||||
@@ -299,6 +300,6 @@ func (h *AuthHandler) sessionInfo(session *Session) *sessionInfoResponse {
|
||||
SupportsRemoteControl: true,
|
||||
HasCustomDeviceName: false,
|
||||
SupportedCommands: []string{},
|
||||
ServerID: h.cfg.JellyfinCompat.ServerID,
|
||||
ServerID: h.cfg().JellyfinCompat.ServerID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,11 +27,13 @@ type endpointInfoResponse struct {
|
||||
|
||||
// SystemHandler serves Jellyfin setup/system endpoints.
|
||||
type SystemHandler struct {
|
||||
cfg *config.Config
|
||||
cfg func() *config.Config
|
||||
}
|
||||
|
||||
// NewSystemHandler creates a new system handler.
|
||||
func NewSystemHandler(cfg *config.Config) *SystemHandler {
|
||||
// NewSystemHandler creates a new system handler. The config provider is
|
||||
// invoked per request so public URL / server name / emulated version
|
||||
// changes apply without restart.
|
||||
func NewSystemHandler(cfg func() *config.Config) *SystemHandler {
|
||||
return &SystemHandler{cfg: cfg}
|
||||
}
|
||||
|
||||
@@ -60,7 +62,7 @@ func (h *SystemHandler) HandleEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, endpointInfoResponse{
|
||||
IsLocal: true,
|
||||
IsInNetwork: true,
|
||||
Address: h.cfg.JellyfinCompat.PublicURL,
|
||||
Address: h.cfg().JellyfinCompat.PublicURL,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -71,11 +73,11 @@ func (h *SystemHandler) HandlePing(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *SystemHandler) systemInfo() publicSystemInfoResponse {
|
||||
return publicSystemInfoResponse{
|
||||
LocalAddress: h.cfg.JellyfinCompat.PublicURL,
|
||||
ServerName: h.cfg.JellyfinCompat.ServerName,
|
||||
Version: h.cfg.JellyfinCompat.EmulatedServerVersion,
|
||||
LocalAddress: h.cfg().JellyfinCompat.PublicURL,
|
||||
ServerName: h.cfg().JellyfinCompat.ServerName,
|
||||
Version: h.cfg().JellyfinCompat.EmulatedServerVersion,
|
||||
ProductName: "Jellyfin Server",
|
||||
ID: h.cfg.JellyfinCompat.ServerID,
|
||||
ID: h.cfg().JellyfinCompat.ServerID,
|
||||
StartupWizardCompleted: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,12 +61,12 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
r.Use(requestLoggerMiddleware)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
systemHandler := NewSystemHandler(deps.Config)
|
||||
systemHandler := NewSystemHandler(deps.CurrentConfig)
|
||||
webFS, err := resolveCompatWebFS(deps)
|
||||
if err != nil {
|
||||
slog.Error("jellycompat web bundle unavailable", "error", err)
|
||||
}
|
||||
authHandler := NewAuthHandler(deps.Config, deps.LoginResolver, deps.Authenticator)
|
||||
authHandler := NewAuthHandler(deps.CurrentConfig, deps.LoginResolver, deps.Authenticator)
|
||||
nextUpRepo := catalog.NewNextUpRepository(deps.DB, deps.UserStoreProvider)
|
||||
var subtitleRepo subtitles.Repository
|
||||
if deps.SubtitleRepo != nil {
|
||||
|
||||
@@ -23,7 +23,10 @@ import (
|
||||
|
||||
// Dependencies holds the pluggable pieces used by the compat server.
|
||||
type Dependencies struct {
|
||||
Config *config.Config
|
||||
Config *config.Config
|
||||
// LiveConfig returns the current hot-reloaded config. May be nil (tests,
|
||||
// worker modes); read through CurrentConfig(), which falls back to Config.
|
||||
LiveConfig func() *config.Config
|
||||
DB *pgxpool.Pool
|
||||
SecretCipher *secret.Cipher // at-rest credential cipher (required when DB is set)
|
||||
ClientIPResolver *clientip.Resolver
|
||||
@@ -84,6 +87,17 @@ type Dependencies struct {
|
||||
S3Bucket string // optional
|
||||
}
|
||||
|
||||
// CurrentConfig returns the live config when hot reload is wired, falling
|
||||
// back to the startup snapshot otherwise.
|
||||
func (d *Dependencies) CurrentConfig() *config.Config {
|
||||
if d.LiveConfig != nil {
|
||||
if cfg := d.LiveConfig(); cfg != nil {
|
||||
return cfg
|
||||
}
|
||||
}
|
||||
return d.Config
|
||||
}
|
||||
|
||||
// Server wraps the compat HTTP handler.
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
|
||||
@@ -4,23 +4,43 @@ import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Handler wraps an slog.Handler and drops records whose message starts
|
||||
// with any of the configured quiet prefixes (e.g. "metadata:").
|
||||
//
|
||||
// The prefix list is shared through an atomic pointer so SetQuiet applies
|
||||
// to every clone produced by WithAttrs/WithGroup, and is safe to call
|
||||
// concurrently with logging.
|
||||
type Handler struct {
|
||||
inner slog.Handler
|
||||
prefixes []string
|
||||
prefixes *atomic.Pointer[[]string]
|
||||
}
|
||||
|
||||
// New returns a Handler that delegates to inner but silences messages
|
||||
// matching any prefix in quietCSV (comma-separated, e.g. "metadata,scanner").
|
||||
// A colon+space is appended automatically: "metadata" silences "metadata: ...".
|
||||
// If quietCSV is empty, the inner handler is returned directly.
|
||||
func New(inner slog.Handler, quietCSV string) slog.Handler {
|
||||
// The Handler is always returned (even for an empty quietCSV) so the quiet
|
||||
// list can be changed later via SetQuiet.
|
||||
func New(inner slog.Handler, quietCSV string) *Handler {
|
||||
prefixes := &atomic.Pointer[[]string]{}
|
||||
parsed := parseQuiet(quietCSV)
|
||||
prefixes.Store(&parsed)
|
||||
return &Handler{inner: inner, prefixes: prefixes}
|
||||
}
|
||||
|
||||
// SetQuiet replaces the quiet prefix list. It applies immediately to this
|
||||
// handler and every WithAttrs/WithGroup clone derived from it.
|
||||
func (h *Handler) SetQuiet(quietCSV string) {
|
||||
parsed := parseQuiet(quietCSV)
|
||||
h.prefixes.Store(&parsed)
|
||||
}
|
||||
|
||||
func parseQuiet(quietCSV string) []string {
|
||||
trimmed := strings.TrimSpace(quietCSV)
|
||||
if trimmed == "" {
|
||||
return inner
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(trimmed, ",")
|
||||
prefixes := make([]string, 0, len(parts))
|
||||
@@ -30,10 +50,7 @@ func New(inner slog.Handler, quietCSV string) slog.Handler {
|
||||
prefixes = append(prefixes, p+":")
|
||||
}
|
||||
}
|
||||
if len(prefixes) == 0 {
|
||||
return inner
|
||||
}
|
||||
return &Handler{inner: inner, prefixes: prefixes}
|
||||
return prefixes
|
||||
}
|
||||
|
||||
func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
@@ -42,7 +59,7 @@ func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
|
||||
func (h *Handler) Handle(ctx context.Context, r slog.Record) error {
|
||||
msg := r.Message
|
||||
for _, prefix := range h.prefixes {
|
||||
for _, prefix := range *h.prefixes.Load() {
|
||||
if strings.HasPrefix(msg, prefix) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package logfilter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newCapture() (*Handler, *bytes.Buffer) {
|
||||
buf := &bytes.Buffer{}
|
||||
h := New(slog.NewTextHandler(buf, nil), "")
|
||||
return h, buf
|
||||
}
|
||||
|
||||
func TestHandlerFiltersConfiguredPrefixes(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
h := New(slog.NewTextHandler(buf, nil), "metadata, scanner")
|
||||
logger := slog.New(h)
|
||||
|
||||
logger.Info("metadata: cached image")
|
||||
logger.Info("scanner: walked folder")
|
||||
logger.Info("playback: session started")
|
||||
|
||||
out := buf.String()
|
||||
if strings.Contains(out, "metadata:") || strings.Contains(out, "scanner:") {
|
||||
t.Fatalf("quiet-prefixed messages leaked: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "playback: session started") {
|
||||
t.Fatalf("unrelated message was dropped: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetQuietAppliesToClones(t *testing.T) {
|
||||
h, buf := newCapture()
|
||||
|
||||
// Clone first (as slog.With does), then change the quiet list — the
|
||||
// clone must see the update because the prefix list is shared.
|
||||
clone := slog.New(h.WithAttrs([]slog.Attr{slog.String("node", "a")}))
|
||||
clone.Info("metadata: before quiet")
|
||||
h.SetQuiet("metadata")
|
||||
clone.Info("metadata: after quiet")
|
||||
clone.Info("other: still logged")
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "metadata: before quiet") {
|
||||
t.Fatalf("message before SetQuiet was dropped: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "metadata: after quiet") {
|
||||
t.Fatalf("clone did not pick up SetQuiet: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "other: still logged") {
|
||||
t.Fatalf("unrelated message was dropped: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyQuietPassesEverything(t *testing.T) {
|
||||
h, buf := newCapture()
|
||||
logger := slog.New(h)
|
||||
|
||||
logger.Info("metadata: not quiet by default")
|
||||
if !strings.Contains(buf.String(), "metadata: not quiet by default") {
|
||||
t.Fatalf("empty quiet list dropped a message: %s", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -38,9 +39,10 @@ type ListSummary struct {
|
||||
}
|
||||
|
||||
// Client wraps a single MDBList apikey. Construct one per server config; it
|
||||
// is safe for concurrent use because http.Client is.
|
||||
// is safe for concurrent use because http.Client is. The apikey is atomic so
|
||||
// admin settings changes apply to subsequent requests without restart.
|
||||
type Client struct {
|
||||
apiKey string
|
||||
apiKey atomic.Pointer[string]
|
||||
baseURL string
|
||||
http *http.Client
|
||||
}
|
||||
@@ -51,16 +53,31 @@ func NewClient(apiKey string, httpClient *http.Client) *Client {
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
return &Client{
|
||||
apiKey: strings.TrimSpace(apiKey),
|
||||
c := &Client{
|
||||
baseURL: defaultBaseURL,
|
||||
http: httpClient,
|
||||
}
|
||||
c.SetAPIKey(apiKey)
|
||||
return c
|
||||
}
|
||||
|
||||
// SetAPIKey replaces the apikey used for subsequent requests. Safe for
|
||||
// concurrent use.
|
||||
func (c *Client) SetAPIKey(apiKey string) {
|
||||
trimmed := strings.TrimSpace(apiKey)
|
||||
c.apiKey.Store(&trimmed)
|
||||
}
|
||||
|
||||
func (c *Client) currentAPIKey() string {
|
||||
if key := c.apiKey.Load(); key != nil {
|
||||
return *key
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Configured reports whether the client has an apikey set.
|
||||
func (c *Client) Configured() bool {
|
||||
return c.apiKey != ""
|
||||
return c.currentAPIKey() != ""
|
||||
}
|
||||
|
||||
// Search returns lists whose title matches query.
|
||||
@@ -70,7 +87,7 @@ func (c *Client) Search(ctx context.Context, query string) ([]ListSummary, error
|
||||
return nil, fmt.Errorf("query is required")
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("apikey", c.apiKey)
|
||||
q.Set("apikey", c.currentAPIKey())
|
||||
q.Set("query", query)
|
||||
return c.fetchLists(ctx, "/lists/search", q)
|
||||
}
|
||||
@@ -78,7 +95,7 @@ func (c *Client) Search(ctx context.Context, query string) ([]ListSummary, error
|
||||
// Top returns the public top lists ranked by Trakt likes.
|
||||
func (c *Client) Top(ctx context.Context) ([]ListSummary, error) {
|
||||
q := url.Values{}
|
||||
q.Set("apikey", c.apiKey)
|
||||
q.Set("apikey", c.currentAPIKey())
|
||||
return c.fetchLists(ctx, "/lists/top", q)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
@@ -308,7 +309,7 @@ type MetadataService struct {
|
||||
seriesWork map[string]*seriesEpisodeWork
|
||||
hooks metadataServiceHooks
|
||||
imageCacher ImageCacher
|
||||
autoCacheImages bool
|
||||
autoCacheImages atomic.Bool // hot-reloaded from metadata.cache_images
|
||||
imageResolver interface {
|
||||
ResolveImageURL(ctx context.Context, path string, variant string) string
|
||||
}
|
||||
@@ -435,9 +436,9 @@ func (s *MetadataService) SetImageCacher(c ImageCacher) {
|
||||
|
||||
// SetAutoCacheImages controls whether refresh pipelines automatically cache
|
||||
// provider images into object storage. Explicit admin image applies still use
|
||||
// the configured image cacher when available.
|
||||
// the configured image cacher when available. Safe for concurrent use.
|
||||
func (s *MetadataService) SetAutoCacheImages(enabled bool) {
|
||||
s.autoCacheImages = enabled
|
||||
s.autoCacheImages.Store(enabled)
|
||||
}
|
||||
|
||||
// SetImageResolver sets the resolver used to convert plugin-prefixed image
|
||||
@@ -1479,7 +1480,7 @@ func (s *MetadataService) mergeAndPersist(
|
||||
}
|
||||
|
||||
// Cache images to S3 if enabled.
|
||||
if s.autoCacheImages && s.imageCacher != nil && isCanonicalWrite {
|
||||
if s.autoCacheImages.Load() && s.imageCacher != nil && isCanonicalWrite {
|
||||
s.cacheItemImages(ctx, item, images)
|
||||
}
|
||||
|
||||
@@ -2461,7 +2462,7 @@ func (s *MetadataService) fetchTargetEpisodeResults(ctx context.Context, provide
|
||||
// provider primaryProviderID happens to prefer. Failures are logged and
|
||||
// the original path is kept.
|
||||
func (s *MetadataService) cacheSeriesChildImages(ctx context.Context, series *models.MediaItem, providerIDs map[string]string, seasons []SeasonResult, episodes []EpisodeResult) {
|
||||
if s == nil || !s.autoCacheImages || s.imageCacher == nil || series == nil {
|
||||
if s == nil || !s.autoCacheImages.Load() || s.imageCacher == nil || series == nil {
|
||||
return
|
||||
}
|
||||
fallbackProvider := primaryProviderID(providerIDs)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/ai/jobrunner"
|
||||
@@ -47,7 +48,9 @@ const onViewFailureCooldown = 15 * time.Minute
|
||||
// provenance-aware persistence. Lifecycle mechanics are delegated to the
|
||||
// shared jobrunner.
|
||||
type Service struct {
|
||||
cfg Config
|
||||
// cfg is behind an atomic pointer so admin settings changes apply to
|
||||
// subsequent enqueues/view checks without rebuilding the service.
|
||||
cfg atomic.Pointer[Config]
|
||||
repo JobRepository
|
||||
content ContentReader
|
||||
locs LocalizationStore
|
||||
@@ -56,6 +59,17 @@ type Service struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// UpdateConfig swaps the service config. Safe for concurrent use; running
|
||||
// jobs finish with the config they started with.
|
||||
func (s *Service) UpdateConfig(cfg Config) {
|
||||
s.cfg.Store(&cfg)
|
||||
}
|
||||
|
||||
// config returns a snapshot of the current service config.
|
||||
func (s *Service) config() Config {
|
||||
return *s.cfg.Load()
|
||||
}
|
||||
|
||||
// NewService wires a metadata translation service. sem is the dispatch
|
||||
// semaphore shared with the other AI job services.
|
||||
func NewService(
|
||||
@@ -71,8 +85,7 @@ func NewService(
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
s := &Service{
|
||||
repo: repo,
|
||||
content: content,
|
||||
locs: locs,
|
||||
@@ -80,10 +93,12 @@ func NewService(
|
||||
runner: jobrunner.New(appCtx, sem, repo, "metadata translation", logger),
|
||||
logger: logger,
|
||||
}
|
||||
s.UpdateConfig(cfg)
|
||||
return s
|
||||
}
|
||||
|
||||
// Enabled reports whether metadata translation can currently run.
|
||||
func (s *Service) Enabled() bool { return s.cfg.Ready() }
|
||||
func (s *Service) Enabled() bool { return s.config().Ready() }
|
||||
|
||||
// Recover clears jobs orphaned by a crashed worker and starts a background
|
||||
// reaper that keeps doing so. Call once at startup.
|
||||
@@ -92,7 +107,7 @@ func (s *Service) Recover() { s.runner.Recover() }
|
||||
// Enqueue validates and queues a job, returning immediately. If an identical
|
||||
// job is already pending or running, that job is returned instead of a new one.
|
||||
func (s *Service) Enqueue(ctx context.Context, req JobRequest) (*Job, error) {
|
||||
if !s.cfg.Ready() {
|
||||
if !s.config().Ready() {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
if req.TargetKind == "" {
|
||||
@@ -112,7 +127,7 @@ func (s *Service) Enqueue(ctx context.Context, req JobRequest) (*Job, error) {
|
||||
}
|
||||
req.TargetLanguage = target
|
||||
|
||||
key := idempotencyKey(req.TargetKind, req.ContentID, req.TargetLanguage, s.cfg.ChatModel)
|
||||
key := idempotencyKey(req.TargetKind, req.ContentID, req.TargetLanguage, s.config().ChatModel)
|
||||
if existing, err := s.repo.GetActiveJobByIdempotencyKey(ctx, key); err != nil {
|
||||
return nil, err
|
||||
} else if existing != nil {
|
||||
@@ -125,7 +140,7 @@ func (s *Service) Enqueue(ctx context.Context, req JobRequest) (*Job, error) {
|
||||
IncludeChildren: req.IncludeChildren,
|
||||
TargetLanguage: req.TargetLanguage,
|
||||
Engine: "openai",
|
||||
Model: s.cfg.ChatModel,
|
||||
Model: s.config().ChatModel,
|
||||
Status: jobrunner.StatusPending,
|
||||
ProgressMessage: "Queued",
|
||||
Force: req.Force,
|
||||
@@ -151,7 +166,7 @@ func (s *Service) Enqueue(ctx context.Context, req JobRequest) (*Job, error) {
|
||||
// loop re-checks per field, so fully translated items never reach the model.
|
||||
// Errors are logged, never returned: a refresh must not fail on this.
|
||||
func (s *Service) AutoEnqueue(ctx context.Context, itemContentID, language string) {
|
||||
if !s.cfg.Ready() {
|
||||
if !s.config().Ready() {
|
||||
return
|
||||
}
|
||||
target, err := subtitles.NormalizeLanguageCode(language)
|
||||
@@ -179,7 +194,7 @@ func (s *Service) AutoEnqueue(ctx context.Context, itemContentID, language strin
|
||||
}
|
||||
|
||||
// OnViewMode exposes the viewer-triggered translation mode for status probes.
|
||||
func (s *Service) OnViewMode() string { return s.cfg.OnViewMode() }
|
||||
func (s *Service) OnViewMode() string { return s.config().OnViewMode() }
|
||||
|
||||
// RequestOnView is the viewer-triggered enqueue path (detail-page button or
|
||||
// auto-on-view). On top of the normal pipeline it suppresses retries for a
|
||||
@@ -187,7 +202,7 @@ func (s *Service) OnViewMode() string { return s.cfg.OnViewMode() }
|
||||
// page views must never hammer a broken endpoint. Returns the resulting job
|
||||
// (which may be the in-flight or recently failed one).
|
||||
func (s *Service) RequestOnView(ctx context.Context, contentID, targetLanguage string, requestedBy *int) (*Job, error) {
|
||||
if s.cfg.OnViewMode() == "off" {
|
||||
if s.config().OnViewMode() == "off" {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
target, err := subtitles.NormalizeLanguageCode(targetLanguage)
|
||||
|
||||
+43
-23
@@ -27,11 +27,31 @@ type MatchWorker struct {
|
||||
seriesClaimer SeriesRootClaimer
|
||||
enableTVSeriesRootQueue bool
|
||||
realtimeHub *notifications.Hub
|
||||
workers int
|
||||
batchSize int
|
||||
interval time.Duration
|
||||
// workers/batchSize are atomics so admin settings changes can resize the
|
||||
// pool while the long-running match loop reads them each cycle.
|
||||
workers atomic.Int32
|
||||
batchSize atomic.Int32
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
// SetConcurrency updates the worker pool size and claim batch size. Safe for
|
||||
// concurrent use; the next match cycle picks the new values up. Out-of-range
|
||||
// values are ignored.
|
||||
func (w *MatchWorker) SetConcurrency(workers, batchSize int) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
if workers >= 1 {
|
||||
w.workers.Store(int32(workers))
|
||||
}
|
||||
if batchSize > 0 {
|
||||
w.batchSize.Store(int32(batchSize))
|
||||
}
|
||||
}
|
||||
|
||||
func (w *MatchWorker) workerCount() int { return int(w.workers.Load()) }
|
||||
func (w *MatchWorker) claimBatchSize() int { return int(w.batchSize.Load()) }
|
||||
|
||||
type NonSeriesFileClaimer interface {
|
||||
ClaimUnmatchedNonSeries(ctx context.Context, limit int) ([]*models.MediaFile, error)
|
||||
ClaimUnmatchedNonSeriesByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, limit int, attemptBefore time.Time) ([]*models.MediaFile, error)
|
||||
@@ -77,14 +97,14 @@ func NewMatchWorker(service *MetadataService, fileLister UnmatchedFileLister, wo
|
||||
if service != nil {
|
||||
itemLister = service.itemRepo
|
||||
}
|
||||
return &MatchWorker{
|
||||
w := &MatchWorker{
|
||||
service: service,
|
||||
fileLister: fileLister,
|
||||
itemLister: itemLister,
|
||||
workers: workers,
|
||||
batchSize: batchSize,
|
||||
interval: interval,
|
||||
}
|
||||
w.SetConcurrency(workers, batchSize)
|
||||
return w
|
||||
}
|
||||
|
||||
// SetSeriesRootClaimer enables native TV root-backed matching when enabled is true.
|
||||
@@ -130,7 +150,7 @@ func (w *MatchWorker) Run(ctx context.Context) {
|
||||
// processUnmatched fetches a batch of unmatched files and processes them.
|
||||
func (w *MatchWorker) processUnmatched(ctx context.Context) {
|
||||
if w.enableTVSeriesRootQueue && w.seriesClaimer != nil {
|
||||
jobs, err := w.seriesClaimer.Claim(ctx, w.batchSize)
|
||||
jobs, err := w.seriesClaimer.Claim(ctx, w.claimBatchSize())
|
||||
if err != nil {
|
||||
slog.Error("metadata: failed to claim unmatched series roots", "error", err)
|
||||
} else if len(jobs) > 0 {
|
||||
@@ -142,7 +162,7 @@ func (w *MatchWorker) processUnmatched(ctx context.Context) {
|
||||
}
|
||||
|
||||
if w.movieClaimer != nil {
|
||||
files, err := w.movieClaimer.Claim(ctx, w.batchSize)
|
||||
files, err := w.movieClaimer.Claim(ctx, w.claimBatchSize())
|
||||
if err != nil {
|
||||
slog.Error("metadata: failed to claim queued movie files", "error", err)
|
||||
} else if len(files) > 0 {
|
||||
@@ -350,7 +370,7 @@ func (w *MatchWorker) ProcessFile(ctx context.Context, file *models.MediaFile) {
|
||||
// number of files processed.
|
||||
func (w *MatchWorker) ProcessBatch(ctx context.Context) (processed int, err error) {
|
||||
if w.enableTVSeriesRootQueue && w.seriesClaimer != nil {
|
||||
jobs, err := w.seriesClaimer.Claim(ctx, w.batchSize)
|
||||
jobs, err := w.seriesClaimer.Claim(ctx, w.claimBatchSize())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -359,7 +379,7 @@ func (w *MatchWorker) ProcessBatch(ctx context.Context) (processed int, err erro
|
||||
}
|
||||
}
|
||||
if w.movieClaimer != nil {
|
||||
files, err := w.movieClaimer.Claim(ctx, w.batchSize)
|
||||
files, err := w.movieClaimer.Claim(ctx, w.claimBatchSize())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -387,7 +407,7 @@ func (w *MatchWorker) ProcessBatchByFolderAndPathPrefix(ctx context.Context, fol
|
||||
return 0, err
|
||||
}
|
||||
if useSeriesQueue {
|
||||
jobs, err := w.seriesClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
|
||||
jobs, err := w.seriesClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.claimBatchSize(), attemptBefore)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -400,7 +420,7 @@ func (w *MatchWorker) ProcessBatchByFolderAndPathPrefix(ctx context.Context, fol
|
||||
return processed, nil
|
||||
}
|
||||
if useMovieQueue {
|
||||
files, err := w.movieClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
|
||||
files, err := w.movieClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.claimBatchSize(), attemptBefore)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -442,7 +462,7 @@ func (w *MatchWorker) ProcessAllByFolderAndPathPrefix(ctx context.Context, folde
|
||||
}
|
||||
|
||||
if useSeriesQueue {
|
||||
jobs, err := w.seriesClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
|
||||
jobs, err := w.seriesClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.claimBatchSize(), attemptBefore)
|
||||
if err != nil {
|
||||
return processed, err
|
||||
}
|
||||
@@ -456,7 +476,7 @@ func (w *MatchWorker) ProcessAllByFolderAndPathPrefix(ctx context.Context, folde
|
||||
}
|
||||
}
|
||||
if useMovieQueue {
|
||||
files, err := w.movieClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
|
||||
files, err := w.movieClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.claimBatchSize(), attemptBefore)
|
||||
if err != nil {
|
||||
return processed, err
|
||||
}
|
||||
@@ -502,7 +522,7 @@ func (w *MatchWorker) processFiles(ctx context.Context, files []*models.MediaFil
|
||||
processed atomic.Int64
|
||||
deferredSeriesLinks sync.Map
|
||||
)
|
||||
for i := 0; i < w.workers; i++ {
|
||||
for i := 0; i < w.workerCount(); i++ {
|
||||
wg.Go(func() {
|
||||
for file := range fileChan {
|
||||
if ctx.Err() != nil {
|
||||
@@ -578,7 +598,7 @@ func (w *MatchWorker) processQueuedMovieFiles(ctx context.Context, files []*mode
|
||||
processed atomic.Int64
|
||||
folders sync.Map
|
||||
)
|
||||
for i := 0; i < w.workers; i++ {
|
||||
for i := 0; i < w.workerCount(); i++ {
|
||||
wg.Go(func() {
|
||||
for file := range fileChan {
|
||||
if ctx.Err() != nil {
|
||||
@@ -806,7 +826,7 @@ func (w *MatchWorker) processSeriesRoots(ctx context.Context, jobs []models.Seri
|
||||
firstErrMu sync.Mutex
|
||||
folders sync.Map
|
||||
)
|
||||
for i := 0; i < w.workers; i++ {
|
||||
for i := 0; i < w.workerCount(); i++ {
|
||||
wg.Go(func() {
|
||||
for job := range jobChan {
|
||||
if runCtx.Err() != nil {
|
||||
@@ -1159,17 +1179,17 @@ func (w *MatchWorker) queueUsageForFolder(ctx context.Context, folderID int) (us
|
||||
func (w *MatchWorker) claimBackgroundFiles(ctx context.Context) ([]*models.MediaFile, error) {
|
||||
if w.enableTVSeriesRootQueue && w.movieClaimer != nil {
|
||||
if claimer, ok := w.fileLister.(MixedFileClaimer); ok {
|
||||
return claimer.ClaimUnmatchedMixed(ctx, w.batchSize)
|
||||
return claimer.ClaimUnmatchedMixed(ctx, w.claimBatchSize())
|
||||
}
|
||||
return nil, fmt.Errorf("mixed-library file claimer is not configured")
|
||||
}
|
||||
if w.enableTVSeriesRootQueue {
|
||||
if claimer, ok := w.fileLister.(NonSeriesFileClaimer); ok {
|
||||
return claimer.ClaimUnmatchedNonSeries(ctx, w.batchSize)
|
||||
return claimer.ClaimUnmatchedNonSeries(ctx, w.claimBatchSize())
|
||||
}
|
||||
return nil, fmt.Errorf("non-series file claimer is not configured")
|
||||
}
|
||||
return w.fileLister.ClaimUnmatched(ctx, w.batchSize)
|
||||
return w.fileLister.ClaimUnmatched(ctx, w.claimBatchSize())
|
||||
}
|
||||
|
||||
type scopedFallbackClaimMode int
|
||||
@@ -1195,16 +1215,16 @@ func (w *MatchWorker) claimScopedFiles(ctx context.Context, folderID int, pathPr
|
||||
switch mode {
|
||||
case scopedFallbackMixed:
|
||||
if claimer, ok := w.fileLister.(MixedFileClaimer); ok {
|
||||
return claimer.ClaimUnmatchedMixedByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
|
||||
return claimer.ClaimUnmatchedMixedByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.claimBatchSize(), attemptBefore)
|
||||
}
|
||||
return nil, fmt.Errorf("mixed-library file claimer is not configured")
|
||||
case scopedFallbackNonSeries:
|
||||
if claimer, ok := w.fileLister.(NonSeriesFileClaimer); ok {
|
||||
return claimer.ClaimUnmatchedNonSeriesByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
|
||||
return claimer.ClaimUnmatchedNonSeriesByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.claimBatchSize(), attemptBefore)
|
||||
}
|
||||
return nil, fmt.Errorf("non-series file claimer is not configured")
|
||||
default:
|
||||
return w.fileLister.ClaimUnmatchedByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
|
||||
return w.fileLister.ClaimUnmatchedByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.claimBatchSize(), attemptBefore)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -22,6 +23,7 @@ type BootstrapOverrides struct {
|
||||
Mode string // from MODE env
|
||||
DatabaseURL string // from DATABASE_URL env
|
||||
JFListen string // from JF_PORT env
|
||||
RedisURL string // from REDIS_URL env
|
||||
}
|
||||
|
||||
// Watcher watches for configuration changes in the database and
|
||||
@@ -59,8 +61,10 @@ func (w *Watcher) Config() *config.Config {
|
||||
return w.cfg
|
||||
}
|
||||
|
||||
// OnChange registers a callback invoked after a config swap.
|
||||
// The callback receives the old and new config. Must be called before Start.
|
||||
// OnChange registers a callback invoked after a config swap whose new value
|
||||
// differs from the old one. The callback receives the old and new config.
|
||||
// Safe to call before or after Start; callbacks registered after Start only
|
||||
// see reloads that happen after registration.
|
||||
func (w *Watcher) OnChange(fn func(old, updated *config.Config)) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
@@ -97,6 +101,18 @@ func (w *Watcher) ForceReload(ctx context.Context) error {
|
||||
return w.reload(ctx)
|
||||
}
|
||||
|
||||
// RequestReload asks the poll goroutine to reload soon. Non-blocking and
|
||||
// coalescing — safe to call from request handlers. Unlike ForceReload, the
|
||||
// reload runs on the poll goroutine, so concurrent requests can never swap a
|
||||
// stale snapshot over a newer one.
|
||||
func (w *Watcher) RequestReload() {
|
||||
select {
|
||||
case w.reloadCh <- struct{}{}:
|
||||
default:
|
||||
// Already pending — coalesce.
|
||||
}
|
||||
}
|
||||
|
||||
// SetConfigForTest sets the config directly without loading from DB.
|
||||
// This is intended for use in tests only.
|
||||
func (w *Watcher) SetConfigForTest(cfg *config.Config) {
|
||||
@@ -108,9 +124,18 @@ func (w *Watcher) SetConfigForTest(cfg *config.Config) {
|
||||
// reload fetches all settings from the database, builds a new Config,
|
||||
// applies bootstrap overrides, and atomically swaps the config pointer.
|
||||
func (w *Watcher) reload(ctx context.Context) error {
|
||||
m, err := w.fetchSettings(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return w.applySettings(m)
|
||||
}
|
||||
|
||||
// fetchSettings reads all server_settings rows and decrypts sensitive values.
|
||||
func (w *Watcher) fetchSettings(ctx context.Context) (map[string]string, error) {
|
||||
rows, err := w.pool.Query(ctx, "SELECT key, value FROM server_settings")
|
||||
if err != nil {
|
||||
return fmt.Errorf("query server_settings: %w", err)
|
||||
return nil, fmt.Errorf("query server_settings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
@@ -118,21 +143,27 @@ func (w *Watcher) reload(ctx context.Context) error {
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return fmt.Errorf("scan server_settings row: %w", err)
|
||||
return nil, fmt.Errorf("scan server_settings row: %w", err)
|
||||
}
|
||||
// Decrypt sensitive keys (read-path contract: legacy plaintext passes
|
||||
// through, enc:v1: values decrypt, corrupt ciphertext errors) so
|
||||
// LoadFromDB always sees plaintext.
|
||||
decrypted, derr := w.cipher.DecryptIfEncrypted(v, secret.SettingsAAD(k))
|
||||
if derr != nil {
|
||||
return fmt.Errorf("decrypt server_settings %q: %w", k, derr)
|
||||
return nil, fmt.Errorf("decrypt server_settings %q: %w", k, derr)
|
||||
}
|
||||
m[k] = decrypted
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("iterate server_settings: %w", err)
|
||||
return nil, fmt.Errorf("iterate server_settings: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// applySettings builds a Config from a plaintext settings map, re-applies
|
||||
// bootstrap overrides, swaps the config pointer, and notifies OnChange
|
||||
// callbacks when the config actually changed.
|
||||
func (w *Watcher) applySettings(m map[string]string) error {
|
||||
newCfg, err := config.LoadFromDB(m)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse config: %w", err)
|
||||
@@ -151,6 +182,9 @@ func (w *Watcher) reload(ctx context.Context) error {
|
||||
if w.bootstrap.JFListen != "" {
|
||||
newCfg.JellyfinCompat.Listen = w.bootstrap.JFListen
|
||||
}
|
||||
if w.bootstrap.RedisURL != "" {
|
||||
newCfg.Redis.URL = w.bootstrap.RedisURL
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
old := w.cfg
|
||||
@@ -159,6 +193,12 @@ func (w *Watcher) reload(ctx context.Context) error {
|
||||
copy(callbacks, w.onChange)
|
||||
w.mu.Unlock()
|
||||
|
||||
// The poll path reloads every 60s regardless of whether anything changed;
|
||||
// don't fire callbacks (which may rebuild clients or log) on no-op swaps.
|
||||
if old != nil && reflect.DeepEqual(*old, *newCfg) {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, fn := range callbacks {
|
||||
fn(old, newCfg)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package nodeconfig
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
)
|
||||
|
||||
func newTestWatcher(t *testing.T, bootstrap BootstrapOverrides) *Watcher {
|
||||
t.Helper()
|
||||
return NewWatcher(nil, nil, nil, bootstrap)
|
||||
}
|
||||
|
||||
func TestApplySettingsSkipsCallbacksOnNoopReload(t *testing.T) {
|
||||
w := newTestWatcher(t, BootstrapOverrides{})
|
||||
|
||||
var calls int
|
||||
w.OnChange(func(old, updated *config.Config) {
|
||||
calls++
|
||||
})
|
||||
|
||||
settings := map[string]string{"server.log_level": "debug"}
|
||||
if err := w.applySettings(settings); err != nil {
|
||||
t.Fatalf("first apply: %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("after initial apply: calls = %d, want 1", calls)
|
||||
}
|
||||
|
||||
// Same settings again — pointer swaps, but callbacks must not fire.
|
||||
if err := w.applySettings(settings); err != nil {
|
||||
t.Fatalf("second apply: %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("after no-op apply: calls = %d, want 1", calls)
|
||||
}
|
||||
|
||||
// A real change fires callbacks again.
|
||||
if err := w.applySettings(map[string]string{"server.log_level": "warn"}); err != nil {
|
||||
t.Fatalf("third apply: %v", err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("after changed apply: calls = %d, want 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySettingsReappliesBootstrapOverrides(t *testing.T) {
|
||||
w := newTestWatcher(t, BootstrapOverrides{
|
||||
Listen: ":9999",
|
||||
RedisURL: "redis://env-host:6379",
|
||||
})
|
||||
|
||||
err := w.applySettings(map[string]string{
|
||||
"server.listen": ":8080",
|
||||
"redis.url": "redis://db-host:6379",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
|
||||
cfg := w.Config()
|
||||
if cfg.Server.Listen != ":9999" {
|
||||
t.Errorf("Listen = %q, want bootstrap override %q", cfg.Server.Listen, ":9999")
|
||||
}
|
||||
if cfg.Redis.URL != "redis://env-host:6379" {
|
||||
t.Errorf("Redis.URL = %q, want bootstrap override %q", cfg.Redis.URL, "redis://env-host:6379")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestReloadCoalesces(t *testing.T) {
|
||||
w := newTestWatcher(t, BootstrapOverrides{})
|
||||
|
||||
// Multiple requests without a draining poll goroutine must neither block
|
||||
// nor queue more than one pending reload.
|
||||
w.RequestReload()
|
||||
w.RequestReload()
|
||||
w.RequestReload()
|
||||
|
||||
if got := len(w.reloadCh); got != 1 {
|
||||
t.Fatalf("pending reloads = %d, want 1 (coalesced)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnChangeAfterFirstApplySeesLaterChanges(t *testing.T) {
|
||||
w := newTestWatcher(t, BootstrapOverrides{})
|
||||
|
||||
if err := w.applySettings(map[string]string{"server.log_level": "info"}); err != nil {
|
||||
t.Fatalf("initial apply: %v", err)
|
||||
}
|
||||
|
||||
var gotOld, gotNew string
|
||||
w.OnChange(func(old, updated *config.Config) {
|
||||
gotOld = old.Server.LogLevel
|
||||
gotNew = updated.Server.LogLevel
|
||||
})
|
||||
|
||||
if err := w.applySettings(map[string]string{"server.log_level": "error"}); err != nil {
|
||||
t.Fatalf("second apply: %v", err)
|
||||
}
|
||||
if gotOld != "info" || gotNew != "error" {
|
||||
t.Errorf("callback saw old=%q new=%q, want old=info new=error", gotOld, gotNew)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -36,9 +36,8 @@ type ClientCapabilities struct {
|
||||
|
||||
// AdminSettings controls server-side playback constraints.
|
||||
type AdminSettings struct {
|
||||
TranscodeEnabled bool
|
||||
Allow4KTranscode bool
|
||||
AllowHEVCEncoding bool
|
||||
TranscodeEnabled bool
|
||||
Allow4KTranscode bool
|
||||
}
|
||||
|
||||
// PlayDecision is the result of resolving how to play a file.
|
||||
|
||||
@@ -19,9 +19,8 @@ func defaultCaps() playback.ClientCapabilities {
|
||||
|
||||
func defaultSettings() playback.AdminSettings {
|
||||
return playback.AdminSettings{
|
||||
TranscodeEnabled: true,
|
||||
Allow4KTranscode: false,
|
||||
AllowHEVCEncoding: false,
|
||||
TranscodeEnabled: true,
|
||||
Allow4KTranscode: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,55 +62,12 @@ func StreamExtractSubtitle(ctx context.Context, opts StreamExtractOpts) error {
|
||||
return errors.New("StreamExtractSubtitle: InputPath is required")
|
||||
}
|
||||
|
||||
outCodec, outFormat := streamExtractOutput(opts.SourceCodec)
|
||||
|
||||
bin := opts.FFmpegPath
|
||||
if bin == "" {
|
||||
bin = "ffmpeg"
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-hide_banner", "-nostats", "-loglevel", "error",
|
||||
}
|
||||
|
||||
// Input seek (before -i) is the fast variant: ffmpeg jumps near the
|
||||
// requested position before demuxing. ASS can't use it because the
|
||||
// output needs the [Script Info] header which only sits at offset 0.
|
||||
seekApplied := opts.SeekSeconds > 0 && !IsASS(opts.SourceCodec)
|
||||
if seekApplied {
|
||||
args = append(args, "-ss", strconv.FormatFloat(opts.SeekSeconds, 'f', 3, 64))
|
||||
}
|
||||
|
||||
// Duration limit must be an *input* option (placed before -i) so it
|
||||
// caps how much of the file we read. Placed as an output option, -t
|
||||
// combined with -copyts stops output when PTS reaches the given
|
||||
// value — which with a non-zero seek is already in the past, so
|
||||
// ffmpeg would emit only the WEBVTT header and zero cues.
|
||||
if opts.DurationSeconds > 0 {
|
||||
args = append(args, "-t", strconv.FormatFloat(opts.DurationSeconds, 'f', 3, 64))
|
||||
}
|
||||
|
||||
args = append(args,
|
||||
"-i", opts.InputPath,
|
||||
"-map", fmt.Sprintf("0:s:%d", opts.TrackIndex),
|
||||
"-c:s", outCodec,
|
||||
)
|
||||
|
||||
// When we seek the input, preserve the absolute source timestamps
|
||||
// in the output. Without this ffmpeg rebases cues to start at 0,
|
||||
// which makes every cue play `opts.SeekSeconds` earlier than it
|
||||
// should — the symptom is subtitles that look "out of sync" with
|
||||
// the video the player is showing at the same media time.
|
||||
if seekApplied {
|
||||
args = append(args, "-copyts", "-avoid_negative_ts", "disabled")
|
||||
}
|
||||
|
||||
args = append(args,
|
||||
"-f", outFormat,
|
||||
"pipe:1",
|
||||
)
|
||||
|
||||
cmd := exec.CommandContext(ctx, bin, args...)
|
||||
cmd := exec.CommandContext(ctx, bin, streamExtractArgs(opts)...)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdout pipe: %w", err)
|
||||
@@ -152,6 +109,59 @@ func StreamExtractSubtitle(ctx context.Context, opts StreamExtractOpts) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// streamExtractArgs builds the ffmpeg argument list for a streaming
|
||||
// subtitle extract.
|
||||
func streamExtractArgs(opts StreamExtractOpts) []string {
|
||||
outCodec, outFormat := streamExtractOutput(opts.SourceCodec)
|
||||
|
||||
args := []string{
|
||||
"-hide_banner", "-nostats", "-loglevel", "error",
|
||||
}
|
||||
|
||||
// Input seek (before -i) is the fast variant: ffmpeg jumps near the
|
||||
// requested position before demuxing. ASS can't use it because the
|
||||
// output needs the [Script Info] header which only sits at offset 0.
|
||||
// PGS can't either: the client (libpgs) fetches the .sup stream
|
||||
// exactly once and consumes it whole, so the output must cover the
|
||||
// complete track from offset 0 with original timestamps — windowing
|
||||
// would silently drop every cue outside the window. The same logic
|
||||
// excludes both from the -t duration cap below.
|
||||
windowable := !IsASS(opts.SourceCodec) && !IsPGS(opts.SourceCodec)
|
||||
seekApplied := opts.SeekSeconds > 0 && windowable
|
||||
if seekApplied {
|
||||
args = append(args, "-ss", strconv.FormatFloat(opts.SeekSeconds, 'f', 3, 64))
|
||||
}
|
||||
|
||||
// Duration limit must be an *input* option (placed before -i) so it
|
||||
// caps how much of the file we read. Placed as an output option, -t
|
||||
// combined with -copyts stops output when PTS reaches the given
|
||||
// value — which with a non-zero seek is already in the past, so
|
||||
// ffmpeg would emit only the WEBVTT header and zero cues.
|
||||
if opts.DurationSeconds > 0 && windowable {
|
||||
args = append(args, "-t", strconv.FormatFloat(opts.DurationSeconds, 'f', 3, 64))
|
||||
}
|
||||
|
||||
args = append(args,
|
||||
"-i", opts.InputPath,
|
||||
"-map", fmt.Sprintf("0:s:%d", opts.TrackIndex),
|
||||
"-c:s", outCodec,
|
||||
)
|
||||
|
||||
// When we seek the input, preserve the absolute source timestamps
|
||||
// in the output. Without this ffmpeg rebases cues to start at 0,
|
||||
// which makes every cue play `opts.SeekSeconds` earlier than it
|
||||
// should — the symptom is subtitles that look "out of sync" with
|
||||
// the video the player is showing at the same media time.
|
||||
if seekApplied {
|
||||
args = append(args, "-copyts", "-avoid_negative_ts", "disabled")
|
||||
}
|
||||
|
||||
return append(args,
|
||||
"-f", outFormat,
|
||||
"pipe:1",
|
||||
)
|
||||
}
|
||||
|
||||
// copyAndFlush streams from src to dst in 32KB chunks, calling Flush on
|
||||
// dst after each successful write when dst implements http.Flusher.
|
||||
func copyAndFlush(dst io.Writer, src io.Reader) error {
|
||||
@@ -177,11 +187,16 @@ func copyAndFlush(dst io.Writer, src io.Reader) error {
|
||||
}
|
||||
|
||||
// streamExtractOutput picks the ffmpeg output codec and muxer format for
|
||||
// a given source codec. ASS/SSA is copied so styling survives; everything
|
||||
// else is transmuxed to WebVTT for direct `<track>` consumption.
|
||||
// a given source codec. ASS/SSA is copied so styling survives; PGS is
|
||||
// copied into a .sup elementary stream for client-side bitmap rendering
|
||||
// (libpgs); everything else is transmuxed to WebVTT for direct `<track>`
|
||||
// consumption.
|
||||
func streamExtractOutput(codec string) (outCodec, outFormat string) {
|
||||
if IsASS(codec) {
|
||||
switch {
|
||||
case IsASS(codec):
|
||||
return "copy", "ass"
|
||||
case IsPGS(codec):
|
||||
return "copy", "sup"
|
||||
}
|
||||
return "webvtt", "webvtt"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsPGS(t *testing.T) {
|
||||
cases := []struct {
|
||||
codec string
|
||||
want bool
|
||||
}{
|
||||
{"pgs", true},
|
||||
{"hdmv_pgs_subtitle", true},
|
||||
{"HDMV_PGS_SUBTITLE", true},
|
||||
{"dvd_subtitle", false},
|
||||
{"dvb_subtitle", false},
|
||||
{"subrip", false},
|
||||
{"ass", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := IsPGS(tc.codec); got != tc.want {
|
||||
t.Errorf("IsPGS(%q) = %v, want %v", tc.codec, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamExtractOutput(t *testing.T) {
|
||||
cases := []struct {
|
||||
codec string
|
||||
wantCodec string
|
||||
wantFormat string
|
||||
}{
|
||||
{"ass", "copy", "ass"},
|
||||
{"ssa", "copy", "ass"},
|
||||
{"pgs", "copy", "sup"},
|
||||
{"hdmv_pgs_subtitle", "copy", "sup"},
|
||||
{"subrip", "webvtt", "webvtt"},
|
||||
{"mov_text", "webvtt", "webvtt"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
outCodec, outFormat := streamExtractOutput(tc.codec)
|
||||
if outCodec != tc.wantCodec || outFormat != tc.wantFormat {
|
||||
t.Errorf("streamExtractOutput(%q) = (%q, %q), want (%q, %q)",
|
||||
tc.codec, outCodec, outFormat, tc.wantCodec, tc.wantFormat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamExtractArgs_TextCodecIsWindowed(t *testing.T) {
|
||||
args := streamExtractArgs(StreamExtractOpts{
|
||||
InputPath: "/media/movie.mkv",
|
||||
TrackIndex: 2,
|
||||
SourceCodec: "subrip",
|
||||
SeekSeconds: 120,
|
||||
DurationSeconds: 600,
|
||||
})
|
||||
|
||||
joined := strings.Join(args, " ")
|
||||
if !strings.Contains(joined, "-ss 120.000") {
|
||||
t.Fatalf("text extract should seek the input: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "-t 600.000") {
|
||||
t.Fatalf("text extract should cap the read duration: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "-copyts") {
|
||||
t.Fatalf("seeked extract must preserve source timestamps: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "-c:s webvtt") || !strings.Contains(joined, "-f webvtt") {
|
||||
t.Fatalf("text extract should transmux to WebVTT: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// ASS and PGS streams are fetched once and consumed whole by their
|
||||
// client-side renderers, so seek/duration windowing must never apply even
|
||||
// when the handler passes nonzero values.
|
||||
func TestStreamExtractArgs_WholeTrackCodecsIgnoreWindow(t *testing.T) {
|
||||
for _, codec := range []string{"ass", "hdmv_pgs_subtitle"} {
|
||||
args := streamExtractArgs(StreamExtractOpts{
|
||||
InputPath: "/media/movie.mkv",
|
||||
TrackIndex: 0,
|
||||
SourceCodec: codec,
|
||||
SeekSeconds: 120,
|
||||
DurationSeconds: 600,
|
||||
})
|
||||
|
||||
if slices.Contains(args, "-ss") {
|
||||
t.Errorf("%s extract must not seek the input: %v", codec, args)
|
||||
}
|
||||
if slices.Contains(args, "-t") {
|
||||
t.Errorf("%s extract must not cap the read duration: %v", codec, args)
|
||||
}
|
||||
if !slices.Contains(args, "copy") {
|
||||
t.Errorf("%s extract must copy the source stream: %v", codec, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamExtractArgs_PGSProducesSup(t *testing.T) {
|
||||
args := streamExtractArgs(StreamExtractOpts{
|
||||
InputPath: "/media/movie.mkv",
|
||||
TrackIndex: 1,
|
||||
SourceCodec: "pgs",
|
||||
})
|
||||
|
||||
joined := strings.Join(args, " ")
|
||||
if !strings.Contains(joined, "-map 0:s:1 -c:s copy -f sup pipe:1") {
|
||||
t.Fatalf("PGS extract should copy into a sup stream: %s", joined)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,20 @@ func NeedsBurnIn(subtitleCodec string) bool {
|
||||
return bitmapSubtitleCodecs[strings.ToLower(subtitleCodec)]
|
||||
}
|
||||
|
||||
// pgsSubtitleCodecs lists PGS (Blu-ray bitmap) subtitle codec names. Unlike
|
||||
// other bitmap codecs, PGS can be extracted losslessly to a .sup elementary
|
||||
// stream and rendered client-side (libpgs in the web player), so burn-in is
|
||||
// not the only delivery option. DVD/DVB bitmap subs still require burn-in.
|
||||
var pgsSubtitleCodecs = map[string]bool{
|
||||
"pgs": true,
|
||||
"hdmv_pgs_subtitle": true,
|
||||
}
|
||||
|
||||
// IsPGS reports whether the given subtitle codec is PGS format.
|
||||
func IsPGS(codec string) bool {
|
||||
return pgsSubtitleCodecs[strings.ToLower(codec)]
|
||||
}
|
||||
|
||||
// assSubtitleCodecs lists subtitle codecs that are ASS/SSA format and support
|
||||
// rich styling (fonts, colors, positioning, karaoke, typesetting).
|
||||
var assSubtitleCodecs = map[string]bool{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,31 @@ func (s *Server) handleSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// When the URL requests SUP format (e.g. /subtitles/{token}/2.sup),
|
||||
// stream the PGS track as a raw .sup elementary stream for client-side
|
||||
// bitmap rendering (libpgs). Unlike the buffered text paths below, this
|
||||
// streams ffmpeg output directly: the track can be large and the client
|
||||
// renders progressively as data arrives.
|
||||
if requestedFormat == "sup" {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
err := playback.StreamExtractSubtitle(r.Context(), playback.StreamExtractOpts{
|
||||
InputPath: claims.MediaPath,
|
||||
TrackIndex: trackIndex,
|
||||
SourceCodec: "hdmv_pgs_subtitle", // .sup URLs are only generated for PGS tracks
|
||||
FFmpegPath: cfg.Playback.FFmpegPath,
|
||||
Writer: w,
|
||||
})
|
||||
if err != nil && r.Context().Err() == nil {
|
||||
// Headers already committed — log and let the client see a
|
||||
// truncated response.
|
||||
slog.Error("stream subtitle (sup)", "error", err, "track", trackIndex,
|
||||
"path", claims.MediaPath, "playback_session_id", claims.SessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// When the URL requests ASS format (e.g. /subtitles/{token}/2.ass),
|
||||
// extract as raw ASS to preserve styling for client-side rendering.
|
||||
if requestedFormat == "ass" {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
+24
-10
@@ -118,10 +118,12 @@ type Scanner struct {
|
||||
itemRepo *catalog.ItemRepository
|
||||
personRepo *catalog.PersonRepository
|
||||
episodeRepo *catalog.EpisodeRepository
|
||||
ffprobePath string
|
||||
s3Client *s3client.Client // public assets bucket (may be nil)
|
||||
imageCacher scannerImageCacher
|
||||
workers int
|
||||
ffprobePath string
|
||||
s3Client *s3client.Client // public assets bucket (may be nil)
|
||||
imageCacher scannerImageCacher
|
||||
// workers is atomic so admin settings changes can resize the per-scan
|
||||
// worker pool while a scan is running (applies to the next scan).
|
||||
workers atomic.Int32
|
||||
emptyTrashAfterScan bool
|
||||
markerFetcher func(context.Context, string) *IntroCreditsMarkers
|
||||
metadataQueue MetadataQueueProducer
|
||||
@@ -139,6 +141,17 @@ func (s *Scanner) SetImageCacher(cacher scannerImageCacher) {
|
||||
s.imageCacher = cacher
|
||||
}
|
||||
|
||||
// SetWorkers updates the scan worker pool size. Safe for concurrent use; the
|
||||
// next scan run picks the new value up. Values below 1 are ignored.
|
||||
func (s *Scanner) SetWorkers(workers int) {
|
||||
if s == nil || workers < 1 {
|
||||
return
|
||||
}
|
||||
s.workers.Store(int32(workers))
|
||||
}
|
||||
|
||||
func (s *Scanner) workerCount() int { return int(s.workers.Load()) }
|
||||
|
||||
const scanProgressLogInterval = 10 * time.Second
|
||||
|
||||
// MetadataQueueProducer enqueues durable initial metadata work rows after a
|
||||
@@ -167,7 +180,7 @@ func NewScanner(fileRepo *FileRepository, ffprobePath string, s3Client *s3client
|
||||
if workers < 1 {
|
||||
workers = 8
|
||||
}
|
||||
return &Scanner{
|
||||
s := &Scanner{
|
||||
fileRepo: fileRepo,
|
||||
rootSnapshotRepo: NewScannedRootRepository(fileRepo.Pool()),
|
||||
groupSnapshotRepo: NewScannedGroupRepository(fileRepo.Pool()),
|
||||
@@ -183,10 +196,11 @@ func NewScanner(fileRepo *FileRepository, ffprobePath string, s3Client *s3client
|
||||
episodeRepo: catalog.NewEpisodeRepository(fileRepo.Pool()),
|
||||
ffprobePath: ffprobePath,
|
||||
s3Client: s3Client,
|
||||
workers: workers,
|
||||
emptyTrashAfterScan: emptyTrashAfterScan,
|
||||
markerFetcher: nil,
|
||||
}
|
||||
s.SetWorkers(workers)
|
||||
return s
|
||||
}
|
||||
|
||||
// SetSeriesQueueSyncer installs the optional pending-series root queue synchronizer.
|
||||
@@ -682,11 +696,11 @@ func (s *Scanner) scanPaths(
|
||||
|
||||
// Phase 2: Process files concurrently with a worker pool.
|
||||
var wg sync.WaitGroup
|
||||
pathCh := make(chan string, s.workers)
|
||||
pathCh := make(chan string, s.workerCount())
|
||||
var newCount, updatedCount, unchangedCount, errorCount, processedCount atomic.Int64
|
||||
subtitleCache := newExternalSubtitleDirCache()
|
||||
|
||||
for range s.workers {
|
||||
for range s.workerCount() {
|
||||
wg.Go(func() {
|
||||
for path := range pathCh {
|
||||
if ctx.Err() != nil {
|
||||
@@ -1159,11 +1173,11 @@ func (s *Scanner) scanScope(
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
pathCh := make(chan string, s.workers)
|
||||
pathCh := make(chan string, s.workerCount())
|
||||
var newCount, updatedCount, unchangedCount, errorCount, processedCount atomic.Int64
|
||||
subtitleCache := newExternalSubtitleDirCache()
|
||||
|
||||
for range s.workers {
|
||||
for range s.workerCount() {
|
||||
wg.Go(func() {
|
||||
for path := range pathCh {
|
||||
if ctx.Err() != nil {
|
||||
|
||||
@@ -83,23 +83,24 @@ type QuotaStatus struct {
|
||||
// insert-time enforcement runs, so the number shown always matches the
|
||||
// number enforced.
|
||||
func (s *Service) TranscribeQuota(ctx context.Context, userID int, exempt bool) (QuotaStatus, error) {
|
||||
if exempt || s.cfg.TranscribeQuotaJobs <= 0 {
|
||||
cfg := s.config()
|
||||
if exempt || cfg.TranscribeQuotaJobs <= 0 {
|
||||
return QuotaStatus{}, nil
|
||||
}
|
||||
used, err := s.repo.CountTranscribeJobsByUserSince(ctx, userID, s.quotaWindowStart())
|
||||
used, err := s.repo.CountTranscribeJobsByUserSince(ctx, userID, quotaWindowStart(cfg))
|
||||
if err != nil {
|
||||
return QuotaStatus{}, err
|
||||
}
|
||||
remaining := s.cfg.TranscribeQuotaJobs - used
|
||||
remaining := cfg.TranscribeQuotaJobs - used
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
return QuotaStatus{
|
||||
Limited: true,
|
||||
Limit: s.cfg.TranscribeQuotaJobs,
|
||||
Limit: cfg.TranscribeQuotaJobs,
|
||||
Used: used,
|
||||
Remaining: remaining,
|
||||
Period: s.cfg.TranscribeQuotaPeriod,
|
||||
Period: cfg.TranscribeQuotaPeriod,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -111,17 +112,18 @@ func (s *Service) transcribeQuotaSpec(req JobRequest) *JobQuota {
|
||||
if !req.Kind.IsTranscribe() || req.QuotaExempt || req.RequestedBy == nil {
|
||||
return nil
|
||||
}
|
||||
if s.cfg.TranscribeQuotaJobs <= 0 {
|
||||
cfg := s.config()
|
||||
if cfg.TranscribeQuotaJobs <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &JobQuota{
|
||||
UserID: *req.RequestedBy,
|
||||
Limit: s.cfg.TranscribeQuotaJobs,
|
||||
Since: s.quotaWindowStart(),
|
||||
Period: s.cfg.TranscribeQuotaPeriod,
|
||||
Limit: cfg.TranscribeQuotaJobs,
|
||||
Since: quotaWindowStart(cfg),
|
||||
Period: cfg.TranscribeQuotaPeriod,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) quotaWindowStart() time.Time {
|
||||
return time.Now().Add(-QuotaPeriodWindow(s.cfg.TranscribeQuotaPeriod))
|
||||
func quotaWindowStart(cfg Config) time.Time {
|
||||
return time.Now().Add(-QuotaPeriodWindow(cfg.TranscribeQuotaPeriod))
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/ai/jobrunner"
|
||||
@@ -60,7 +61,9 @@ type SubtitleLister interface {
|
||||
// dispatch, heartbeat, stale-job reaping, cancellation) are delegated to the
|
||||
// shared jobrunner so they stay identical across Silo's AI job services.
|
||||
type Service struct {
|
||||
cfg Config
|
||||
// cfg is behind an atomic pointer so admin settings changes apply to
|
||||
// subsequent enqueues/quota checks without rebuilding the service.
|
||||
cfg atomic.Pointer[Config]
|
||||
repo JobRepository
|
||||
translator Translator
|
||||
transcriber Transcriber // optional; nil disables ASR kinds
|
||||
@@ -73,6 +76,17 @@ type Service struct {
|
||||
runner *jobrunner.Runner
|
||||
}
|
||||
|
||||
// UpdateConfig swaps the service config. Safe for concurrent use; running
|
||||
// jobs finish with the config they started with.
|
||||
func (s *Service) UpdateConfig(cfg Config) {
|
||||
s.cfg.Store(&cfg)
|
||||
}
|
||||
|
||||
// config returns a snapshot of the current service config.
|
||||
func (s *Service) config() Config {
|
||||
return *s.cfg.Load()
|
||||
}
|
||||
|
||||
// NewService wires a translation service. notifier may be nil. appCtx is the
|
||||
// application lifecycle context; jobs and the reaper derive from it so they stop
|
||||
// on shutdown. A nil appCtx falls back to context.Background(). sem is the
|
||||
@@ -96,8 +110,7 @@ func NewService(
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
s := &Service{
|
||||
repo: repo,
|
||||
translator: translator,
|
||||
transcriber: transcriber,
|
||||
@@ -109,14 +122,16 @@ func NewService(
|
||||
logger: logger,
|
||||
runner: jobrunner.New(appCtx, sem, repo, "subtitle ai", logger),
|
||||
}
|
||||
s.UpdateConfig(cfg)
|
||||
return s
|
||||
}
|
||||
|
||||
// Enabled reports whether translation can currently run.
|
||||
func (s *Service) Enabled() bool { return s.cfg.TranslateReady() }
|
||||
func (s *Service) Enabled() bool { return s.config().TranslateReady() }
|
||||
|
||||
// TranscribeEnabled reports whether ASR subtitle generation can currently run.
|
||||
func (s *Service) TranscribeEnabled() bool {
|
||||
return s.cfg.TranscribeReady() && s.transcriber != nil
|
||||
return s.config().TranscribeReady() && s.transcriber != nil
|
||||
}
|
||||
|
||||
// Recover clears jobs orphaned by a crashed worker and starts a background
|
||||
@@ -131,22 +146,25 @@ func (s *Service) Enqueue(ctx context.Context, req JobRequest) (*Job, error) {
|
||||
if req.Kind == "" {
|
||||
req.Kind = JobKindTranslate
|
||||
}
|
||||
jobModel := s.cfg.ChatModel
|
||||
// Snapshot once so the job's model provenance is internally consistent
|
||||
// even if the config reloads mid-enqueue.
|
||||
cfg := s.config()
|
||||
jobModel := cfg.ChatModel
|
||||
switch req.Kind {
|
||||
case JobKindTranslate:
|
||||
if !s.cfg.TranslateReady() {
|
||||
if !cfg.TranslateReady() {
|
||||
return nil, ErrEngineNotConfigured
|
||||
}
|
||||
case JobKindTranscribe:
|
||||
if !s.TranscribeEnabled() {
|
||||
if !cfg.TranscribeReady() || s.transcriber == nil {
|
||||
return nil, ErrEngineNotConfigured
|
||||
}
|
||||
jobModel = s.cfg.ASRModel
|
||||
jobModel = cfg.ASRModel
|
||||
case JobKindTranscribeTranslate:
|
||||
if !s.TranscribeEnabled() || !s.cfg.TranslateReady() {
|
||||
if !cfg.TranscribeReady() || s.transcriber == nil || !cfg.TranslateReady() {
|
||||
return nil, ErrEngineNotConfigured
|
||||
}
|
||||
jobModel = s.cfg.ASRModel + "+" + s.cfg.ChatModel
|
||||
jobModel = cfg.ASRModel + "+" + cfg.ChatModel
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unsupported job kind %q", ErrInvalidRequest, req.Kind)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -69,9 +70,11 @@ type audioTranscriber interface {
|
||||
// a word straddling a boundary can be clipped — accepted v1 limitation, noted
|
||||
// for a silence-aligned follow-up.
|
||||
type WhisperTranscriber struct {
|
||||
client audioTranscriber
|
||||
ffmpegPath string
|
||||
chunkSeconds int
|
||||
client audioTranscriber
|
||||
// ffmpegPath/chunkSeconds are atomics so admin settings changes apply to
|
||||
// jobs started afterwards; each job snapshots them once at start.
|
||||
ffmpegPath atomic.Pointer[string]
|
||||
chunkSeconds atomic.Int32
|
||||
// extract and probeOffset are playback helpers, injectable for tests.
|
||||
extract func(ctx context.Context, filePath string, audioTrackIndex int, dir, ffmpegPath string, chunkSeconds int) ([]playback.AudioChunk, error)
|
||||
probeOffset func(ctx context.Context, filePath string, audioTrackIndex int, ffmpegPath string) float64
|
||||
@@ -81,16 +84,24 @@ type WhisperTranscriber struct {
|
||||
// chunkSeconds outside [minASRChunkSeconds, defaultASRChunkSeconds] falls
|
||||
// back to the default (longer chunks would exceed upload limits).
|
||||
func NewWhisperTranscriber(client *llm.Client, ffmpegPath string, chunkSeconds int) *WhisperTranscriber {
|
||||
t := &WhisperTranscriber{
|
||||
client: client,
|
||||
extract: playback.ExtractAudioChunks,
|
||||
probeOffset: playback.ProbeAudioStartOffset,
|
||||
}
|
||||
t.SetExtraction(ffmpegPath, chunkSeconds)
|
||||
return t
|
||||
}
|
||||
|
||||
// SetExtraction updates the ffmpeg path and ASR chunk duration used by jobs
|
||||
// started afterwards. Safe for concurrent use; out-of-range chunk durations
|
||||
// fall back to the default, matching construction.
|
||||
func (t *WhisperTranscriber) SetExtraction(ffmpegPath string, chunkSeconds int) {
|
||||
if chunkSeconds < minASRChunkSeconds || chunkSeconds > defaultASRChunkSeconds {
|
||||
chunkSeconds = defaultASRChunkSeconds
|
||||
}
|
||||
return &WhisperTranscriber{
|
||||
client: client,
|
||||
ffmpegPath: ffmpegPath,
|
||||
chunkSeconds: chunkSeconds,
|
||||
extract: playback.ExtractAudioChunks,
|
||||
probeOffset: playback.ProbeAudioStartOffset,
|
||||
}
|
||||
t.ffmpegPath.Store(&ffmpegPath)
|
||||
t.chunkSeconds.Store(int32(chunkSeconds))
|
||||
}
|
||||
|
||||
// Transcribe implements Transcriber. The returned cues are NOT sorted (they
|
||||
@@ -105,7 +116,12 @@ func (t *WhisperTranscriber) Transcribe(ctx context.Context, req TranscribeJobRe
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
chunks, err := t.extract(ctx, req.FilePath, req.AudioTrackIndex, dir, t.ffmpegPath, t.chunkSeconds)
|
||||
// Snapshot once so the whole job extracts and times chunks consistently
|
||||
// even if the config reloads mid-job.
|
||||
ffmpegPath := *t.ffmpegPath.Load()
|
||||
chunkSeconds := int(t.chunkSeconds.Load())
|
||||
|
||||
chunks, err := t.extract(ctx, req.FilePath, req.AudioTrackIndex, dir, ffmpegPath, chunkSeconds)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
@@ -113,10 +129,10 @@ func (t *WhisperTranscriber) Transcribe(ctx context.Context, req TranscribeJobRe
|
||||
// Audio streams can start after the container's timeline origin (TS
|
||||
// remuxes especially); Whisper times are relative to the first audio
|
||||
// sample, so the delta is a constant sync error unless added back.
|
||||
startOffset := t.probeOffset(ctx, req.FilePath, req.AudioTrackIndex, t.ffmpegPath)
|
||||
startOffset := t.probeOffset(ctx, req.FilePath, req.AudioTrackIndex, ffmpegPath)
|
||||
|
||||
order := chunkOrderForPosition(chunks, req.StartPosition)
|
||||
timeout := time.Duration(t.chunkSeconds*asrChunkTimeoutFactor) * time.Second
|
||||
timeout := time.Duration(chunkSeconds*asrChunkTimeoutFactor) * time.Second
|
||||
|
||||
var all []SubtitleCue
|
||||
detected := ""
|
||||
|
||||
@@ -57,12 +57,13 @@ func evenStarts(n int) []float64 {
|
||||
}
|
||||
|
||||
func newTestTranscriber(client *fakeASRClient, chunks int, recordDir *string) *WhisperTranscriber {
|
||||
return &WhisperTranscriber{
|
||||
client: client,
|
||||
chunkSeconds: 600,
|
||||
extract: stubExtract(evenStarts(chunks), recordDir),
|
||||
probeOffset: func(context.Context, string, int, string) float64 { return 0 },
|
||||
tr := &WhisperTranscriber{
|
||||
client: client,
|
||||
extract: stubExtract(evenStarts(chunks), recordDir),
|
||||
probeOffset: func(context.Context, string, int, string) float64 { return 0 },
|
||||
}
|
||||
tr.SetExtraction("", 600)
|
||||
return tr
|
||||
}
|
||||
|
||||
func TestTranscribeOffsetsTimestampsByChunkStart(t *testing.T) {
|
||||
@@ -338,12 +339,12 @@ func TestTranscribeUsesExactChunkStartsAndAudioOffset(t *testing.T) {
|
||||
},
|
||||
}
|
||||
tr := &WhisperTranscriber{
|
||||
client: client,
|
||||
chunkSeconds: 600,
|
||||
client: client,
|
||||
// The second chunk really starts at 600.5s, not 600s.
|
||||
extract: stubExtract([]float64{0, 600.5}, &dir),
|
||||
probeOffset: func(context.Context, string, int, string) float64 { return 1.25 },
|
||||
}
|
||||
tr.SetExtraction("", 600)
|
||||
|
||||
cues, _, err := tr.Transcribe(context.Background(), TranscribeJobRequest{FilePath: "/x.mkv"}, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/ai/llm"
|
||||
aitranslate "github.com/Silo-Server/silo-server/internal/ai/translate"
|
||||
@@ -17,25 +18,34 @@ import (
|
||||
// untranslated context so the model can keep scene continuity across batch
|
||||
// boundaries.
|
||||
type LLMTranslator struct {
|
||||
client *llm.Client
|
||||
batchSize int
|
||||
contextNeighbors int
|
||||
client *llm.Client
|
||||
// batching is atomic so admin settings changes apply to jobs started
|
||||
// afterwards without rebuilding the translator.
|
||||
batchSize atomic.Int32
|
||||
contextNeighbors atomic.Int32
|
||||
}
|
||||
|
||||
// NewLLMTranslator builds a translator. batchSize and contextNeighbors fall
|
||||
// back to sane defaults when non-positive.
|
||||
func NewLLMTranslator(client *llm.Client, batchSize, contextNeighbors int) *LLMTranslator {
|
||||
t := &LLMTranslator{client: client}
|
||||
t.SetBatching(batchSize, contextNeighbors)
|
||||
return t
|
||||
}
|
||||
|
||||
// SetBatching updates the translation batch size and context-neighbor count.
|
||||
// Safe for concurrent use; jobs started afterwards pick the new values up.
|
||||
// Non-positive batch sizes and negative neighbor counts fall back to the
|
||||
// same defaults as construction.
|
||||
func (t *LLMTranslator) SetBatching(batchSize, contextNeighbors int) {
|
||||
if batchSize <= 0 {
|
||||
batchSize = 40
|
||||
}
|
||||
if contextNeighbors < 0 {
|
||||
contextNeighbors = 0
|
||||
}
|
||||
return &LLMTranslator{
|
||||
client: client,
|
||||
batchSize: batchSize,
|
||||
contextNeighbors: contextNeighbors,
|
||||
}
|
||||
t.batchSize.Store(int32(batchSize))
|
||||
t.contextNeighbors.Store(int32(contextNeighbors))
|
||||
}
|
||||
|
||||
// Translate implements Translator.
|
||||
@@ -76,8 +86,8 @@ func (t *LLMTranslator) Translate(ctx context.Context, req TranslateRequest, onB
|
||||
SystemPrompt: translationSystemPrompt(srcName, tgtName),
|
||||
TargetName: tgtName,
|
||||
EntryNoun: "cues",
|
||||
BatchSize: t.batchSize,
|
||||
ContextNeighbors: t.contextNeighbors,
|
||||
BatchSize: int(t.batchSize.Load()),
|
||||
ContextNeighbors: int(t.contextNeighbors.Load()),
|
||||
}, func(batch []aitranslate.Segment, done, totalSegs int) {
|
||||
// Batches are sequential ranges, so this batch covers [done-len, done).
|
||||
start := done - len(batch)
|
||||
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user