fix(notifications): paginate the list and give it a way to be cleared - #386
Merged
singh-odyssey merged 1 commit intoAug 11, 2026
Conversation
GET /api/notifications was a single unbounded findMany. Every notification a user had ever received was serialised on every authenticated page load, to render a dropdown that is max-h-96 and shows about six of them. It also returned rows whose expiresAt had already passed, because the cron sweeper deletes in batches of at most 500 and read paths were trusting it to have caught up. The endpoint now uses the same cursor helpers as /api/messages, /api/routes and /api/tickets: limit defaults to 20, caps at 100, and the response carries pagination.nextCursor / hasMore. The `notifications` key is kept as an alias for `items` so nothing breaks. Expired rows are filtered from both the page and unreadCount, off a single clock reading so the two cannot disagree. Also adds the missing write paths: - PATCH /api/notifications marks everything read. markAllNotificationsAsRead was already exported from src/lib/notifications.ts with no caller anywhere in the codebase — the helper existed, the route did not. - DELETE /api/notifications/[id] dismisses one, scoped with deleteMany so a guessed id belonging to someone else deletes nothing and returns 404. - DELETE /api/notifications clears the read ones, ?all=true clears the rest. NotificationBell gets Mark all read, a per-row dismiss, and Load more, each with an optimistic update that rolls back if the request fails. The list loading state now renders skeletons instead of an empty dropdown. Closes TravellersMeet#381
|
Thanks for opening this PR, @MOHITKOURAV01! 👋 Our maintainers will review it shortly. Estimate Time is 5-8 hrs .Meanwhile please:
If anything changes, feel free to push updates—this thread will stay open. |
Contributor
Author
On the red
|
singh-odyssey
approved these changes
Aug 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #381
The problem
GET /api/notificationswas a single unbounded query:NotificationBellfetches this on mount for every authenticated page, so an active user downloads every notification they have ever received in order to render a dropdown that ismax-h-96and shows about six of them.It also returned rows whose
expiresAthad already passed. The schema has the column andcleanupExpiredNotifications()sweeps it, but on a cron in batches of ≤500 — so between sweeps there are expired rows in the table and the read path was handing them to the client.And there was no way to remove anything.
PATCH /api/notifications/[id]/readexisted;DELETEdid not, andmarkAllNotificationsAsRead()was exported fromsrc/lib/notifications.tswith no caller anywhere in the codebase. The helper was written, the route never was, so clearing a 500-item bell meant clicking 500 rows.What this changes
Pagination
Uses the shared helpers from
src/lib/pagination.ts— the same ones/api/messages,/api/routes,/api/ticketsand/api/usersalready use. Default 20, capped at 100,pagination.nextCursor/hasMorein the response,400on a malformedlimitorcursor. Thenotificationskey is kept as an alias foritemsso nothing breaks.Expiry
activeNotificationWhere()filters both the page andunreadCount, off a single clock reading so the badge and the list cannot disagree about a notification expiring mid-request. It usesgt: nowagainst the sweeper'slte: now, so the two rules partition the table: a row is either sweepable or visible, never both and never neither.The missing write paths
PATCH /api/notificationsmarkAllNotificationsAsRead()DELETE /api/notifications/[id]DELETE /api/notifications?all=trueThe single-row delete goes through
deleteManywith theuserIdin the samewhere, so a guessed id belonging to somebody else deletes nothing and returns 404 rather than throwingRecordNotFound.UI
NotificationBellgets Mark all read, a per-row dismiss, and Load more. Both mutations are optimistic with a rollback on failure. The loading state renders skeletons instead of "No notifications yet" while the first page is still in flight — previously the dropdown claimed the list was empty during load.Tests
45 new tests across three files:
src/lib/__tests__/notification-list.test.ts— the new helpers, including that the cursor filter and expiry filter both survive underANDrather than one replacing the other, and thatdeleteNotificationreports 0 instead of throwing for a foreign idsrc/app/api/notifications/__tests__/route.test.ts— GET/PATCH/DELETE, including the single-clock-reading assertionsrc/app/api/notifications/[id]/__tests__/route.test.ts— the new DELETEVerification
npx vitest run src/app/api/notifications src/lib/__tests__/notification-list.test.ts src/lib/__tests__/notifications.test.ts— 45 passedmain's baseline: 9 failing files / 23 failing tests before and afternpx tsc --noEmitreports nothing new for these pathsNote on the base branch
maindoes not currently typecheck (#366, fixed by #379). Nothing here touches those files.