From 1e886a705d8953a20ec7ebdbc82f834c3866747d Mon Sep 17 00:00:00 2001 From: root Date: Sat, 11 Apr 2026 15:31:37 +0500 Subject: [PATCH] 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) --- backend/internal/runner/runner.go | 16 +- backend/internal/storage/store.go | 53 +++++ .../lib/components/NotificationsModal.svelte | 184 ++++++++++++++++++ ui/src/lib/server/pocketbase.ts | 20 ++ ui/src/routes/+layout.svelte | 108 ++-------- ui/src/routes/api/profile/+server.ts | 37 +++- ui/src/routes/notifications/+page.server.ts | 28 +++ ui/src/routes/notifications/+page.svelte | 127 ++++++++++++ ui/src/routes/profile/+page.server.ts | 17 +- ui/src/routes/profile/+page.svelte | 54 +++++ 10 files changed, 549 insertions(+), 95 deletions(-) create mode 100644 ui/src/lib/components/NotificationsModal.svelte create mode 100644 ui/src/routes/notifications/+page.server.ts create mode 100644 ui/src/routes/notifications/+page.svelte diff --git a/backend/internal/runner/runner.go b/backend/internal/runner/runner.go index 620fc6e..f279cba 100644 --- a/backend/internal/runner/runner.go +++ b/backend/internal/runner/runner.go @@ -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{ diff --git a/backend/internal/storage/store.go b/backend/internal/storage/store.go index 5e5c5d4..8e262ab 100644 --- a/backend/internal/storage/store.go +++ b/backend/internal/storage/store.go @@ -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 +} diff --git a/ui/src/lib/components/NotificationsModal.svelte b/ui/src/lib/components/NotificationsModal.svelte new file mode 100644 index 0000000..c6a92e1 --- /dev/null +++ b/ui/src/lib/components/NotificationsModal.svelte @@ -0,0 +1,184 @@ + + + + + + +
{ if (e.target === e.currentTarget) onclose(); }} +> + + +
e.stopPropagation()} + > + +
+
+ Notifications + {#if unreadCount > 0} + + {unreadCount} + + {/if} +
+ +
+ {#if unreadCount > 0} + + {/if} + {#if notifications.length > 0} + + {/if} + +
+
+ + +
+ + +
+ + +
+ {#if filtered.length === 0} +
+ {filter === 'unread' ? 'No unread notifications' : 'No notifications yet'} +
+ {:else} + {#each filtered as n (n.id)} +
+ { onMarkRead(n.id); onclose(); }} + class="flex-1 px-4 py-3.5 min-w-0" + > +
+ {#if !n.read} + + {/if} + {n.title} +
+

{n.message}

+
+ +
+ {/each} + {/if} +
+ + + +
+
diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index 9b9230d..e025a4f 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -95,6 +95,7 @@ export interface User { oauth_id?: string; polar_customer_id?: string; polar_subscription_id?: string; + notify_new_chapters?: boolean; } // ─── Auth token cache ───────────────────────────────────────────────────────── @@ -1481,6 +1482,25 @@ export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Pr } } +/** + * Update a user's notification preferences (stored on app_users record). + */ +export async function updateUserNotificationPrefs( + userId: string, + prefs: { notify_new_chapters?: boolean } +): Promise { + const token = await getToken(); + const res = await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(prefs) + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`updateUserNotificationPrefs failed: ${res.status} ${body}`); + } +} + // ─── Comments ───────────────────────────────────────────────────────────────── export interface PBBookComment { diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte index d886f79..cbd0136 100644 --- a/ui/src/routes/+layout.svelte +++ b/ui/src/routes/+layout.svelte @@ -13,6 +13,7 @@ import { locales, getLocale } from '$lib/paraglide/runtime.js'; import ListeningMode from '$lib/components/ListeningMode.svelte'; import SearchModal from '$lib/components/SearchModal.svelte'; + import NotificationsModal from '$lib/components/NotificationsModal.svelte'; import { fly, fade } from 'svelte/transition'; let { children, data }: { children: Snippet; data: LayoutData } = $props(); @@ -26,7 +27,6 @@ // Notifications let notificationsOpen = $state(false); let notifications = $state<{id: string; title: string; message: string; link: string; read: boolean}[]>([]); - let notifFilter = $state<'all' | 'unread'>('all'); async function loadNotifications() { if (!data.user) return; try { @@ -65,9 +65,6 @@ } $effect(() => { if (data.user) loadNotifications(); }); const unreadCount = $derived(notifications.filter(n => !n.read).length); - const filteredNotifications = $derived( - notifFilter === 'unread' ? notifications.filter(n => !n.read) : notifications - ); // Close search on navigation $effect(() => { @@ -588,12 +585,12 @@ {/if} - - {#if data.user?.role === 'admin'} -
+ + {#if data.user} +
- {#if notificationsOpen} -
- -
- Notifications -
- {#if unreadCount > 0} - - {/if} - {#if notifications.length > 0} - - {/if} -
-
- -
- - -
- -
- {#if filteredNotifications.length === 0} -
- {notifFilter === 'unread' ? 'No unread notifications' : 'No notifications'} -
- {:else} - {#each filteredNotifications as n (n.id)} -
- { markRead(n.id); notificationsOpen = false; }} - class="flex-1 p-3 min-w-0" - > -
- {#if !n.read} - - {/if} - {n.title} -
-
{n.message}
-
- -
- {/each} - {/if} -
- - -
- {/if}
{/if} @@ -1222,6 +1138,20 @@ { searchOpen = false; }} /> {/if} + +{#if notificationsOpen && data.user} + { notificationsOpen = false; }} + onMarkRead={markRead} + onMarkAllRead={markAllRead} + onDismiss={dismissNotification} + onClearAll={clearAllNotifications} + /> +{/if} + { // Don't intercept when typing in an input/textarea const tag = (e.target as HTMLElement).tagName; diff --git a/ui/src/routes/api/profile/+server.ts b/ui/src/routes/api/profile/+server.ts index 091ec05..f7f186f 100644 --- a/ui/src/routes/api/profile/+server.ts +++ b/ui/src/routes/api/profile/+server.ts @@ -1,8 +1,43 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { deleteUserAccount } from '$lib/server/pocketbase'; +import { deleteUserAccount, updateUserNotificationPrefs } from '$lib/server/pocketbase'; import { log } from '$lib/server/logger'; +/** + * PATCH /api/profile + * + * Update mutable profile preferences (currently: notification preferences). + * Body: { notify_new_chapters?: boolean } + */ +export const PATCH: RequestHandler = async ({ locals, request }) => { + if (!locals.user) error(401, 'Not authenticated'); + + let body: Record; + try { + body = await request.json(); + } catch { + error(400, 'Invalid JSON'); + } + + const prefs: { notify_new_chapters?: boolean } = {}; + if (typeof body.notify_new_chapters === 'boolean') { + prefs.notify_new_chapters = body.notify_new_chapters; + } + + if (Object.keys(prefs).length === 0) { + error(400, 'No valid preferences provided'); + } + + try { + await updateUserNotificationPrefs(locals.user.id, prefs); + } catch (e) { + log.error('profile', 'PATCH /api/profile failed', { userId: locals.user.id, err: String(e) }); + error(500, { message: 'Failed to update preferences. Please try again.' }); + } + + return json({ ok: true }); +}; + /** * DELETE /api/profile * diff --git a/ui/src/routes/notifications/+page.server.ts b/ui/src/routes/notifications/+page.server.ts new file mode 100644 index 0000000..065d231 --- /dev/null +++ b/ui/src/routes/notifications/+page.server.ts @@ -0,0 +1,28 @@ +import type { PageServerLoad } from './$types'; +import { redirect } from '@sveltejs/kit'; +import { backendFetch } from '$lib/server/scraper'; + +export const load: PageServerLoad = async ({ locals }) => { + // Admins have their own full notifications page + if (locals.user?.role === 'admin') { + redirect(302, '/admin/notifications'); + } + + const userId = locals.user!.id; + try { + const res = await backendFetch('/api/notifications?user_id=' + userId); + const data = await res.json().catch(() => ({ notifications: [] })); + return { + userId, + notifications: (data.notifications ?? []) as Array<{ + id: string; + title: string; + message: string; + link: string; + read: boolean; + }> + }; + } catch { + return { userId, notifications: [] }; + } +}; diff --git a/ui/src/routes/notifications/+page.svelte b/ui/src/routes/notifications/+page.svelte new file mode 100644 index 0000000..0e05a15 --- /dev/null +++ b/ui/src/routes/notifications/+page.svelte @@ -0,0 +1,127 @@ + + + + Notifications + + +
+
+
+

Notifications

+ {#if unreadCount > 0} +

{unreadCount} unread

+ {/if} +
+
+ {#if unreadCount > 0} + + {/if} + {#if notifications.length > 0} + + {/if} +
+
+ + +
+ + +
+ + + {#if filtered.length === 0} +
+ {filter === 'unread' ? 'No unread notifications' : 'No notifications'} +
+ {:else} +
+ {#each filtered as n (n.id)} +
+ markRead(n.id)} + class="flex-1 p-4 min-w-0" + > +
+ {#if !n.read} + + {/if} + {n.title} +
+

{n.message}

+
+ +
+ {/each} +
+ {/if} +
diff --git a/ui/src/routes/profile/+page.server.ts b/ui/src/routes/profile/+page.server.ts index a508a80..dfef050 100644 --- a/ui/src/routes/profile/+page.server.ts +++ b/ui/src/routes/profile/+page.server.ts @@ -5,7 +5,8 @@ import { getUserByUsername, getUserStats, allProgress, - getBooksBySlugs + getBooksBySlugs, + getUserById } from '$lib/server/pocketbase'; import { resolveAvatarUrl } from '$lib/server/minio'; import { log } from '$lib/server/logger'; @@ -41,12 +42,18 @@ export const load: PageServerLoad = async ({ locals }) => { }; } + // Helper: fetch fresh user record (for notification prefs not in auth token) + async function fetchFreshUser() { + return getUserById(locals.user!.id); + } + // Run all three independent groups concurrently - const [userRecord, sessionsResult, statsResult, historyResult] = await Promise.allSettled([ + const [userRecord, sessionsResult, statsResult, historyResult, freshUserResult] = await Promise.allSettled([ fetchUserRecord(), listUserSessions(locals.user.id), getUserStats(locals.sessionId, locals.user.id), - fetchHistory() + fetchHistory(), + fetchFreshUser() ]); if (userRecord.status === 'rejected') @@ -57,7 +64,6 @@ export const load: PageServerLoad = async ({ locals }) => { log.warn('profile', 'stats fetch failed (non-fatal)', { err: String(statsResult.reason) }); if (historyResult.status === 'rejected') log.warn('profile', 'history fetch failed (non-fatal)', { err: String(historyResult.reason) }); - const { avatarUrl = null, email = null, polarCustomerId = null } = userRecord.status === 'fulfilled' ? userRecord.value : {}; const sessions = @@ -66,12 +72,15 @@ export const load: PageServerLoad = async ({ locals }) => { statsResult.status === 'fulfilled' ? statsResult.value : null; const history = historyResult.status === 'fulfilled' ? historyResult.value : []; + const freshUser = + freshUserResult.status === 'fulfilled' ? freshUserResult.value : null; return { user: locals.user, avatarUrl, email, polarCustomerId, + notifyNewChapters: freshUser?.notify_new_chapters ?? true, stats: stats ?? { totalChaptersRead: 0, booksReading: 0, booksCompleted: 0, booksPlanToRead: 0, booksDropped: 0, topGenres: [], diff --git a/ui/src/routes/profile/+page.svelte b/ui/src/routes/profile/+page.svelte index d2d54bc..e797cc2 100644 --- a/ui/src/routes/profile/+page.svelte +++ b/ui/src/routes/profile/+page.svelte @@ -238,6 +238,27 @@ let pushState = $state('unsupported'); let pushError = $state(''); + // ── In-app notifications ────────────────────────────────────────────────────── + let notifyNewChapters = $state(data.notifyNewChapters ?? true); + let notifyNewChaptersSaving = $state(false); + + async function toggleNotifyNewChapters() { + notifyNewChaptersSaving = true; + const next = !notifyNewChapters; + try { + const res = await fetch('/api/profile', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ notify_new_chapters: next }) + }); + if (res.ok) { + notifyNewChapters = next; + } + } catch { /* ignore */ } finally { + notifyNewChaptersSaving = false; + } + } + $effect(() => { if (!browser) return; if (!('serviceWorker' in navigator) || !('PushManager' in window)) { @@ -724,6 +745,39 @@ {/if} + +
+
+
+

In-app notifications

+

+ {#if notifyNewChapters} + You'll receive a notification when new chapters are added to books in your library. + {:else} + In-app new-chapter notifications are disabled. + {/if} +

+
+ +
+
+ {#if pushState !== 'unsupported'}