Skip to content

Add in-portal notification system with admin broadcasts#761

Open
JoaquinBN wants to merge 20 commits into
devfrom
JoaquinBN/portal-alerts
Open

Add in-portal notification system with admin broadcasts#761
JoaquinBN wants to merge 20 commits into
devfrom
JoaquinBN/portal-alerts

Conversation

@JoaquinBN

@JoaquinBN JoaquinBN commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Adds a notification center to the portal: a navbar bell with unread badge and dropdown, plus a full notifications page with unread filtering and pagination. Personal notifications fire automatically for submission review outcomes (deep-linking to the matching row in My Submissions), highlighted contributions, referrals joining, and validator graduation. Admins can explicitly broadcast featured content, alerts, partners, contribution types, missions, Gen TV streams, POAP drops, and validator-targeted node upgrade announcements via an off-by-default checkbox and bulk action in Django admin; each broadcast is stored as a single audience-scoped row with lazy per-user read receipts, so fan-out cost stays flat and re-broadcasting resurfaces instead of duplicating. An event registry centralizes event metadata (category, priority, audience, and future email/Telegram channels) so adding a notification is one registry entry plus one producer call, and the change also repairs the 0051 data migration that blocked fresh test databases and ships 16 backend tests covering dedupe, receipts, audience scoping, the API, and the admin mixin.

Summary by CodeRabbit

  • New Features
    • In-app notifications: navbar bell with unread badge and dropdown, auto-refresh polling
    • Dedicated Notifications page with pagination, unread filter, and “Mark all read”
    • Deep-links from review notifications into My Submissions
    • Notifications for submission decisions, highlights, referrals joining, and validator graduations
    • Admin-driven broadcasts for featured content, alerts, streams, POAPs, partners, missions, etc.
    • Unread counts auto-refresh; individual notifications can be marked read (single/all)

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a full in-portal notifications system: DB models and migration, a registry and services layer (personal + broadcast semantics, dedupe, feeds, read-state), admin broadcast mixin and integrations, DRF API with read/mark actions, frontend store/UI (navbar bell + notifications page), workflow hooks, tests, and docs.

Changes

In-Portal Notification System

Layer / File(s) Summary
Notification models, migration, and app config
backend/notifications/models.py, backend/notifications/migrations/0001_initial.py, backend/notifications/apps.py, backend/notifications/__init__.py, backend/tally/settings.py
Adds Notification and NotificationReceipt models, initial migration, app config that imports receivers on ready(), package initializer, and registers NotificationsConfig in INSTALLED_APPS.
Event registry and core services
backend/notifications/registry.py, backend/notifications/services.py
Adds EventType registry and core services: notify(), broadcast(), feed construction (feed_for), unread filter UNREAD_Q, annotate_read_state(), mark_notification_read(), mark_all_read(), audience utilities, and broadcast reach estimation.
Notification serializer, viewset, and router
backend/notifications/serializers.py, backend/notifications/views.py, backend/api/urls.py
NotificationSerializer (computed is_read/labels), NotificationViewSet with get_queryset() (annotated feed), unread-count, mark-read, mark-all-read actions, and router registration under /notifications/.
Producer helpers and receivers
backend/notifications/services.py, backend/notifications/receivers.py
Personal producers: notify_submission_review, notify_contribution_highlighted, notify_referral_joined, notify_validator_graduated; broadcast producers for featured content/partners/alerts/missions/streams/POAP/target node; post-save receiver for ContributionHighlight.
Admin broadcast mixin and integrations
backend/notifications/admin_mixins.py, backend/notifications/admin.py, backend/contributions/admin.py, backend/gen_tv/admin.py, backend/partners/admin.py, backend/poaps/admin.py, backend/contributions/node_upgrade/admin.py
Introduces BroadcastNotificationAdminMixin (form-only broadcast fields, save/action wiring, bulk action) and integrates it into multiple existing admin classes; registers Notification and NotificationReceipt admin UIs.
Backend workflow hooks
backend/contributions/views.py, backend/ethereum_auth/views.py, backend/users/admin.py
my_submissions supports submission UUID query param for deep-linking; call sites now invoke notify_submission_review, referral join notification invoked on SIWE login (non-blocking), and admin validator assignment calls notify_validator_graduated.
Tests and docs
backend/notifications/tests.py, CHANGELOG.md, backend/CLAUDE.md, frontend/CLAUDE.md
Adds end-to-end test coverage for notification producers, broadcast semantics, read-state, API, and admin integrations; updates changelog and architecture docs describing backend and frontend notification behavior.
Frontend API client, store, and utils
frontend/src/lib/api.js, frontend/src/lib/notificationStore.js, frontend/src/lib/notificationUtils.js, frontend/src/lib/relativeTime.js
Adds notificationsAPI helpers, a notificationStore singleton (loadLatest, loadUnreadCount, markRead, markAllRead, reset), asList/followNotificationLink helpers, and relativeTime formatter.
Navbar bell, NotificationCenter, and Notifications page
frontend/src/components/NotificationCenter.svelte, frontend/src/components/Navbar.svelte, frontend/src/routes/Notifications.svelte, frontend/src/App.svelte, frontend/src/routes/MySubmissions.svelte
Navbar bell with unread badge, polling and dropdown of latest 7 with mark-read interactions; full /notifications route with pagination, unread filtering, mark-all-read; deep-link highlighting/scrolling in My Submissions; SPA route wired.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant SPA as Frontend SPA
  participant API as Notification API
  participant Service as Notification Service
  participant DB as Database
  User->>SPA: open bell / view notifications
  SPA->>API: GET /notifications/?page=...
  API->>Service: feed_for(user) + annotate_read_state
  Service->>DB: Query Notification & NotificationReceipt
  DB-->>Service: rows + receipts
  Service-->>API: serialized notifications
  API-->>SPA: notifications list
  User->>SPA: click notification
  SPA->>API: POST /notifications/{id}/mark-read/
  API->>Service: mark_notification_read(notification, user)
  Service->>DB: update read state / create receipt
  DB-->>Service: OK
  API-->>SPA: updated notification (is_read=true)
  SPA->>SPA: navigate to link_url
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and concisely summarizes the main change: adding an in-portal notification system with admin broadcast capabilities, which is the core feature described in the PR objectives.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch JoaquinBN/portal-alerts

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/routes/MySubmissions.svelte (1)

95-130: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Consider removing redundant loadSubmissions() call from onMount.

The onMount handler calls loadSubmissions() at line 129 after a 100ms delay, but the first $effect (lines 95-103) already calls loadSubmissions() immediately when authenticated. This results in two API calls on initial mount for authenticated users: one immediate (from the effect) and one delayed (from onMount).

The effects already handle both authentication state changes and querystring updates, making the onMount call redundant. Consider simplifying onMount to only handle the success message check (lines 121-125) and let the effects manage data loading.

🎯 Suggested refactor to eliminate duplicate calls
 onMount(async () => {
   syncSubmissionDeepLink($querystring);
 
   // Check for success message from edit submission
   const updateSuccess = sessionStorage.getItem('submissionUpdateSuccess');
   if (updateSuccess) {
     showSuccess(updateSuccess);
     sessionStorage.removeItem('submissionUpdateSuccess');
   }
-
-  // Wait a moment for auth state to be verified
-  await new Promise(resolve => setTimeout(resolve, 100));
-  loadSubmissions();
 });

The deep-link sync at line 118 can also be removed since the second effect (line 110) handles it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/routes/MySubmissions.svelte` around lines 95 - 130, The onMount
handler is causing a duplicate API call by invoking loadSubmissions() after a
delay even though the two $effect blocks already call loadSubmissions() and sync
deep links; remove the loadSubmissions() call (and optional
syncSubmissionDeepLink($querystring) if you accept the suggestion) from onMount
so onMount only handles the sessionStorage success message logic; keep the
$effect blocks (which reference $authState, authChecked, $querystring,
lastAppliedQuerystring, syncSubmissionDeepLink, and loadSubmissions) as the sole
triggers for loading submissions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/CLAUDE.md`:
- Around line 146-161: The MD022 lint error is caused by a missing blank line
before the "### Notifications" heading in backend/CLAUDE.md; open the file, find
the "### Notifications" heading (the section that lists Models:
`notifications/models.py`, Registry: `notifications/registry.py`, Services:
`notifications/services.py`, etc.), and insert a single blank line immediately
above that heading so there is an empty line separator before "###
Notifications" to satisfy the markdown lint rule.

In `@backend/contributions/admin.py`:
- Around line 1-3: Remove the duplicate import of "from django import forms" in
admin.py — keep a single "from django import forms" and ensure the other imports
(e.g., "from django.contrib import admin") remain untouched; locate the
duplicate by searching for the symbol "forms" or the exact import statement and
delete the redundant line, then run linting/formatting to confirm no
unused-import warnings remain.

In `@backend/notifications/tests.py`:
- Around line 217-347: Add two tests to NotificationAPITests: one to verify
pagination (e.g., create > page_size notifications using
services.broadcast_partner/services.notify, call GET '/api/v1/notifications/'
with page/page_size params, assert response.data['results'] length equals
page_size and presence/values of response.data['next'] and
response.data['previous']) and one to verify category filtering (create
notifications with differing event_type or category like 'submission' and
'partner.published', call GET '/api/v1/notifications/?category=submission', and
assert only submission-related notifications are returned). Place these as new
methods (e.g., test_pagination and test_category_filter) near
test_feed_lists_personal_and_broadcast_chronologically and reuse
services.notify, services.broadcast_partner and self.client to construct
assertions consistent with test_unread_filter.
- Around line 217-283: Add two new tests in NotificationAPITests to assert user
isolation: (1) create user_a and user_b, create a personal notification for
user_a via services.notify(recipient=user_a,...), then force_authenticate the
test client as user_b and GET '/api/v1/notifications/' and assert the user_a
notification's pk/event_type is not present in the results; (2) create a
personal notification for user_a, authenticate as user_b and POST to
'/api/v1/notifications/{pk}/mark-read/' for that notification and assert the
response is unauthorized/not found (status 403/404) and that the notification's
is_read on the Notification model has not been changed. Ensure you use
NotificationAPITests setup helpers, services.notify, and the existing endpoints
'/api/v1/notifications/' and '/api/v1/notifications/{pk}/mark-read/' to locate
code.

---

Outside diff comments:
In `@frontend/src/routes/MySubmissions.svelte`:
- Around line 95-130: The onMount handler is causing a duplicate API call by
invoking loadSubmissions() after a delay even though the two $effect blocks
already call loadSubmissions() and sync deep links; remove the loadSubmissions()
call (and optional syncSubmissionDeepLink($querystring) if you accept the
suggestion) from onMount so onMount only handles the sessionStorage success
message logic; keep the $effect blocks (which reference $authState, authChecked,
$querystring, lastAppliedQuerystring, syncSubmissionDeepLink, and
loadSubmissions) as the sole triggers for loading submissions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 678c55b6-e044-4601-af30-160a6203ed59

📥 Commits

Reviewing files that changed from the base of the PR and between 1816216 and d12af9d.

📒 Files selected for processing (35)
  • CHANGELOG.md
  • backend/CLAUDE.md
  • backend/api/urls.py
  • backend/contributions/admin.py
  • backend/contributions/migrations/0051_zero_validator_waitlist_points.py
  • backend/contributions/node_upgrade/admin.py
  • backend/contributions/views.py
  • backend/ethereum_auth/views.py
  • backend/gen_tv/admin.py
  • backend/notifications/__init__.py
  • backend/notifications/admin.py
  • backend/notifications/admin_mixins.py
  • backend/notifications/apps.py
  • backend/notifications/migrations/0001_initial.py
  • backend/notifications/migrations/__init__.py
  • backend/notifications/models.py
  • backend/notifications/receivers.py
  • backend/notifications/registry.py
  • backend/notifications/serializers.py
  • backend/notifications/services.py
  • backend/notifications/tests.py
  • backend/notifications/views.py
  • backend/partners/admin.py
  • backend/poaps/admin.py
  • backend/tally/settings.py
  • backend/users/admin.py
  • frontend/CLAUDE.md
  • frontend/src/App.svelte
  • frontend/src/components/Navbar.svelte
  • frontend/src/components/NotificationCenter.svelte
  • frontend/src/lib/api.js
  • frontend/src/lib/notificationStore.js
  • frontend/src/lib/relativeTime.js
  • frontend/src/routes/MySubmissions.svelte
  • frontend/src/routes/Notifications.svelte

Comment thread backend/CLAUDE.md
Comment thread backend/contributions/admin.py Outdated
Comment thread backend/notifications/tests.py
Comment thread backend/notifications/tests.py
@JoaquinBN JoaquinBN force-pushed the JoaquinBN/portal-alerts branch from d12af9d to f3abf26 Compare June 10, 2026 16:03

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/tally/settings.py (1)

43-43: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Include testserver in ALLOWED_HOSTS.

Line 43 only uses environment values; if testserver is omitted there, Django test-client requests can fail host validation. Add it unconditionally (deduped) in settings.

Suggested patch
-ALLOWED_HOSTS = get_required_env('ALLOWED_HOSTS').split(',')
+ALLOWED_HOSTS = list(
+    dict.fromkeys([*get_required_env('ALLOWED_HOSTS').split(','), 'testserver'])
+)

As per coding guidelines, backend/**/settings.py must add 'testserver' to ALLOWED_HOSTS for test database flows.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tally/settings.py` at line 43, ALLOWED_HOSTS is constructed from
get_required_env('ALLOWED_HOSTS') only, which can omit 'testserver' and break
Django test-client host validation; update the settings so after calling
get_required_env('ALLOWED_HOSTS').split(',') you append or insert 'testserver'
and ensure duplicates are removed (e.g., by converting to a deduping collection)
before assigning back to ALLOWED_HOSTS; modify the code around the ALLOWED_HOSTS
assignment (referencing ALLOWED_HOSTS and get_required_env) to always include
'testserver' in the final list.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/src/components/NotificationCenter.svelte`:
- Around line 27-37: Extract the duplicated followNotification logic into a
single exported helper (e.g., create frontend/src/lib/notificationUtils.js and
export function followNotification) that implements the same behavior (use
link_url || '', return if empty, open external http/https links with window.open
and noopener/noreferrer, otherwise call push with url.slice(1) when it starts
with '`#/`'). Replace the local followNotification implementations in
NotificationCenter.svelte and Notifications.svelte to import and call the shared
followNotification helper so both components share identical URL-handling logic.

In `@frontend/src/lib/notificationStore.js`:
- Around line 4-8: The helper function asList is duplicated; extract it into a
shared utility module (e.g., create frontend/src/lib/utils.js or arrayUtils.js)
that exports asList, update notificationStore.js to import { asList } from that
module and remove the local definition, and also update Notifications.svelte to
import and use the same asList export to ensure a single source of truth and
avoid duplication.

---

Outside diff comments:
In `@backend/tally/settings.py`:
- Line 43: ALLOWED_HOSTS is constructed from get_required_env('ALLOWED_HOSTS')
only, which can omit 'testserver' and break Django test-client host validation;
update the settings so after calling
get_required_env('ALLOWED_HOSTS').split(',') you append or insert 'testserver'
and ensure duplicates are removed (e.g., by converting to a deduping collection)
before assigning back to ALLOWED_HOSTS; modify the code around the ALLOWED_HOSTS
assignment (referencing ALLOWED_HOSTS and get_required_env) to always include
'testserver' in the final list.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4b2e77ba-8bb1-4faf-a616-7fe4ac80ec78

📥 Commits

Reviewing files that changed from the base of the PR and between d12af9d and f3abf26.

📒 Files selected for processing (34)
  • CHANGELOG.md
  • backend/CLAUDE.md
  • backend/api/urls.py
  • backend/contributions/admin.py
  • backend/contributions/node_upgrade/admin.py
  • backend/contributions/views.py
  • backend/ethereum_auth/views.py
  • backend/gen_tv/admin.py
  • backend/notifications/__init__.py
  • backend/notifications/admin.py
  • backend/notifications/admin_mixins.py
  • backend/notifications/apps.py
  • backend/notifications/migrations/0001_initial.py
  • backend/notifications/migrations/__init__.py
  • backend/notifications/models.py
  • backend/notifications/receivers.py
  • backend/notifications/registry.py
  • backend/notifications/serializers.py
  • backend/notifications/services.py
  • backend/notifications/tests.py
  • backend/notifications/views.py
  • backend/partners/admin.py
  • backend/poaps/admin.py
  • backend/tally/settings.py
  • backend/users/admin.py
  • frontend/CLAUDE.md
  • frontend/src/App.svelte
  • frontend/src/components/Navbar.svelte
  • frontend/src/components/NotificationCenter.svelte
  • frontend/src/lib/api.js
  • frontend/src/lib/notificationStore.js
  • frontend/src/lib/relativeTime.js
  • frontend/src/routes/MySubmissions.svelte
  • frontend/src/routes/Notifications.svelte

Comment thread frontend/src/components/NotificationCenter.svelte Outdated
Comment thread frontend/src/lib/notificationStore.js Outdated
@JoaquinBN JoaquinBN force-pushed the JoaquinBN/portal-alerts branch from f3abf26 to 0d2bed3 Compare June 11, 2026 07:37

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/notifications/views.py`:
- Around line 9-12: Override NotificationViewSet.get_serializer_context to add
context['use_light_serializers'] = self.action == 'list' so list actions use
light serializers; keep existing base context (call
super().get_serializer_context()), set the flag, and return the updated context.
Update the class NotificationViewSet (which already defines serializer_class =
NotificationSerializer and permission_classes) to include this method.

In `@frontend/src/components/Navbar.svelte`:
- Around line 61-62: Navbar mounts NotificationCenter twice (desktop + mobile),
causing duplicate polling/listeners; remove the duplicate mount and ensure only
one instance is rendered. Locate the two <NotificationCenter /> usages and keep
a single instance (e.g., the desktop one) that is conditionally rendered with
{`#if` $authState.isAuthenticated}; remove the second/mobile <NotificationCenter
/> inside the mobile block (the one hidden/shown via CSS classes like
hidden/md:*) so only one component mounts; if mobile needs separate placement,
render NotificationCenter once and position it with CSS or a portal rather than
duplicating the component.

In `@frontend/src/routes/MySubmissions.svelte`:
- Around line 30-41: The syncSubmissionDeepLink function leaves a stale
stateFilter when the URL's state param is missing or invalid; update
syncSubmissionDeepLink to explicitly reset stateFilter to a default (e.g., '' or
a designated defaultState) and set currentPage appropriately when
params.get('state') is falsy or not in validStateFilters. In other words, after
computing requestedState, if requestedState is present and valid set stateFilter
and currentPage as before, otherwise clear/reset stateFilter (and optionally
reset highlightedSubmissionId behavior if desired) so navigating to plain
`#/my-submissions` removes prior filters.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3756a5e9-0f5a-472f-b812-7bc264963b1f

📥 Commits

Reviewing files that changed from the base of the PR and between f3abf26 and 0d2bed3.

📒 Files selected for processing (35)
  • CHANGELOG.md
  • backend/CLAUDE.md
  • backend/api/urls.py
  • backend/contributions/admin.py
  • backend/contributions/node_upgrade/admin.py
  • backend/contributions/views.py
  • backend/ethereum_auth/views.py
  • backend/gen_tv/admin.py
  • backend/notifications/__init__.py
  • backend/notifications/admin.py
  • backend/notifications/admin_mixins.py
  • backend/notifications/apps.py
  • backend/notifications/migrations/0001_initial.py
  • backend/notifications/migrations/__init__.py
  • backend/notifications/models.py
  • backend/notifications/receivers.py
  • backend/notifications/registry.py
  • backend/notifications/serializers.py
  • backend/notifications/services.py
  • backend/notifications/tests.py
  • backend/notifications/views.py
  • backend/partners/admin.py
  • backend/poaps/admin.py
  • backend/tally/settings.py
  • backend/users/admin.py
  • frontend/CLAUDE.md
  • frontend/src/App.svelte
  • frontend/src/components/Navbar.svelte
  • frontend/src/components/NotificationCenter.svelte
  • frontend/src/lib/api.js
  • frontend/src/lib/notificationStore.js
  • frontend/src/lib/notificationUtils.js
  • frontend/src/lib/relativeTime.js
  • frontend/src/routes/MySubmissions.svelte
  • frontend/src/routes/Notifications.svelte
👮 Files not reviewed due to content moderation or server errors (8)
  • backend/notifications/registry.py
  • backend/notifications/services.py
  • backend/notifications/receivers.py
  • backend/contributions/views.py
  • backend/ethereum_auth/views.py
  • backend/users/admin.py
  • backend/notifications/admin_mixins.py
  • backend/contributions/admin.py

Comment thread backend/notifications/views.py
Comment on lines +61 to 62
<NotificationCenter />
{#if $authState.isAuthenticated}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid mounting NotificationCenter twice; it duplicates polling and listeners.

Line 61 and Line 78 render two instances at once (desktop + mobile). CSS hidden/md:* only hides visually; both components still mount, so both run loadLatest() and the 60s unread polling/document click listener from NotificationCenter.svelte.

Suggested fix
+ import { onMount } from 'svelte';
  import NotificationCenter from './NotificationCenter.svelte';
+
+ let isDesktop = $state(typeof window !== 'undefined' ? window.innerWidth >= 768 : false);
+
+ onMount(() => {
+   const onResize = () => {
+     isDesktop = window.innerWidth >= 768;
+   };
+   window.addEventListener('resize', onResize);
+   return () => window.removeEventListener('resize', onResize);
+ });

  <div class="hidden md:flex items-center gap-2">
-   <NotificationCenter />
+   {`#if` isDesktop}
+     <NotificationCenter />
+   {/if}
    {`#if` $authState.isAuthenticated}
      <SearchBar />
    {/if}
    ...
  </div>

  <div class="navbar-mobile-actions flex items-center md:hidden gap-2">
-   <NotificationCenter />
+   {`#if` !isDesktop}
+     <NotificationCenter />
+   {/if}
    <AuthButton />
    ...
  </div>

Also applies to: 78-79

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/Navbar.svelte` around lines 61 - 62, Navbar mounts
NotificationCenter twice (desktop + mobile), causing duplicate
polling/listeners; remove the duplicate mount and ensure only one instance is
rendered. Locate the two <NotificationCenter /> usages and keep a single
instance (e.g., the desktop one) that is conditionally rendered with {`#if`
$authState.isAuthenticated}; remove the second/mobile <NotificationCenter />
inside the mobile block (the one hidden/shown via CSS classes like hidden/md:*)
so only one component mounts; if mobile needs separate placement, render
NotificationCenter once and position it with CSS or a portal rather than
duplicating the component.

Comment thread frontend/src/routes/MySubmissions.svelte
@JoaquinBN JoaquinBN force-pushed the JoaquinBN/portal-alerts branch from 0d2bed3 to de75585 Compare June 11, 2026 10:37

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/routes/MySubmissions.svelte (1)

99-107: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid duplicate initial fetches from both $effect and onMount.

When already authenticated, both paths call loadSubmissions(), causing redundant API calls and possible response-order flicker.

Suggested fix
-  onMount(async () => {
+  onMount(() => {
     syncSubmissionDeepLink($querystring);
@@
-    // Wait a moment for auth state to be verified
-    await new Promise(resolve => setTimeout(resolve, 100));
-    loadSubmissions();
   });

Also applies to: 121-134

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/routes/MySubmissions.svelte` around lines 99 - 107, The $effect
and onMount both call loadSubmissions() when the user is already authenticated,
causing duplicate API calls and flicker; fix by adding a single guard to prevent
double-fetches (e.g., introduce a boolean submissionsLoaded or reuse
authChecked) and check it before calling loadSubmissions in either $effect or
onMount (set submissionsLoaded = true after successful load); alternatively,
change the $effect to only run on subsequent auth changes by checking
authChecked or a similar flag so only one path (onMount or the effect) triggers
loadSubmissions for the initial authenticated state.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/notifications/registry.py`:
- Line 53: EVENT_TYPES is built by silently overwriting duplicates from
_EVENT_TYPES; change the construction to detect duplicate slugs at import time
and raise a clear exception. Iterate over _EVENT_TYPES (or use a counter) and if
a slug from an event already exists in the map, raise a ValueError (or
RuntimeError) that includes the duplicate slug and the two conflicting event
identifiers (e.g., event.name or type) so collisions fail fast; ensure the final
variable name remains EVENT_TYPES and no silent overwrite occurs.

In `@frontend/src/App.svelte`:
- Line 213: Wrap the '/notifications' route registration with the existing
protectedRoute guard so only authenticated users can access it; specifically
replace the unguarded mapping that uses Notifications with
protectedRoute(Notifications) (or whatever protectedRoute wrapper is used
elsewhere) where routes are registered (look for the routes object/registration
near App.svelte and the protectedRoute helper) so navigation to '/notifications'
requires authentication.

In `@frontend/src/lib/notificationStore.js`:
- Around line 106-114: The markRead handler always decrements unreadCount
regardless of prior state; change markRead (and its update callback) to first
find the existing item in state.items by id, compare its previous unread/read
status with the returned updated object from notificationsAPI.markRead, and only
decrement unreadCount when the previous item was unread and the updated item is
marked read; update items with the updated object as before and ensure
unreadCount is bounded by Math.max(0, ...).

In `@frontend/src/lib/relativeTime.js`:
- Around line 6-22: The relativeTime function needs to explicitly handle
null/invalid timestamps: at the top of relativeTime (before computing seconds)
check whether value is null/undefined or the constructed Date is invalid (use
isNaN(date.getTime()) or Number.isNaN(date.getTime())); if invalid, return a
safe fallback (e.g., empty string or '—') to avoid rendering "Invalid Date" in
the UI, otherwise continue with the existing logic that computes seconds,
minutes, hours, days and returns formatted strings.

In `@frontend/src/routes/MySubmissions.svelte`:
- Around line 33-36: The highlight ID is stored as a string from
params.get('submission') (requestedSubmission) while submission.id is numeric,
causing strict comparisons to fail; update the code that sets
highlightedSubmissionId (and the other occurrence around where submission IDs
are compared at the end of the file) to normalize types consistently—either
parse requestedSubmission to a number before assigning highlightedSubmissionId
or convert submission.id to string at comparison time—so strict equality checks
match reliably; reference highlightedSubmissionId, requestedSubmission, and
submission.id when making the change.

In `@frontend/src/routes/Notifications.svelte`:
- Around line 18-47: loadNotifications can have out-of-order async responses
overwrite newer state; add a monotonic request token to ignore stale responses:
declare a module-scoped counter (e.g., let latestLoadId = 0), increment it at
the start of loadNotifications and capture the incremented id in a local
variable (e.g., const thisLoadId = ++latestLoadId) before awaiting
notificationsAPI.list, then after the await (in both success and catch) check if
thisLoadId === latestLoadId and only then update notifications, totalCount,
hasNext, currentPage, loading and error; apply the same pattern to the other
load call at lines 67-71 to ensure older responses are ignored.

---

Outside diff comments:
In `@frontend/src/routes/MySubmissions.svelte`:
- Around line 99-107: The $effect and onMount both call loadSubmissions() when
the user is already authenticated, causing duplicate API calls and flicker; fix
by adding a single guard to prevent double-fetches (e.g., introduce a boolean
submissionsLoaded or reuse authChecked) and check it before calling
loadSubmissions in either $effect or onMount (set submissionsLoaded = true after
successful load); alternatively, change the $effect to only run on subsequent
auth changes by checking authChecked or a similar flag so only one path (onMount
or the effect) triggers loadSubmissions for the initial authenticated state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 107bea73-210d-47f1-90ed-f016e2b0d60e

📥 Commits

Reviewing files that changed from the base of the PR and between 0d2bed3 and de75585.

📒 Files selected for processing (35)
  • CHANGELOG.md
  • backend/CLAUDE.md
  • backend/api/urls.py
  • backend/contributions/admin.py
  • backend/contributions/node_upgrade/admin.py
  • backend/contributions/views.py
  • backend/ethereum_auth/views.py
  • backend/gen_tv/admin.py
  • backend/notifications/__init__.py
  • backend/notifications/admin.py
  • backend/notifications/admin_mixins.py
  • backend/notifications/apps.py
  • backend/notifications/migrations/0001_initial.py
  • backend/notifications/migrations/__init__.py
  • backend/notifications/models.py
  • backend/notifications/receivers.py
  • backend/notifications/registry.py
  • backend/notifications/serializers.py
  • backend/notifications/services.py
  • backend/notifications/tests.py
  • backend/notifications/views.py
  • backend/partners/admin.py
  • backend/poaps/admin.py
  • backend/tally/settings.py
  • backend/users/admin.py
  • frontend/CLAUDE.md
  • frontend/src/App.svelte
  • frontend/src/components/Navbar.svelte
  • frontend/src/components/NotificationCenter.svelte
  • frontend/src/lib/api.js
  • frontend/src/lib/notificationStore.js
  • frontend/src/lib/notificationUtils.js
  • frontend/src/lib/relativeTime.js
  • frontend/src/routes/MySubmissions.svelte
  • frontend/src/routes/Notifications.svelte

Comment thread backend/notifications/registry.py Outdated
Comment thread frontend/src/App.svelte
'/contributions/:id': EditSubmission,
'/metrics': Metrics,
'/profile': ProfileEdit,
'/notifications': Notifications,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Protect /notifications with the existing auth route guard.

Line 213 currently registers the route without protectedRoute(...), which conflicts with the authenticated-only notifications contract and allows direct unauthenticated navigation to that page shell.

Proposed fix
-    '/notifications': Notifications,
+    '/notifications': protectedRoute(Notifications),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
'/notifications': Notifications,
'/notifications': protectedRoute(Notifications),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/App.svelte` at line 213, Wrap the '/notifications' route
registration with the existing protectedRoute guard so only authenticated users
can access it; specifically replace the unguarded mapping that uses
Notifications with protectedRoute(Notifications) (or whatever protectedRoute
wrapper is used elsewhere) where routes are registered (look for the routes
object/registration near App.svelte and the protectedRoute helper) so navigation
to '/notifications' requires authentication.

Comment thread frontend/src/lib/notificationStore.js Outdated
Comment thread frontend/src/lib/relativeTime.js
Comment thread frontend/src/routes/MySubmissions.svelte
Comment thread frontend/src/routes/Notifications.svelte
The portal now has a notification center: a navbar bell with unread
badge and dropdown, and a full notifications page with unread filtering
and pagination.

Personal notifications fire automatically for submission review
outcomes (including bulk rejections), highlighted contributions,
referrals joining, and validator graduation. Announcements are
admin-explicit broadcasts: featured content, system alerts, ecosystem
partners, contribution types, missions, Gen TV streams, POAP drops, and
node upgrade targets stay silent on save unless the admin checks a
broadcast control or runs the bulk action.

A broadcast is stored as a single row scoped to an audience (all users,
validators, stewards), with per-user read state in lazily created
receipts, so fan-out cost stays flat and re-broadcasting refreshes
instead of duplicating. An event registry centralizes category,
priority, audience, and future delivery channels (email, Telegram), so
adding a notification is one registry entry plus one producer call. A
reusable admin mixin provides the broadcast checkbox and bulk action to
any ModelAdmin.

Also repairs two data migrations that made fresh test databases
unbuildable.

- backend/notifications/models.py: Notification (nullable recipient = broadcast, audience field, frozen copy, payload JSON, dedupe_key) and NotificationReceipt (lazy per-user broadcast read state)
- backend/notifications/registry.py: EventType dataclass + EVENT_TYPES dict, single source of truth for event metadata and future channels
- backend/notifications/services.py: core notify()/broadcast(), feed_for()/annotate_read_state()/mark_* helpers, per-event producers and broadcasters
- backend/notifications/admin_mixins.py: BroadcastNotificationAdminMixin (dynamic form fields, fieldset injection, save_model hook, bulk action, reach feedback)
- backend/notifications/receivers.py, apps.py: post_save on ContributionHighlight notifies the contribution owner
- backend/notifications/views.py, serializers.py: feed (reverse-chronological, unread/category filters), unread-count, receipt-aware mark-read, mark-all-read
- backend/notifications/admin.py: Notification and NotificationReceipt admins
- backend/notifications/tests.py: 16 tests (dedupe, receipts, audiences, join-date cutoff, API, admin mixin render + broadcast POST)
- backend/notifications/migrations/0001_initial.py: initial schema
- backend/api/urls.py: register notifications router
- backend/tally/settings.py: install notifications app
- backend/contributions/admin.py: FeaturedContent/Alert/ContributionType/Mission admins use the broadcast mixin (replaces bespoke per-admin forms)
- backend/contributions/views.py: submission review + bulk reject create notifications; owner-scoped ?submission= deep-link filter on /submissions/my/
- backend/contributions/node_upgrade/admin.py: TargetNodeVersion broadcasts to the validators audience
- backend/partners/admin.py, backend/gen_tv/admin.py, backend/poaps/admin.py: broadcast mixin on Partner/Stream/PoapDrop admins
- backend/ethereum_auth/views.py: notify referrer on referred signup (guarded so it never breaks login)
- backend/users/admin.py: set_as_validator action notifies the graduated user
- backend/contributions/migrations/0037_seed_featured_content.py: skip seeding when fixture users are absent (fresh DBs)
- backend/contributions/migrations/0051_zero_validator_waitlist_points.py: skip live-model leaderboard recalculation on empty databases
- frontend/src/components/NotificationCenter.svelte: navbar bell + dropdown, Tailwind/portal design
- frontend/src/routes/Notifications.svelte: full feed page (All/Unread pills, load more)
- frontend/src/lib/notificationStore.js: shared store powering dropdown and page
- frontend/src/lib/relativeTime.js: shared compact timestamp util
- frontend/src/lib/api.js: notificationsAPI (list, unreadCount, markRead, markAllRead)
- frontend/src/components/Navbar.svelte, frontend/src/App.svelte: mount NotificationCenter, register /notifications route
- frontend/src/routes/MySubmissions.svelte: state/submission query params filter, scroll to and highlight the target row
- backend/CLAUDE.md, frontend/CLAUDE.md: document the notifications app, endpoints, components, and store
Adds the test coverage CodeRabbit flagged: user isolation tests proving
one user can neither see nor mark-read another user's personal
notifications, plus pagination and category filter tests for the feed.
Removes a duplicate forms import introduced by the rebase, and restores
the 0051 waitlist-points migration to dev's exact version since dev's
added migration dependencies now fix fresh-database builds on their
own, making our guard redundant (verified: full suite passes against
the unmodified migration).

## Claude Implementation Notes
- backend/contributions/admin.py: drop duplicate `from django import forms`
- backend/notifications/tests.py: add test_category_filter, test_pagination, test_user_cannot_see_other_users_personal_notifications, test_user_cannot_mark_other_users_notification_read (foreign mark-read returns 404 and leaves read_at null)
- backend/contributions/migrations/0051_zero_validator_waitlist_points.py: revert to origin/dev verbatim; PR no longer modifies any pre-existing migration
The navbar bell now sits to the left of the search bar on desktop so
notification access is grouped with the other lookup tools; mobile
layout is unchanged. The duplicated notification link-following and
API payload normalization helpers now live in a single shared module
used by the store, the dropdown, and the notifications page,
addressing the CodeRabbit duplication findings.

## Claude Implementation Notes
- frontend/src/components/Navbar.svelte: NotificationCenter moved before SearchBar in the desktop actions row
- frontend/src/lib/notificationUtils.js: new shared module exporting asList and followNotificationLink
- frontend/src/lib/notificationStore.js, components/NotificationCenter.svelte, routes/Notifications.svelte: import the shared helpers, local copies removed; unused push import dropped from the page
- frontend/CLAUDE.md: document notificationUtils.js and the bell position
The main steward review endpoint notifies the submitter again: the
hook was lost in a rebase over the security-hardening refactor, so
only update-accepted and bulk-reject were emitting decision
notifications. A view-level regression test now locks the main path
in. On the frontend, the navbar bell mounts twice (desktop and mobile
rows), so unread polling is now a refcounted singleton in the
notification store and loads coalesce while a request is in flight;
store resets bump an epoch that discards in-flight responses so a
logout or wallet switch cannot leak the previous session's feed. The
notifications ViewSet sets the use_light_serializers context flag per
backend convention, and My Submissions clears a deep-linked state
filter when the URL no longer carries a valid state.

## Claude Implementation Notes
- backend/contributions/views.py: review action calls notify_submission_review after the decision is saved, matching update_accepted/bulk_reject
- backend/notifications/tests.py: StewardReviewEndpointNotificationTests posts to /steward-submissions/{id}/review/ and asserts the submitter notification (steward + reject StewardPermission fixtures)
- backend/notifications/views.py: get_serializer_context sets use_light_serializers for the list action
- frontend/src/lib/notificationStore.js: refcounted startPolling() singleton, in-flight coalescing for loadLatest/loadUnreadCount, epoch guard on reset() that discards stale responses
- frontend/src/components/NotificationCenter.svelte: use the store's shared polling instead of a per-instance interval
- frontend/src/routes/MySubmissions.svelte: syncSubmissionDeepLink resets stateFilter when the state param is absent or invalid
@JoaquinBN JoaquinBN force-pushed the JoaquinBN/portal-alerts branch from de75585 to 3751330 Compare June 11, 2026 11:15

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

♻️ Duplicate comments (5)
frontend/src/lib/notificationStore.js (1)

106-117: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider transition-aware unread decrement.

Line 113 always decrements unreadCount even if the notification was already marked as read. While the polling mechanism will eventually correct the count, duplicate markRead calls (e.g., user clicks same notification twice quickly) could temporarily desync the badge.

♻️ Optional improvement
   async function markRead(id) {
     const response = await notificationsAPI.markRead(id);
     const updated = response.data;

     update((state) => {
+      const wasUnread = state.items.some((item) => item.id === id && !item.is_read);
       return {
         ...state,
         items: state.items.map((item) => (item.id === id ? updated : item)),
-        unreadCount: Math.max(0, state.unreadCount - 1)
+        unreadCount: wasUnread ? Math.max(0, state.unreadCount - 1) : state.unreadCount
       };
     });

     return updated;
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/lib/notificationStore.js` around lines 106 - 117, In markRead,
avoid always decrementing unreadCount; before calling update check the current
item in state.items (or inside the update callback) to see if the item with id
is currently unread, and only subtract 1 when its prior read flag is false (or
when updated.read === true and prior.read === false). Use the existing symbols:
function markRead, notificationsAPI.markRead, updated, and the update((state) =>
...) callback to conditionally update unreadCount so duplicate markRead calls
won’t decrement the badge incorrectly.
frontend/src/App.svelte (1)

215-215: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Protect /notifications with protectedRoute(...).

This route bypasses the app’s auth route-guard path and allows unauthenticated direct navigation to the notifications page shell.

Proposed fix
-    '/notifications': Notifications,
+    '/notifications': protectedRoute(Notifications),

As per coding guidelines, “For protected routes, check $authState.isAuthenticated before allowing access; redirect unauthenticated users using replace() to avoid infinite redirect loops.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/App.svelte` at line 215, Wrap the '/notifications' route with
the app's protectedRoute guard so unauthenticated users cannot access
Notifications directly; specifically, replace the raw mapping "/notifications":
Notifications with the protectedRoute(...) call (using the same protectedRoute
function used elsewhere) and ensure the guard checks $authState.isAuthenticated
and redirects unauthenticated users using replace() to the login route to avoid
infinite redirect loops.

Source: Coding guidelines

frontend/src/routes/Notifications.svelte (1)

18-47: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent out-of-order loadNotifications() responses from overwriting newer state.

Concurrent loads (fast filter toggles / load-more overlap) can let older responses clobber newer UI state.

Proposed fix
+  let latestRequestId = 0;
+
   async function loadNotifications(reset = true) {
@@
-    loading = true;
+    const requestId = ++latestRequestId;
+    loading = true;
     error = '';
@@
-      const items = asList(response.data);
+      if (requestId !== latestRequestId) return;
+      const items = asList(response.data);
       notifications = reset ? items : [...notifications, ...items];
       totalCount = response.data?.count ?? notifications.length;
       hasNext = Boolean(response.data?.next);
       currentPage = page;
     } catch (err) {
+      if (requestId !== latestRequestId) return;
       error = 'Failed to load notifications';
     } finally {
-      loading = false;
+      if (requestId === latestRequestId) loading = false;
     }
   }

Also applies to: 67-71

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/routes/Notifications.svelte` around lines 18 - 47, Concurrent
loadNotifications() responses can be applied out-of-order and overwrite newer UI
state; fix by adding a monotonically increasing request id (e.g., module-scoped
let lastLoadId = 0) that you increment at the start of loadNotifications(),
capture as const localLoadId = ++lastLoadId, and after the await response return
early if localLoadId !== lastLoadId so only the latest response updates
notifications, totalCount, hasNext, currentPage, loading and error; apply the
same request-id guard to the other load-more/filter handler referenced around
lines 67-71 so stale promises cannot clobber newer state.
frontend/src/lib/relativeTime.js (1)

5-22: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard invalid timestamps before computing relative time.

When value is null/invalid, this can surface "Invalid Date" in the UI instead of a safe fallback.

Proposed fix
 export function relativeTime(value, { verbose = false } = {}) {
   const date = new Date(value);
+  if (Number.isNaN(date.getTime())) return '';
   const seconds = Math.max(1, Math.floor((Date.now() - date.getTime()) / 1000));
   const suffix = verbose ? ' ago' : '';
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/lib/relativeTime.js` around lines 5 - 22, In relativeTime, guard
against invalid or null timestamps by validating the Date created from value
(use isNaN(date.getTime()) or Number.isNaN(date.getTime())) and return a safe
fallback (e.g. an empty string or 'now') before doing any arithmetic; update the
function to check this right after const date = new Date(value) and
short-circuit to the chosen fallback so the rest of the code never operates on
an "Invalid Date".
backend/notifications/registry.py (1)

53-53: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add duplicate-slug protection when building EVENT_TYPES.

Line 53 silently overwrites earlier entries if a slug is accidentally duplicated, which can misroute notification metadata. Add an import-time guard so duplicates fail fast.

🛡️ Proposed protection
 EVENT_TYPES = {event.slug: event for event in _EVENT_TYPES}
+if len(EVENT_TYPES) != len(_EVENT_TYPES):
+    seen = set()
+    duplicates = []
+    for event in _EVENT_TYPES:
+        if event.slug in seen:
+            duplicates.append(event.slug)
+        seen.add(event.slug)
+    raise RuntimeError(
+        f"Duplicate notification event slug(s): {', '.join(sorted(set(duplicates)))}"
+    )

As per coding guidelines, notifications/registry.py is the single source of truth for event types, so collisions should be prevented explicitly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/notifications/registry.py` at line 53, EVENT_TYPES currently builds
with {event.slug: event for event in _EVENT_TYPES} which will silently overwrite
duplicates; replace this with an import-time guard that iterates _EVENT_TYPES,
checks if event.slug is already in the map, and raises a clear ValueError
(including the duplicate slug and conflicting event classes) if so, then inserts
the event into EVENT_TYPES; reference EVENT_TYPES, _EVENT_TYPES, and event.slug
to locate the change.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/notifications/admin_mixins.py`:
- Around line 78-90: The admin save currently calls broadcast logic that can
raise or return None causing a 500 after the model is already saved; update
save_model/_send_broadcast to guard calls to broadcast_service by wrapping them
in try/except and by testing the return value before using it: catch exceptions
from broadcast_service, log the error, and call self.message_user(request, ...)
with a WARNING explaining broadcast failed (use self.broadcast_ineligible_reason
or the exception message safely), and if broadcast_service returns None treat as
failure and message similarly; ensure any failures do not re-raise so the
original save completes and admin actions don’t produce a server error (refer to
save_model, _send_broadcast, broadcast_service, broadcast_eligible, and
message_user to locate spots to add the guards).

In `@backend/notifications/models.py`:
- Line 80: The dedupe_key should not be globally unique; change the model to
remove unique=True on dedupe_key and add a UniqueConstraint that scopes
uniqueness to the recipient and broadcast mode (use the model fields dedupe_key,
recipient, and the is_broadcast-like field already present on the model) so keys
are unique per recipient/broadcast combination; then update the personal dedupe
lookup in backend/notifications/services.py (the get_or_create call that
currently uses dedupe_key alone) to include recipient (and/or is_broadcast where
appropriate) in the lookup/filters so get_or_create cannot reassign another
user's row; finally regenerate and commit the migration reflecting the new
constraint.

In `@backend/notifications/registry.py`:
- Around line 60-63: Replace the plain re-raise of KeyError at the notification
lookup that uses slug with an explicit suppression of exception chaining by
appending "from None" to the raise; specifically update the existing raise
KeyError(f"Unknown notification event type '{slug}'. Register it in
notifications/registry.py.") to use "raise KeyError(... ) from None" so the
replacement message is not chained to any prior exception.

In `@backend/notifications/serializers.py`:
- Around line 6-35: Create a new LightNotificationSerializer that mirrors
NotificationSerializer but omits heavy/nested fields (remove payload, body,
link_url, link_label and any fields that trigger nested queries) and only
includes minimal fields like id, event_type, category, category_label, priority,
priority_label, title, is_broadcast, is_read, created_at (keep get_is_read logic
if needed). Name it LightNotificationSerializer and place it alongside
NotificationSerializer in serializers.py. Then update the notifications ViewSet
to return LightNotificationSerializer for list actions (implement or update
get_serializer_class to return LightNotificationSerializer when self.action ==
'list', and NotificationSerializer otherwise) so list views use the lightweight
serializer. Ensure get_is_read behavior is preserved and read_only_fields remain
appropriate.

In `@backend/notifications/services.py`:
- Around line 139-150: The current re-broadcast path calls
_apply_values(notification, values) (which saves and updates updated_at) then
separately runs
Notification.objects.filter(pk=notification.pk).update(created_at=timezone.now())
and deletes receipts, leaving the in-memory notification out-of-sync briefly;
fix by consolidating DB writes inside a transaction: wrap the sequence in
transaction.atomic(), acquire the row with select_for_update() if concurrent
readers are a concern, perform a single queryset .update(...) that sets the
fields from values plus created_at=timezone.now() (instead of calling
_apply_values which saves separately), delete notification.receipts.all() within
the same transaction, then call
notification.refresh_from_db(fields=['created_at','updated_at', ...]) to sync
the in-memory object.
- Around line 230-241: TOCTOU: unread_broadcast_ids is queried then used in
NotificationReceipt.objects.bulk_create which can over-report how many were
marked due to concurrent updates; after the bulk_create call compute the actual
number marked by re-querying NotificationReceipt for the user and those
notification IDs (e.g.
NotificationReceipt.objects.filter(notification_id__in=unread_broadcast_ids,
user=user).count()) and use that count for the "X notifications marked" message,
or alternatively add a short comment referencing unread_broadcast_ids and
NotificationReceipt.objects.bulk_create explaining the minor race and why it's
acceptable if you choose not to change behavior.
- Around line 37-51: Add explicit return type annotations to the three private
helpers: annotate _source_for to return Dict[str, str] (it currently returns a
mapping of 'source_app', 'source_model', 'source_object_id' or {}), annotate
_apply_values to return None (it mutates and saves the notification), and
annotate _display_submission_name to return str (it produces the display
string). Import typing.Dict if needed and update the function signatures for
_source_for, _apply_values, and _display_submission_name accordingly to improve
IDE/type-checker support.

In `@frontend/src/routes/Notifications.svelte`:
- Around line 62-65: markAllRead currently marks all items read but leaves them
visible when the Unread filter is active; after calling
notificationStore.markAllRead() update the local notifications list to reflect
the active filter state: set notifications = notifications.map(..., is_read:
true) as you already do, then if the Unread filter is active (check the
component's filter state, e.g. filter === 'unread' or showUnread) remove or hide
newly-read items (notifications = notifications.filter(item => !item.is_read))
so the UI no longer shows read items when Unread is selected; update the
markAllRead() function to perform this conditional filtering using the
component's existing filter state variables.

---

Duplicate comments:
In `@backend/notifications/registry.py`:
- Line 53: EVENT_TYPES currently builds with {event.slug: event for event in
_EVENT_TYPES} which will silently overwrite duplicates; replace this with an
import-time guard that iterates _EVENT_TYPES, checks if event.slug is already in
the map, and raises a clear ValueError (including the duplicate slug and
conflicting event classes) if so, then inserts the event into EVENT_TYPES;
reference EVENT_TYPES, _EVENT_TYPES, and event.slug to locate the change.

In `@frontend/src/App.svelte`:
- Line 215: Wrap the '/notifications' route with the app's protectedRoute guard
so unauthenticated users cannot access Notifications directly; specifically,
replace the raw mapping "/notifications": Notifications with the
protectedRoute(...) call (using the same protectedRoute function used elsewhere)
and ensure the guard checks $authState.isAuthenticated and redirects
unauthenticated users using replace() to the login route to avoid infinite
redirect loops.

In `@frontend/src/lib/notificationStore.js`:
- Around line 106-117: In markRead, avoid always decrementing unreadCount;
before calling update check the current item in state.items (or inside the
update callback) to see if the item with id is currently unread, and only
subtract 1 when its prior read flag is false (or when updated.read === true and
prior.read === false). Use the existing symbols: function markRead,
notificationsAPI.markRead, updated, and the update((state) => ...) callback to
conditionally update unreadCount so duplicate markRead calls won’t decrement the
badge incorrectly.

In `@frontend/src/lib/relativeTime.js`:
- Around line 5-22: In relativeTime, guard against invalid or null timestamps by
validating the Date created from value (use isNaN(date.getTime()) or
Number.isNaN(date.getTime())) and return a safe fallback (e.g. an empty string
or 'now') before doing any arithmetic; update the function to check this right
after const date = new Date(value) and short-circuit to the chosen fallback so
the rest of the code never operates on an "Invalid Date".

In `@frontend/src/routes/Notifications.svelte`:
- Around line 18-47: Concurrent loadNotifications() responses can be applied
out-of-order and overwrite newer UI state; fix by adding a monotonically
increasing request id (e.g., module-scoped let lastLoadId = 0) that you
increment at the start of loadNotifications(), capture as const localLoadId =
++lastLoadId, and after the await response return early if localLoadId !==
lastLoadId so only the latest response updates notifications, totalCount,
hasNext, currentPage, loading and error; apply the same request-id guard to the
other load-more/filter handler referenced around lines 67-71 so stale promises
cannot clobber newer state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f210be22-6fe3-4019-bbdf-5b6918ac860f

📥 Commits

Reviewing files that changed from the base of the PR and between de75585 and 3751330.

📒 Files selected for processing (35)
  • CHANGELOG.md
  • backend/CLAUDE.md
  • backend/api/urls.py
  • backend/contributions/admin.py
  • backend/contributions/node_upgrade/admin.py
  • backend/contributions/views.py
  • backend/ethereum_auth/views.py
  • backend/gen_tv/admin.py
  • backend/notifications/__init__.py
  • backend/notifications/admin.py
  • backend/notifications/admin_mixins.py
  • backend/notifications/apps.py
  • backend/notifications/migrations/0001_initial.py
  • backend/notifications/migrations/__init__.py
  • backend/notifications/models.py
  • backend/notifications/receivers.py
  • backend/notifications/registry.py
  • backend/notifications/serializers.py
  • backend/notifications/services.py
  • backend/notifications/tests.py
  • backend/notifications/views.py
  • backend/partners/admin.py
  • backend/poaps/admin.py
  • backend/tally/settings.py
  • backend/users/admin.py
  • frontend/CLAUDE.md
  • frontend/src/App.svelte
  • frontend/src/components/Navbar.svelte
  • frontend/src/components/NotificationCenter.svelte
  • frontend/src/lib/api.js
  • frontend/src/lib/notificationStore.js
  • frontend/src/lib/notificationUtils.js
  • frontend/src/lib/relativeTime.js
  • frontend/src/routes/MySubmissions.svelte
  • frontend/src/routes/Notifications.svelte

Comment thread backend/notifications/admin_mixins.py
Comment thread backend/notifications/models.py Outdated
Comment thread backend/notifications/registry.py Outdated
Comment thread backend/notifications/serializers.py
Comment on lines +37 to +51
def _source_for(obj):
if obj is None:
return {}
meta = obj._meta
return {
'source_app': meta.app_label,
'source_model': meta.model_name,
'source_object_id': str(obj.pk),
}


def _apply_values(notification, values):
for field, value in values.items():
setattr(notification, field, value)
notification.save(update_fields=[*values.keys(), 'updated_at'])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider adding return type annotations to private helpers.

The private functions _source_for, _apply_values, and _display_submission_name (line 264) are missing return type annotations. Adding them improves maintainability and IDE support.

📝 Suggested annotations
-def _source_for(obj):
+def _source_for(obj) -> dict[str, str]:
     if obj is None:
         return {}
     ...

-def _apply_values(notification, values):
+def _apply_values(notification, values) -> None:
     for field, value in values.items():
         ...

-def _display_submission_name(submission):
+def _display_submission_name(submission) -> str:
     if submission.title:
         ...
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _source_for(obj):
if obj is None:
return {}
meta = obj._meta
return {
'source_app': meta.app_label,
'source_model': meta.model_name,
'source_object_id': str(obj.pk),
}
def _apply_values(notification, values):
for field, value in values.items():
setattr(notification, field, value)
notification.save(update_fields=[*values.keys(), 'updated_at'])
def _source_for(obj) -> dict[str, str]:
if obj is None:
return {}
meta = obj._meta
return {
'source_app': meta.app_label,
'source_model': meta.model_name,
'source_object_id': str(obj.pk),
}
def _apply_values(notification, values) -> None:
for field, value in values.items():
setattr(notification, field, value)
notification.save(update_fields=[*values.keys(), 'updated_at'])
🧰 Tools
🪛 Ruff (0.15.15)

[warning] 37-37: Missing return type annotation for private function _source_for

(ANN202)


[warning] 48-48: Missing return type annotation for private function _apply_values

Add return type annotation: None

(ANN202)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/notifications/services.py` around lines 37 - 51, Add explicit return
type annotations to the three private helpers: annotate _source_for to return
Dict[str, str] (it currently returns a mapping of 'source_app', 'source_model',
'source_object_id' or {}), annotate _apply_values to return None (it mutates and
saves the notification), and annotate _display_submission_name to return str (it
produces the display string). Import typing.Dict if needed and update the
function signatures for _source_for, _apply_values, and _display_submission_name
accordingly to improve IDE/type-checker support.

Comment thread backend/notifications/services.py
Comment on lines +230 to +241
unread_broadcast_ids = list(
annotate_read_state(feed_for(user).filter(recipient__isnull=True), user)
.filter(receipt_read=False)
.values_list('pk', flat=True)
)
NotificationReceipt.objects.bulk_create(
[
NotificationReceipt(notification_id=pk, user=user, read_at=now)
for pk in unread_broadcast_ids
],
ignore_conflicts=True,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Minor TOCTOU race in mark_all_read between query and bulk-create.

Lines 230–234 query for unread broadcast IDs, then lines 235–241 bulk-create receipts for those IDs. If another concurrent request marks some of the same broadcasts as read between the query and the bulk-create, the returned count (line 242) may over-report the number marked. The ignore_conflicts=True flag prevents errors from duplicate receipts.

Impact is limited to a slightly incorrect "X notifications marked" message and is acceptable for a notification system. Fixing would require advisory locking or atomic upsert logic (higher complexity). Current implementation is reasonable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/notifications/services.py` around lines 230 - 241, TOCTOU:
unread_broadcast_ids is queried then used in
NotificationReceipt.objects.bulk_create which can over-report how many were
marked due to concurrent updates; after the bulk_create call compute the actual
number marked by re-querying NotificationReceipt for the user and those
notification IDs (e.g.
NotificationReceipt.objects.filter(notification_id__in=unread_broadcast_ids,
user=user).count()) and use that count for the "X notifications marked" message,
or alternatively add a short comment referencing unread_broadcast_ids and
NotificationReceipt.objects.bulk_create explaining the minor race and why it's
acceptable if you choose not to change behavior.

Comment thread frontend/src/routes/Notifications.svelte
JoaquinBN added 15 commits June 11, 2026 13:45
The event registry fails fast on duplicate slugs instead of silently
overwriting entries. The unread badge no longer over-decrements when a
dropdown item is marked read twice, fast All/Unread filter toggles can
no longer let an older response overwrite a newer one (same request
token pattern as CommunityPoaps), and invalid timestamps render as
empty instead of "Invalid Date". The /notifications route stays
unwrapped on purpose: like /profile and /my-submissions it renders an
in-page sign-in prompt rather than redirecting.

## Claude Implementation Notes
- backend/notifications/registry.py: EVENT_TYPES built in a loop raising RuntimeError on duplicate slugs
- frontend/src/lib/notificationStore.js: markRead decrements only when the dropdown-slice item was unread; page-only items keep decrementing since callers guard on is_read
- frontend/src/routes/Notifications.svelte: latestRequestId token discards stale list responses
- frontend/src/lib/relativeTime.js: return '' for invalid dates
Dedupe keys are now scoped per recipient (with a separate constraint
for broadcasts), so a key collision between producers can never
reassign another user's notification; the lookup pins the recipient
and a regression test proves two users sharing a key get separate
rows. Admin broadcasts that fail after the object is saved report an
error message instead of a 500, re-broadcast refreshes are atomic,
list responses drop the channel-renderer payload field, marking all
read while the Unread filter is active clears the list, and the
registry raises unknown-event errors without misleading chaining.

## Claude Implementation Notes
- backend/notifications/models.py: dedupe_key unique=True replaced with two conditional UniqueConstraints (per-recipient personal, broadcast-wide); migration 0001 regenerated
- backend/notifications/services.py: notify()/broadcast() include recipient in the get_or_create lookup; re-broadcast refresh wrapped in transaction.atomic()
- backend/notifications/admin_mixins.py: try/except with logging around broadcast_service in save path and bulk action, failure counts in messages
- backend/notifications/serializers.py, views.py: LightNotificationSerializer (full minus payload) used for list via get_serializer_class
- backend/notifications/registry.py: raise KeyError ... from None
- backend/notifications/tests.py: test_same_dedupe_key_never_reassigns_across_users
- frontend/src/routes/Notifications.svelte: markAllRead clears the list when the Unread filter is active
Staff can now compose one-off notifications in Django admin and send
them to everyone, to users with selected roles (builders, validators,
stewards, creators, union semantics), to hand-picked users, or to a
pasted batch of wallet addresses with unmatched lines reported back.
Announcements can be pure text with no link, and bodies support
markdown on the notifications page. Sending fans out personal
notification rows per resolved recipient (snapshot semantics, never a
public broadcast), a draft preview shows reach before sending, and a
deliberate resend resurfaces the notification as unread for the
current audience without duplicating. Recipient resolution is a
standalone step so future email and Telegram campaigns can reuse it.

## Claude Implementation Notes
- backend/notifications/models.py: CustomNotification (copy + target_mode/roles/users M2M/wallets + status/sent_*/unmatched_wallets/channels JSON default ['portal']); 'announcement' category choice
- backend/notifications/migrations/0002_*: CreateModel + category AlterField
- backend/notifications/campaigns.py: parse_wallet_lines (case-insensitive dedupe, invalid line report), resolve_recipients (channel-agnostic; is_active only; banned/invisible included by design), send_campaign (audience-scoped refresh with explicit created_at/updated_at, chunked bulk_create ignore_conflicts, status flip last so crashes leave a resumable draft)
- backend/notifications/registry.py: custom.announcement EventType
- backend/notifications/admin.py: CustomNotificationAdminForm (mode radios, role checkboxes, send_now) + CustomNotificationAdmin (autocomplete target_users, audience_preview readonly, send in save_related after M2M commit, send/resend bulk actions, failure guards)
- backend/notifications/tests.py: 17 new tests (resolution modes, fan-out privacy guard, zero recipients, double-send, audience-scoped resend, feed visibility, admin POST flows incl. save_related ordering)
- frontend/src/routes/Notifications.svelte: markdown bodies via parseMarkdown, row as div[role=button] so body links are clickable, cursor-default without link_url, scoped styles for p/a/lists/code
- frontend/src/components/NotificationCenter.svelte: cursor affordance only when link_url set
- backend/CLAUDE.md, frontend/CLAUDE.md: document campaigns module and markdown rendering
Social task admins get the same off-by-default broadcast checkbox as
other content types, but the notification goes only to the role the
task's category targets: builder tasks reach builders, validator tasks
reach validators, and community tasks reach community members. This
adds builders and community as notification audiences, resolved
through the existing role profiles, and links each announcement to the
matching category tasks page.

## Claude Implementation Notes
- backend/notifications/models.py: AUDIENCE_BUILDERS and AUDIENCE_COMMUNITY choices; migration 0003 (AlterField)
- backend/notifications/services.py: audiences_for resolves Builder/Creator profiles; estimate_broadcast_reach counts them; broadcast_social_task maps task.category.slug to audience and to /builders|validators|community/tasks, raises on unmapped categories instead of over-notifying
- backend/notifications/registry.py: social_task.published EventType (content category)
- backend/social_tasks/admin.py: SocialTaskAdmin uses BroadcastNotificationAdminMixin; eligible only while is_currently_active()
- backend/notifications/tests.py: 5 tests (audience resolution, builder/community scoping incl. feed visibility, single-row rebroadcast, admin POST with checkbox)
- backend/CLAUDE.md: document new audiences and producer
Notification bodies now render through the image-free markdown
sanitizer, so a campaign body can no longer embed an external image
that would report each recipient's IP and open timing to the image
host when they open their notifications. Clicking an inline link
inside a body now follows only that link: it still marks the
notification read but no longer also triggers the row's own redirect.

## Claude Implementation Notes
- frontend/src/routes/Notifications.svelte: parseUserMarkdown() instead of parseMarkdown() for bodies; openNotification takes the click event and skips followNotificationLink when the click originated from an anchor (keyboard activation still follows the row link)
- frontend/CLAUDE.md: document the image-free sanitizer and anchor-click behavior
Admins can now recall mistaken portal notifications from the same surfaces that sent them. Source-object broadcasts get a recall checkbox and bulk action via the broadcast admin mixin, deleting the single broadcast row and any receipts. Custom notification campaigns get a recall checkbox and bulk action that deletes all delivered portal rows while keeping the campaign record for audit and resend.

## Claude Implementation Notes
- backend/notifications/services.py: add broadcast_dedupe_key() and recall_broadcast() for source-object broadcast deletion
- backend/notifications/admin_mixins.py: add recall checkbox, mutual-exclusion validation, bulk recall action, and broadcast_event_slug config
- backend/contributions/admin.py, partners/admin.py, gen_tv/admin.py, poaps/admin.py, contributions/node_upgrade/admin.py, social_tasks/admin.py: declare broadcast_event_slug for recall lookup
- backend/notifications/campaigns.py: add recall_campaign() for custom campaign portal-row deletion
- backend/notifications/admin.py: add recall checkbox/action to CustomNotificationAdmin
- backend/notifications/tests.py: cover broadcast recall, admin recall, campaign recall, and social task recall
- backend/CLAUDE.md: document recall behavior
The My Submissions endpoint now lets a specific submission id take precedence over a state filter. Review notification links freeze the decision state at send time, so older links could previously AND a stale state with the submission id and land on an empty list after resubmission or appeal changes. The submission id remains owner-scoped through get_queryset().

## Claude Implementation Notes
- backend/contributions/views.py: apply the state filter only when no specific submission id is requested
- backend/contributions/tests/test_security_boundaries.py: add regression coverage for stale-state deep links and cross-user submission id scoping
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant