feat: notifications modal, admin dedup, and in-app notification preferences
- 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:
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user