feat: notifications modal, admin dedup, and in-app notification preferences
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 1m53s
Release / Docker (push) Successful in 6m22s
Release / Gitea Release (push) Successful in 35s

- Replace bell dropdown with full-screen NotificationsModal (mirrors SearchModal pattern)
- Notifications visible to all logged-in users (not just admin)
- Admin users excluded from new-chapter fan-out (dedup vs Scrape Complete notification)
- Users with notify_new_chapters=false opted out of new-chapter in-app notifications
- Toggle in profile page to enable/disable in-app new-chapter notifications
- PATCH /api/profile endpoint to save notification preferences
- User-facing /notifications page (admin redirects to /admin/notifications)
This commit is contained in:
root
2026-04-11 15:31:37 +05:00
parent 19b5b44454
commit 1e886a705d
10 changed files with 549 additions and 95 deletions

View File

@@ -539,7 +539,21 @@ func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
fmt.Sprintf("Scraped %d chapters, skipped %d (%s)", result.ChaptersScraped, result.ChaptersSkipped, task.Kind),
"/admin/tasks")
}
// Send push notifications to users who have this book in their library.
// Fan-out in-app new-chapter notification to all users who have this book
// in their library. Runs in background so it doesn't block the task loop.
if r.deps.Store != nil && result.ChaptersScraped > 0 &&
result.Slug != "" && task.Kind != "catalogue" {
go func() {
notifyCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
title := result.Slug
_ = r.deps.Store.NotifyUsersWithBook(notifyCtx, result.Slug,
"New chapters available",
fmt.Sprintf("%d new chapter(s) added to %s", result.ChaptersScraped, title),
"/books/"+result.Slug)
}()
}
// Send Web Push notifications to subscribed browsers.
if r.deps.WebPush != nil && r.deps.Store != nil &&
result.ChaptersScraped > 0 && result.Slug != "" && task.Kind != "catalogue" {
go r.deps.WebPush.SendToBook(context.Background(), r.deps.Store, result.Slug, webpush.Payload{

View File

@@ -1639,3 +1639,56 @@ func (s *Store) ListPushSubscriptionsByBook(ctx context.Context, slug string) ([
}
return subs, nil
}
// NotifyUsersWithBook creates an in-app notification for every logged-in user
// who has slug in their library. Errors for individual users are logged but
// do not abort the loop. Returns the number of notifications created.
func (s *Store) NotifyUsersWithBook(ctx context.Context, slug, title, message, link string) int {
userIDs, err := s.ListUserIDsWithBook(ctx, slug)
if err != nil || len(userIDs) == 0 {
return 0
}
var n int
for _, uid := range userIDs {
if createErr := s.CreateNotification(ctx, uid, title, message, link); createErr == nil {
n++
}
}
return n
}
// who have slug in their user_library. Used to fan-out new-chapter notifications.
// Admin users and users who have opted out of in-app new-chapter notifications
// (notify_new_chapters=false on app_users) are excluded.
func (s *Store) ListUserIDsWithBook(ctx context.Context, slug string) ([]string, error) {
// Collect user IDs to skip: admins + opted-out users.
skipIDs := make(map[string]bool)
excludedItems, err := s.pb.listAll(ctx, "app_users", `role="admin"||notify_new_chapters=false`, "")
if err == nil {
for _, raw := range excludedItems {
var rec struct {
ID string `json:"id"`
}
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
skipIDs[rec.ID] = true
}
}
}
filter := fmt.Sprintf("slug=%q&&user_id!=''", slug)
items, err := s.pb.listAll(ctx, "user_library", filter, "")
if err != nil {
return nil, fmt.Errorf("ListUserIDsWithBook: %w", err)
}
seen := make(map[string]bool)
var ids []string
for _, raw := range items {
var rec struct {
UserID string `json:"user_id"`
}
if json.Unmarshal(raw, &rec) == nil && rec.UserID != "" && !seen[rec.UserID] && !skipIDs[rec.UserID] {
seen[rec.UserID] = true
ids = append(ids, rec.UserID)
}
}
return ids, nil
}