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:
@@ -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