Skip to content

feat: add in-app notification system (#98)#99

Merged
1 commit merged into
mainfrom
devin/1782064598-notification-system
Jun 21, 2026
Merged

feat: add in-app notification system (#98)#99
1 commit merged into
mainfrom
devin/1782064598-notification-system

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements the full in-app notification system requested in #98. The system uses Firebase Firestore for persistence and real-time updates.

Architecture

src/dashboard/notifications/
├── types.ts                      # Notification & NotificationPreferences interfaces
├── notificationService.ts        # Firestore CRUD: subscribe, paginate, mark-read, preferences
├── notificationTriggers.ts       # Helper functions to create notifications for events
├── NotificationContextObject.ts  # React context definition (split for fast-refresh)
├── NotificationContext.tsx       # NotificationProvider — real-time Firestore listeners
├── useNotifications.ts           # useNotifications() hook
├── NotificationBell.tsx          # Bell icon + dropdown panel in top bar
├── NotificationCenter.tsx        # /dashboard/notifications — full-page list
├── NotificationPreferences.tsx   # /dashboard/notification-preferences — toggle categories/channels
└── index.ts                      # Barrel exports

Key decisions

  • Real-time via onSnapshot: NotificationProvider subscribes to Firestore with onSnapshot — notifications update instantly without refresh.
  • Context split: NotificationContextObject.ts holds the createContext call; NotificationContext.tsx holds the Provider component; useNotifications.ts has the hook — avoids the react-refresh/only-export-components lint error.
  • Top bar bell visible globally: The DashboardLayout header (now always visible, not mobile-only) includes <NotificationBell /> with animated unread badge and dropdown panel.
  • Paginated center: NotificationCenter does client-side filtering/search/pagination over the real-time snapshot; fetchNotificationsPage is available for server-side cursor pagination if needed.
  • Trigger utilities: notificationTriggers.ts exports typed helpers (notifyProjectCreated, notifyPaymentReceived, notifyWelcome, etc.) ready to be called from existing event handlers.
  • Preferences: Users can toggle category subscriptions (project/payment/message/system) and delivery channels (in-app/email/push), persisted to notificationPreferences/{userId} in Firestore.

Routes added

  • /dashboard/notificationsNotificationCenter
  • /dashboard/notification-preferencesNotificationPreferences

Link to Devin session: https://app.devin.ai/sessions/0f66026af44c4180b0c1a5adefe5832b
Requested by: @hrx01-dev

Summary by CodeRabbit

Release Notes

  • New Features
    • Added notification bell icon to the dashboard header displaying unread count with dropdown panel showing recent notifications
    • Launched full notifications dashboard with search, category filtering, read/unread status filtering, and pagination
    • Introduced notification preferences page allowing customization of notification categories (Project Updates, Payments, Messages, System Alerts) and delivery channels (In-App, Email, Push)

- Notification types, Firestore service with real-time listeners
- NotificationProvider context with subscribe/mark-read/preferences
- NotificationBell dropdown with unread badge in dashboard top bar
- NotificationCenter page at /dashboard/notifications with filters, search, pagination
- NotificationPreferences page at /dashboard/notification-preferences
- Notification trigger utilities for project, payment, account, and admin events
- Routes and sidebar navigation integration

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a complete in-app notification system to the dashboard. New Firestore-backed service functions handle real-time subscription, pagination, read mutations, and preference persistence. A React context provider exposes notifications and actions to the UI. Three new UI components are added: NotificationBell (dropdown), NotificationCenter (full page), and NotificationPreferences (settings page). Fourteen event-trigger helpers cover project, payment, account, and system categories. Routes and layout are wired to integrate everything.

Changes

In-App Notification System

Layer / File(s) Summary
Types, context contract, and hook
src/dashboard/notifications/types.ts, src/dashboard/notifications/NotificationContextObject.ts, src/dashboard/notifications/useNotifications.ts
Defines NotificationType, NotificationCategory, Notification, NotificationPreferences, DEFAULT_PREFERENCES, the NotificationContextValue interface, the NotificationContext object with no-op defaults, and the useNotifications hook.
Firestore notification service
src/dashboard/notifications/notificationService.ts
Implements Firestore document mapper, real-time subscribeToNotifications, cursor-based fetchNotificationsPage, markAsRead, batched markAllAsRead, createNotification, subscribeToPreferences, and updatePreferences.
Notification trigger helpers
src/dashboard/notifications/notificationTriggers.ts
Adds a shared trigger helper and 14 exported notify* functions covering project, payment, account, and admin/system event categories, each calling createNotification with formatted titles and messages.
NotificationProvider context wiring
src/dashboard/notifications/NotificationContext.tsx
Subscribes to notifications and preferences on auth state change, computes unreadCount, and exposes markAsRead/markAllAsRead/updatePreferences callbacks through NotificationContext.Provider.
NotificationBell dropdown UI
src/dashboard/notifications/NotificationBell.tsx
Renders a bell icon with an unread badge, an animated dropdown listing up to 8 recent notifications with type icons and relative timestamps, outside-click close, "Mark all read", and a link to the full notifications page.
NotificationCenter page
src/dashboard/notifications/NotificationCenter.tsx
Full notifications page with search, category and read-state filters, memoized client-side pagination, animated NotificationRow cards, mark-all-read, and a link to preferences.
NotificationPreferences page
src/dashboard/notifications/NotificationPreferences.tsx
Preferences page with ToggleRow components for four notification categories and three delivery channels, change detection against persisted state, and an animated save button with success feedback.
Barrel export and dashboard routing/layout wiring
src/dashboard/notifications/index.ts, src/app/App.tsx, src/dashboard/components/DashboardLayout.tsx
Adds the index.ts barrel, registers /dashboard/notifications and /dashboard/notification-preferences routes, wraps DashboardLayout in NotificationProvider, adds NotificationBell to the top bar, and adds a "Notifications" nav entry.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant NotificationBell
  participant NotificationProvider
  participant notificationService
  participant Firestore

  User->>NotificationBell: Click bell icon
  NotificationBell->>NotificationProvider: reads useNotifications() context
  NotificationProvider-->>NotificationBell: notifications[], unreadCount

  User->>NotificationBell: Click notification item
  NotificationBell->>NotificationProvider: markAsRead(notificationId)
  NotificationProvider->>notificationService: markAsRead(id)
  notificationService->>Firestore: updateDoc(isRead: true)

  User->>NotificationBell: Click "Mark all read"
  NotificationBell->>NotificationProvider: markAllAsRead()
  NotificationProvider->>notificationService: markAllAsRead(userId)
  notificationService->>Firestore: batch.update all isRead=false docs

  Note over notificationService,Firestore: Real-time subscription
  Firestore-->>notificationService: onSnapshot update
  notificationService-->>NotificationProvider: Notification[]
  NotificationProvider-->>NotificationBell: updated unreadCount
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • Add In-App Notification System #98: This PR directly implements the complete in-app notification system described in the issue, including the notification bell, notification center page, Firestore real-time integration, preferences page, and all event trigger categories (project, payment, account, system).

Possibly related PRs

  • hrx01-dev/Servio#68: Both PRs modify src/app/App.tsx and src/dashboard/components/DashboardLayout.tsx to add new routes and wire components into the existing dashboard structure.

Suggested reviewers

  • hrx01-dev

Poem

🐰 Hop hop, a bell now rings with glee,
Notifications flow from Firestore free,
A dropdown blooms with every unread dot,
Mark all as read — forget me not!
The rabbit cheers: the dashboard's complete, 🔔
With preferences saved, the system's neat!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.12% 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 title 'feat: add in-app notification system (#98)' clearly and concisely describes the main change: implementing a complete in-app notification system with Firebase integration, UI components, and routing.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch devin/1782064598-notification-system

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

@github-actions

Copy link
Copy Markdown

Visit the preview URL for this PR (updated for commit 7713e02):

https://servio-0--pr99-devin-1782064598-not-utrqi3a8.web.app

(expires Sun, 28 Jun 2026 18:05:27 GMT)

🔥 via Firebase Hosting GitHub Action 🌎

Sign: 15915abb5951eb298a844eda460b24f444d93a69

@hrx01-dev hrx01-dev self-requested a review June 21, 2026 18:10
@hrx01-dev

Copy link
Copy Markdown
Owner

add a welcome message notification for all new users when they signup

@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

🧹 Nitpick comments (1)
src/dashboard/notifications/NotificationContextObject.ts (1)

15-23: ⚡ Quick win

Fail fast when NotificationProvider is missing.

The default no-op context value hides wiring bugs; consumers outside the provider will “work” but silently discard actions. Prefer a nullable context and throw in the hook.

♻️ Proposed refactor
- export const NotificationContext = createContext<NotificationContextValue>({
-  notifications: [],
-  unreadCount: 0,
-  loading: true,
-  preferences: DEFAULT_PREFERENCES,
-  markAsRead: async () => {},
-  markAllAsRead: async () => {},
-  updatePreferences: async () => {},
-});
+export const NotificationContext =
+  createContext<NotificationContextValue | null>(null);
// src/dashboard/notifications/useNotifications.ts
 export function useNotifications() {
-  return useContext(NotificationContext);
+  const context = useContext(NotificationContext);
+  if (!context) {
+    throw new Error("useNotifications must be used within NotificationProvider");
+  }
+  return context;
 }
🤖 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 `@src/dashboard/notifications/NotificationContextObject.ts` around lines 15 -
23, The NotificationContext default value currently provides no-op
implementations that silently ignore actions when the context is used outside of
NotificationProvider, making bugs hard to detect. Change the NotificationContext
default value to null instead of providing default implementations. Then, in the
custom hook that consumes this context (typically a useNotification or similar
hook), add a null check that throws a descriptive error message if the hook is
called when NotificationProvider is not present in the component tree, ensuring
developers get immediate feedback when the context is missing.
🤖 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 `@src/dashboard/notifications/NotificationCenter.tsx`:
- Around line 93-133: The Card component in NotificationCenter currently relies
on an onClick handler which is not keyboard-accessible. Implement the stretched
link pattern by: setting position: relative on the Card, converting the
notification.title element into an interactive element (link or button), adding
a CSS ::after pseudo-element with position: absolute; inset: 0 to make the
entire card surface clickable, and positioning the secondary interactive
elements (the icon from typeIcon, the Badge with categoryBadgeClass, and the
timestamp) with position: relative and appropriate z-index values so they remain
interactive and layered above the stretched link. Remove the onClick handler
from the Card element.

In `@src/dashboard/notifications/NotificationContext.tsx`:
- Around line 27-31: In the NotificationContext.tsx file, the conditional block
checking if currentUser is falsy is resetting notifications and loading state
but not resetting preferences, which leaves stale user data exposed. Add a call
to setPreferences with DEFAULT_PREFERENCES in the same conditional block where
currentUser is null, alongside the existing setNotifications and setLoading
calls, to ensure preferences are cleared when the user logs out or switches.

In `@src/dashboard/notifications/NotificationPreferences.tsx`:
- Around line 58-63: The localPrefs state is initialized once from the
preferences prop but never syncs when preferences changes asynchronously from
Firestore, causing stale data. Add a useEffect hook that watches the preferences
dependency and calls setLocalPrefs with the updated preferences value whenever
the preferences prop changes, ensuring the local state stays in sync with the
remote data loaded from Firestore.
- Around line 65-73: The handleSave function currently sets setSaved(true)
immediately after awaiting updatePreferences, but since updatePreferences in
NotificationContext.tsx catches and logs errors internally without throwing, the
success state displays even when the update fails. Modify the updatePreferences
function to return a success/failure indicator (such as a boolean or result
object) instead of silently catching errors, then update the handleSave function
to only call setSaved(true) when updatePreferences actually returns a successful
result. Alternatively, rethrow the error in updatePreferences so handleSave can
catch it in an additional catch block to handle failures appropriately.

In `@src/dashboard/notifications/notificationService.ts`:
- Around line 108-117: The `markAsRead()` function lacks user validation,
allowing any user to update any notification by ID, and `markAllAsRead()`
accepts userId as a client parameter without verifying it matches the
authenticated user. Modify the `markAsRead()` signature to accept a userId
parameter alongside notificationId, then add validation logic before the
updateDoc call to verify that the notification's userId matches the provided
userId parameter. For `markAllAsRead()`, ensure the userId parameter is
validated against the authenticated user's ID before executing the query.
Additionally, define explicit Firestore security rules for the
NOTIFICATIONS_COLLECTION that enforce request.auth.uid ==
request.resource.data.userId for all read and write operations to prevent
unauthorized access at the database level.
- Around line 122-127: The markAllAsRead function processes all unread
notifications in a single writeBatch, which fails when the count exceeds
Firestore's 500 operation limit. Modify the function to chunk the snapshot.docs
array into groups of 500 or fewer, creating a new writeBatch for each chunk and
committing each batch separately. This ensures that regardless of the number of
unread notifications, they will all be marked as read without exceeding
Firestore's batch operation constraints.

In `@src/dashboard/notifications/notificationTriggers.ts`:
- Around line 13-15: The trigger function is creating notifications directly
without checking the user's notification preferences, making preference settings
ineffective. Before calling createNotification within the trigger function,
fetch the user's notification preferences based on the user ID and notification
category/channel from the params object, then only proceed with the
createNotification call if the user's preferences allow notifications for that
specific category and channel combination. This will ensure that disabled
notification preferences are properly respected during notification generation.
- Around line 13-18: The trigger function is catching errors from
createNotification and logging them without re-throwing, which prevents callers
from detecting failures or implementing retry logic. Remove the try-catch block
around the createNotification call in the trigger function to allow errors to
propagate up to callers, or alternatively keep the catch block but re-throw the
error after logging so that the failure is not silently swallowed.

---

Nitpick comments:
In `@src/dashboard/notifications/NotificationContextObject.ts`:
- Around line 15-23: The NotificationContext default value currently provides
no-op implementations that silently ignore actions when the context is used
outside of NotificationProvider, making bugs hard to detect. Change the
NotificationContext default value to null instead of providing default
implementations. Then, in the custom hook that consumes this context (typically
a useNotification or similar hook), add a null check that throws a descriptive
error message if the hook is called when NotificationProvider is not present in
the component tree, ensuring developers get immediate feedback when the context
is missing.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 102aa66f-1ba5-4896-ba16-4f83997d24dd

📥 Commits

Reviewing files that changed from the base of the PR and between 81bc69f and 7713e02.

📒 Files selected for processing (12)
  • src/app/App.tsx
  • src/dashboard/components/DashboardLayout.tsx
  • src/dashboard/notifications/NotificationBell.tsx
  • src/dashboard/notifications/NotificationCenter.tsx
  • src/dashboard/notifications/NotificationContext.tsx
  • src/dashboard/notifications/NotificationContextObject.ts
  • src/dashboard/notifications/NotificationPreferences.tsx
  • src/dashboard/notifications/index.ts
  • src/dashboard/notifications/notificationService.ts
  • src/dashboard/notifications/notificationTriggers.ts
  • src/dashboard/notifications/types.ts
  • src/dashboard/notifications/useNotifications.ts

Comment on lines +93 to +133
<Card
className={`border-gray-200 dark:border-slate-800 bg-white dark:bg-slate-900 cursor-pointer hover:shadow-md transition-shadow ${
!notification.isRead ? "ring-1 ring-indigo-200 dark:ring-indigo-800" : ""
}`}
onClick={handleClick}
>
<CardContent className="flex items-start gap-4 py-4">
<div className="mt-0.5 shrink-0">{typeIcon(notification.type)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3
className={`text-sm ${
!notification.isRead
? "font-semibold text-gray-900 dark:text-gray-100"
: "font-medium text-gray-700 dark:text-gray-300"
}`}
>
{notification.title}
</h3>
<Badge className={categoryBadgeClass(notification.category)}>
{categoryLabel(notification.category)}
</Badge>
{!notification.isRead && (
<span className="h-2 w-2 rounded-full bg-indigo-500 shrink-0" />
)}
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{notification.message}
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1.5">
{formatDistanceToNow(notification.createdAt, {
addSuffix: true,
})}
</p>
</div>
{notification.actionUrl && (
<ExternalLink className="h-4 w-4 text-gray-400 shrink-0 mt-1" />
)}
</CardContent>
</Card>
</motion.div>

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

🧩 Analysis chain

🌐 Web query:

keyboard accessibility for React Card components button wrapper

💡 Result:

To make a React Card component accessible while allowing the entire surface area to be clickable, you should avoid wrapping the entire card in a single anchor or button tag [1][2][3]. Doing so forces screen readers to concatenate all the content inside the card, resulting in a confusing and non-descriptive accessible name [1][2]. The recommended industry approach is to use a CSS "stretched link" technique [1][3]. Recommended Implementation Strategy: 1. Use a standard interactive element (an tag if the card navigates, or a if it triggers an action) for the card's primary action [4][1]. 2. Place this link or button inside the card, often associated with the card's title [1][2]. 3. Apply a CSS::after pseudo-element to the primary link/button that covers the entire card area [1][2]. - Set the pseudo-element to position: absolute, inset: 0 (or top, right, bottom, left: 0) [1]. - Ensure the card container itself is set to position: relative to serve as the reference for the absolute positioning [1]. 4. This ensures the entire card is clickable for mouse users while screen readers receive a concise, meaningful label derived from the actual link or button text [1][2]. Handling Nested Interactive Elements: If your card contains secondary buttons or links (e.g., "Share", "Delete"), you must ensure they remain independently interactive [1][2]. - Position these secondary elements above the stretched link/button layer using CSS [1]. - Apply position: relative and z-index (e.g., z-index: 1) to the secondary elements so they sit visually and functionally "on top" of the stretched link area [1]. Best Practices: - Semantic Grouping: If displaying multiple cards, wrap them in an unordered list (

Make notification rows keyboard-accessible using a stretched link pattern, not a button wrapper.

The row is currently click-only (Card with onClick), preventing keyboard access. However, wrapping the entire Card in a button creates poor semantics—screen readers will concatenate all content as the button label, resulting in a confusing accessible name.

Use the industry-standard stretched link pattern instead:

  1. Place an interactive element (link or button) for the primary action inside the card, typically on the title
  2. Use a CSS ::after pseudo-element on that element to cover the entire card surface with position: absolute; inset: 0
  3. Set position: relative on the card container
  4. Position any secondary interactive elements (if present) above the stretched link using position: relative and appropriate z-index

This preserves semantic structure while making the entire surface clickable for mouse users and keyboard-navigable for assistive technology users.

🤖 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 `@src/dashboard/notifications/NotificationCenter.tsx` around lines 93 - 133,
The Card component in NotificationCenter currently relies on an onClick handler
which is not keyboard-accessible. Implement the stretched link pattern by:
setting position: relative on the Card, converting the notification.title
element into an interactive element (link or button), adding a CSS ::after
pseudo-element with position: absolute; inset: 0 to make the entire card surface
clickable, and positioning the secondary interactive elements (the icon from
typeIcon, the Badge with categoryBadgeClass, and the timestamp) with position:
relative and appropriate z-index values so they remain interactive and layered
above the stretched link. Remove the onClick handler from the Card element.

Comment on lines +27 to +31
if (!currentUser) {
setNotifications([]);
setLoading(false);
return;
}

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

🧩 Analysis chain

🏁 Script executed:

cat src/dashboard/notifications/NotificationContext.tsx

Repository: hrx01-dev/Servio

Length of output: 2656


Reset preferences on logout/user switch to prevent stale cross-user state.

When currentUser becomes null, preferences retains the previous user's value and remains exposed via the NotificationContext until the next user logs in. This causes a brief window where one user's notification preferences are visible to another session.

Reset to DEFAULT_PREFERENCES when currentUser is null (and before subscribing to new user data) to eliminate this cross-session data leakage.

🐛 Proposed fix
   useEffect(() => {
     if (!currentUser) {
       setNotifications([]);
+      setPreferences(DEFAULT_PREFERENCES);
       setLoading(false);
       return;
     }

     setLoading(true);
+    setPreferences(DEFAULT_PREFERENCES);
     const unsubNotifications = subscribeToNotifications(
📝 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
if (!currentUser) {
setNotifications([]);
setLoading(false);
return;
}
if (!currentUser) {
setNotifications([]);
setPreferences(DEFAULT_PREFERENCES);
setLoading(false);
return;
}
🤖 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 `@src/dashboard/notifications/NotificationContext.tsx` around lines 27 - 31, In
the NotificationContext.tsx file, the conditional block checking if currentUser
is falsy is resetting notifications and loading state but not resetting
preferences, which leaves stale user data exposed. Add a call to setPreferences
with DEFAULT_PREFERENCES in the same conditional block where currentUser is
null, alongside the existing setNotifications and setLoading calls, to ensure
preferences are cleared when the user logs out or switches.

Comment on lines +58 to +63
const [localPrefs, setLocalPrefs] = useState<PrefsType>(preferences);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);

const hasChanges =
JSON.stringify(localPrefs) !== JSON.stringify(preferences);

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

Sync localPrefs when remote preferences change.

localPrefs on Line 58 is initialized once, but preferences is loaded asynchronously from Firestore. This can leave stale toggle state and mark the form as changed without user edits.

Proposed fix
-import { useState } from "react";
+import { useEffect, useState } from "react";
...
   const [localPrefs, setLocalPrefs] = useState<PrefsType>(preferences);
+  const [isDirty, setIsDirty] = useState(false);
   const [saving, setSaving] = useState(false);
   const [saved, setSaved] = useState(false);

+  useEffect(() => {
+    if (!isDirty && !saving) {
+      setLocalPrefs(preferences);
+    }
+  }, [preferences, isDirty, saving]);
+
   const hasChanges =
     JSON.stringify(localPrefs) !== JSON.stringify(preferences);
...
   const updateCategory = (
     key: keyof PrefsType["categories"],
     value: boolean,
   ) => {
+    setIsDirty(true);
     setLocalPrefs((prev) => ({
       ...prev,
       categories: { ...prev.categories, [key]: value },
     }));
   };
...
   const updateChannel = (
     key: keyof PrefsType["channels"],
     value: boolean,
   ) => {
+    setIsDirty(true);
     setLocalPrefs((prev) => ({
       ...prev,
       channels: { ...prev.channels, [key]: value },
     }));
   };
📝 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
const [localPrefs, setLocalPrefs] = useState<PrefsType>(preferences);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const hasChanges =
JSON.stringify(localPrefs) !== JSON.stringify(preferences);
const [localPrefs, setLocalPrefs] = useState<PrefsType>(preferences);
const [isDirty, setIsDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
if (!isDirty && !saving) {
setLocalPrefs(preferences);
}
}, [preferences, isDirty, saving]);
const hasChanges =
JSON.stringify(localPrefs) !== JSON.stringify(preferences);
🤖 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 `@src/dashboard/notifications/NotificationPreferences.tsx` around lines 58 -
63, The localPrefs state is initialized once from the preferences prop but never
syncs when preferences changes asynchronously from Firestore, causing stale
data. Add a useEffect hook that watches the preferences dependency and calls
setLocalPrefs with the updated preferences value whenever the preferences prop
changes, ensuring the local state stays in sync with the remote data loaded from
Firestore.

Comment on lines +65 to +73
const handleSave = async () => {
setSaving(true);
try {
await updatePreferences(localPrefs);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} finally {
setSaving(false);
}

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

Don’t show “Saved!” unless persistence actually succeeds.

Lines 68-70 assume success after await updatePreferences(localPrefs), but updatePreferences in src/dashboard/notifications/NotificationContext.tsx currently catches/logs errors and resolves. This can show a false success state.

Proposed fix
// src/dashboard/notifications/NotificationContext.tsx
  const updatePreferences = useCallback(
    async (prefs: NotificationPreferences) => {
      if (!currentUser) return;
      try {
        await updatePreferencesService(currentUser.uid, prefs);
      } catch (error) {
        console.error("Failed to update preferences:", error);
+       throw error;
      }
    },
    [currentUser],
  );
// src/dashboard/notifications/NotificationPreferences.tsx
   const handleSave = async () => {
     setSaving(true);
     try {
       await updatePreferences(localPrefs);
       setSaved(true);
       setTimeout(() => setSaved(false), 2000);
+      setIsDirty(false);
+    } catch {
+      setSaved(false);
     } finally {
       setSaving(false);
     }
   };
🤖 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 `@src/dashboard/notifications/NotificationPreferences.tsx` around lines 65 -
73, The handleSave function currently sets setSaved(true) immediately after
awaiting updatePreferences, but since updatePreferences in
NotificationContext.tsx catches and logs errors internally without throwing, the
success state displays even when the update fails. Modify the updatePreferences
function to return a success/failure indicator (such as a boolean or result
object) instead of silently catching errors, then update the handleSave function
to only call setSaved(true) when updatePreferences actually returns a successful
result. Alternatively, rethrow the error in updatePreferences so handleSave can
catch it in an additional catch block to handle failures appropriately.

Comment on lines +108 to +117
export async function markAsRead(notificationId: string): Promise<void> {
const ref = doc(db, NOTIFICATIONS_COLLECTION, notificationId);
await updateDoc(ref, { isRead: true });
}

export async function markAllAsRead(userId: string): Promise<void> {
const q = query(
collection(db, NOTIFICATIONS_COLLECTION),
where("userId", "==", userId),
where("isRead", "==", false),

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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locating Firestore rules/config files..."
fd -i 'firestore.rules|firebase.json|firestore.indexes.json'

RULE_FILE="$(fd -i 'firestore.rules' | head -n1 || true)"
if [ -z "${RULE_FILE}" ]; then
  echo "No firestore.rules file found in repository root scan."
  exit 0
fi

echo "Inspecting notification-related rule blocks in ${RULE_FILE}..."
rg -n -C4 'notifications|notificationPreferences|allow\s+(read|write|create|update|delete)' "${RULE_FILE}"

Repository: hrx01-dev/Servio

Length of output: 3560


🏁 Script executed:

# Get the full firestore.rules file to check if notifications collection has rules
cat firestore.rules

Repository: hrx01-dev/Servio

Length of output: 6289


🏁 Script executed:

# Read the full notificationService.ts file to understand the implementation
cat src/dashboard/notifications/notificationService.ts

Repository: hrx01-dev/Servio

Length of output: 4224


Add per-user ownership verification to notification mutation functions and define explicit Firestore rules.

markAsRead() lacks user validation entirely—any user can update any notification by ID. markAllAsRead() accepts userId as a client parameter without verifying it matches the authenticated user. Define explicit rules for the notifications and notificationPreferences collections that enforce request.auth.uid == userId for all read/write operations, and modify markAsRead(notificationId, userId) to accept and validate the userId parameter server-side (via Firestore rules).

🤖 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 `@src/dashboard/notifications/notificationService.ts` around lines 108 - 117,
The `markAsRead()` function lacks user validation, allowing any user to update
any notification by ID, and `markAllAsRead()` accepts userId as a client
parameter without verifying it matches the authenticated user. Modify the
`markAsRead()` signature to accept a userId parameter alongside notificationId,
then add validation logic before the updateDoc call to verify that the
notification's userId matches the provided userId parameter. For
`markAllAsRead()`, ensure the userId parameter is validated against the
authenticated user's ID before executing the query. Additionally, define
explicit Firestore security rules for the NOTIFICATIONS_COLLECTION that enforce
request.auth.uid == request.resource.data.userId for all read and write
operations to prevent unauthorized access at the database level.

Comment on lines +122 to +127
const batch = writeBatch(db);
snapshot.docs.forEach((docSnap) => {
batch.update(docSnap.ref, { isRead: true });
});
await batch.commit();
}

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

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the actual file to see the current state
cat -n src/dashboard/notifications/notificationService.ts | head -150

Repository: hrx01-dev/Servio

Length of output: 4994


🌐 Web query:

Firestore writeBatch operation limit maximum writes per batch

💡 Result:

The maximum number of write operations allowed in a single Firestore WriteBatch is 500 [1]. If you attempt to exceed this limit by adding more than 500 operations to a single batch, the operation will fail. It is recommended that for operations requiring a larger number of documents, you should either split the work into smaller batches or use alternative methods such as a bulk writer or parallelized individual writes [2]. Note that this 500-operation limit also applies to transactions [2][3].

Citations:


Add batch chunking to prevent failure when unread notification count exceeds 500.

The markAllAsRead function queries all unread notifications with no limit and attempts to update them in a single batch. Firestore's writeBatch operation limit is 500; if a user has more than 500 unread notifications, commit() will fail and nothing will be marked read.

Proposed fix
 export async function markAllAsRead(userId: string): Promise<void> {
   const q = query(
     collection(db, NOTIFICATIONS_COLLECTION),
     where("userId", "==", userId),
     where("isRead", "==", false),
   );
   const snapshot = await getDocs(q);
   if (snapshot.empty) return;
 
+  const BATCH_SIZE = 500;
-  const batch = writeBatch(db);
-  snapshot.docs.forEach((docSnap) => {
+  for (let i = 0; i < snapshot.docs.length; i += BATCH_SIZE) {
+    const batch = writeBatch(db);
+    snapshot.docs.slice(i, i + BATCH_SIZE).forEach((docSnap) => {
       batch.update(docSnap.ref, { isRead: true });
+    });
+    await batch.commit();
   }
-  await batch.commit();
 }
📝 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
const batch = writeBatch(db);
snapshot.docs.forEach((docSnap) => {
batch.update(docSnap.ref, { isRead: true });
});
await batch.commit();
}
const BATCH_SIZE = 500;
for (let i = 0; i < snapshot.docs.length; i += BATCH_SIZE) {
const batch = writeBatch(db);
snapshot.docs.slice(i, i + BATCH_SIZE).forEach((docSnap) => {
batch.update(docSnap.ref, { isRead: true });
});
await batch.commit();
}
}
🤖 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 `@src/dashboard/notifications/notificationService.ts` around lines 122 - 127,
The markAllAsRead function processes all unread notifications in a single
writeBatch, which fails when the count exceeds Firestore's 500 operation limit.
Modify the function to chunk the snapshot.docs array into groups of 500 or
fewer, creating a new writeBatch for each chunk and committing each batch
separately. This ensures that regardless of the number of unread notifications,
they will all be marked as read without exceeding Firestore's batch operation
constraints.

Comment on lines +13 to +15
async function trigger(params: TriggerParams) {
try {
await createNotification(params);

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 | 🏗️ Heavy lift

Notification preferences are not applied before creating notifications.

All trigger helpers write notifications directly, so disabled categories/channels in preferences do not affect delivery. That makes preferences effectively non-functional for generation behavior.

🤖 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 `@src/dashboard/notifications/notificationTriggers.ts` around lines 13 - 15,
The trigger function is creating notifications directly without checking the
user's notification preferences, making preference settings ineffective. Before
calling createNotification within the trigger function, fetch the user's
notification preferences based on the user ID and notification category/channel
from the params object, then only proceed with the createNotification call if
the user's preferences allow notifications for that specific category and
channel combination. This will ensure that disabled notification preferences are
properly respected during notification generation.

Comment on lines +13 to +18
async function trigger(params: TriggerParams) {
try {
await createNotification(params);
} catch (error) {
console.error("Failed to create notification:", error);
}

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

Do not swallow notification creation failures.

trigger logs and resolves on error, so callers cannot detect failure or retry. This silently drops notifications on write errors.

🐛 Proposed fix
 async function trigger(params: TriggerParams) {
   try {
     await createNotification(params);
   } catch (error) {
     console.error("Failed to create notification:", error);
+    throw error;
   }
 }
📝 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
async function trigger(params: TriggerParams) {
try {
await createNotification(params);
} catch (error) {
console.error("Failed to create notification:", error);
}
async function trigger(params: TriggerParams) {
try {
await createNotification(params);
} catch (error) {
console.error("Failed to create notification:", error);
throw error;
}
}
🤖 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 `@src/dashboard/notifications/notificationTriggers.ts` around lines 13 - 18,
The trigger function is catching errors from createNotification and logging them
without re-throwing, which prevents callers from detecting failures or
implementing retry logic. Remove the try-catch block around the
createNotification call in the trigger function to allow errors to propagate up
to callers, or alternatively keep the catch block but re-throw the error after
logging so that the failure is not silently swallowed.

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Done — welcome notification is now triggered on both email and Google signup flows. The change is in the new PR #100 (this PR's branch got blocked by repo rulesets on subsequent pushes, so I opened a fresh PR with all changes including this one).

In SignUp.tsx, both handleEmailSignUp and handleGoogleSignIn now call:

notifyWelcome(user.uid, user.displayName ?? user.email ?? 'there');

@hrx01-dev hrx01-dev closed this pull request by merging all changes into main in 30fe924 Jun 21, 2026
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