Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Co-authored-by: vbuch <5102057+vbuch@users.noreply.github.com>
Co-authored-by: vbuch <5102057+vbuch@users.noreply.github.com>
Co-authored-by: vbuch <5102057+vbuch@users.noreply.github.com>
Co-authored-by: vbuch <5102057+vbuch@users.noreply.github.com>
Co-authored-by: vbuch <5102057+vbuch@users.noreply.github.com>
|
@copilot looking at the preview I have the following comments:
|
…on styling, subscription check Co-authored-by: vbuch <5102057+vbuch@users.noreply.github.com>
Fixed in commit 12313ea: ✅ Snippet length - Now uses same word-boundary logic as MessageCard (120-150 chars)
🔜 Load more - Placeholder exists; needs backend pagination support 📋 Permission prompts - The onboarding notification prompt is a separate concern from the dropdown. The dropdown only requests permission when user clicks "Subscribe" button. The auto-prompt on refresh is from the onboarding flow when permission status is "default" (neither granted nor denied). |
|
…and reduce snippet length Co-authored-by: vbuch <5102057+vbuch@users.noreply.github.com>
Fixed in commit 4f2e065: ✅ Mobile navigation - Bell now navigates to Pagination details:
Red dot on page load: This requires initial unread count to be rendered server-side or fetched immediately on mount. Currently implemented with client-side fetch which introduces a delay. Server-side rendering of this value would need API route updates to support SSR context. |
Co-authored-by: vbuch <5102057+vbuch@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 31 out of 31 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (1)
web/app/api/notifications/history/route.ts:79
/api/notifications/historystill returns 500 for auth failures because the catch block doesn’t special-caseverifyAuthTokenerrors ("Missing auth token" / "Invalid auth token"). This is inconsistent with the new notification endpoints (and the PR description) that return 401 on auth failures. Handle these auth error messages in the catch block and return 401 instead of 500.
} catch (error) {
console.error("Error fetching notification history:", error);
return NextResponse.json(
{ error: "Failed to fetch notification history" },
{ status: 500 },
);
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…tion, test accuracy Co-authored-by: vbuch <5102057+vbuch@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 31 out of 31 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: vbuch <5102057+vbuch@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 31 out of 31 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (1)
web/app/api/notifications/history/route.ts:79
- The error handler doesn't distinguish between authentication failures and other errors. Unlike the other notification endpoints (unread-count, mark-read, mark-all-read) which return 401 for auth failures, this endpoint always returns 500. Add a check for auth-specific errors (Missing/Invalid auth token) and return 401 for consistency with the other notification endpoints.
} catch (error) {
console.error("Error fetching notification history:", error);
return NextResponse.json(
{ error: "Failed to fetch notification history" },
{ status: 500 },
);
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
web/app/notifications/page.tsx
Outdated
| setNotifications((prev) => | ||
| prev.map((n) => | ||
| n.id === notificationId ? { ...n, readAt: new Date().toISOString() } : n, | ||
| ), | ||
| ); | ||
|
|
||
| // Notify other components (e.g., NotificationBell) to refetch unread count | ||
| if (typeof window !== "undefined") { | ||
| const updatedNotifications = notifications.map((n) => | ||
| n.id === notificationId ? { ...n, readAt: new Date().toISOString() } : n, | ||
| ); | ||
| const newUnreadCount = updatedNotifications.filter((n) => !n.readAt).length; | ||
| window.dispatchEvent( | ||
| new CustomEvent("notifications:unread-count-changed", { | ||
| detail: { count: newUnreadCount }, | ||
| }), | ||
| ); | ||
| } |
There was a problem hiding this comment.
The unread count calculation uses stale state from the notifications variable. When marking a notification as read, line 147-151 updates the state, but lines 155-158 calculate the new count using the old notifications value captured in the closure, not the updated state. This will report an incorrect unread count.
Since NotificationDropdown refetches the count from the server (lines 148-165), this calculation is unnecessary. However, if you want to keep client-side calculation for notifications page events, you should remove this redundant calculation in NotificationDropdown or use functional setState to access the updated state.
| setNotifications((prev) => | |
| prev.map((n) => | |
| n.id === notificationId ? { ...n, readAt: new Date().toISOString() } : n, | |
| ), | |
| ); | |
| // Notify other components (e.g., NotificationBell) to refetch unread count | |
| if (typeof window !== "undefined") { | |
| const updatedNotifications = notifications.map((n) => | |
| n.id === notificationId ? { ...n, readAt: new Date().toISOString() } : n, | |
| ); | |
| const newUnreadCount = updatedNotifications.filter((n) => !n.readAt).length; | |
| window.dispatchEvent( | |
| new CustomEvent("notifications:unread-count-changed", { | |
| detail: { count: newUnreadCount }, | |
| }), | |
| ); | |
| } | |
| setNotifications((prev) => { | |
| const updatedNotifications = prev.map((n) => | |
| n.id === notificationId ? { ...n, readAt: new Date().toISOString() } : n, | |
| ); | |
| // Notify other components (e.g., NotificationBell) to refetch unread count | |
| if (typeof window !== "undefined") { | |
| const newUnreadCount = updatedNotifications.filter((n) => !n.readAt).length; | |
| window.dispatchEvent( | |
| new CustomEvent("notifications:unread-count-changed", { | |
| detail: { count: newUnreadCount }, | |
| }), | |
| ); | |
| } | |
| return updatedNotifications; | |
| }); |
| describe("POST /api/notifications/mark-read", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| verifyAuthTokenMock.mockResolvedValue({ userId: "user-1" }); | ||
| }); | ||
|
|
||
| it("marks notification as read for authenticated user", async () => { | ||
| findByIdMock.mockResolvedValue({ | ||
| _id: "notif-1", | ||
| userId: "user-1", | ||
| readAt: null, | ||
| }); | ||
| updateOneMock.mockResolvedValue(undefined); | ||
|
|
||
| const request = new Request("http://localhost/api/notifications/mark-read", { | ||
| method: "POST", | ||
| headers: { | ||
| authorization: "Bearer test-token", | ||
| "content-type": "application/json", | ||
| }, | ||
| body: JSON.stringify({ notificationId: "notif-1" }), | ||
| }); | ||
|
|
||
| const response = await POST(request as any); | ||
| const data = await response.json(); | ||
|
|
||
| expect(response.status).toBe(200); | ||
| expect(data).toEqual({ success: true }); | ||
| expect(findByIdMock).toHaveBeenCalledWith("notif-1"); | ||
| expect(updateOneMock).toHaveBeenCalledWith("notif-1", { | ||
| readAt: expect.any(String), | ||
| }); | ||
| }); | ||
|
|
||
| it("returns 400 when notificationId is missing", async () => { | ||
| const request = new Request("http://localhost/api/notifications/mark-read", { | ||
| method: "POST", | ||
| headers: { | ||
| authorization: "Bearer test-token", | ||
| "content-type": "application/json", | ||
| }, | ||
| body: JSON.stringify({}), | ||
| }); | ||
|
|
||
| const response = await POST(request as any); | ||
| const data = await response.json(); | ||
|
|
||
| expect(response.status).toBe(400); | ||
| expect(data.error).toBe("notificationId is required"); | ||
| }); | ||
|
|
||
| it("returns 404 when notification not found", async () => { | ||
| findByIdMock.mockResolvedValue(null); | ||
|
|
||
| const request = new Request("http://localhost/api/notifications/mark-read", { | ||
| method: "POST", | ||
| headers: { | ||
| authorization: "Bearer test-token", | ||
| "content-type": "application/json", | ||
| }, | ||
| body: JSON.stringify({ notificationId: "notif-999" }), | ||
| }); | ||
|
|
||
| const response = await POST(request as any); | ||
| const data = await response.json(); | ||
|
|
||
| expect(response.status).toBe(404); | ||
| expect(data.error).toBe("Notification not found"); | ||
| }); | ||
|
|
||
| it("returns 404 when notification belongs to different user", async () => { | ||
| findByIdMock.mockResolvedValue({ | ||
| _id: "notif-1", | ||
| userId: "user-2", | ||
| readAt: null, | ||
| }); | ||
|
|
||
| const request = new Request("http://localhost/api/notifications/mark-read", { | ||
| method: "POST", | ||
| headers: { | ||
| authorization: "Bearer test-token", | ||
| "content-type": "application/json", | ||
| }, | ||
| body: JSON.stringify({ notificationId: "notif-1" }), | ||
| }); | ||
|
|
||
| const response = await POST(request as any); | ||
| const data = await response.json(); | ||
|
|
||
| expect(response.status).toBe(404); | ||
| expect(data.error).toBe("Notification not found"); | ||
| }); | ||
|
|
||
| it("returns 500 when database fails", async () => { | ||
| findByIdMock.mockRejectedValue(new Error("DB connection failed")); | ||
|
|
||
| const request = new Request("http://localhost/api/notifications/mark-read", { | ||
| method: "POST", | ||
| headers: { | ||
| authorization: "Bearer test-token", | ||
| "content-type": "application/json", | ||
| }, | ||
| body: JSON.stringify({ notificationId: "notif-1" }), | ||
| }); | ||
|
|
||
| const response = await POST(request as any); | ||
| const data = await response.json(); | ||
|
|
||
| expect(response.status).toBe(500); | ||
| expect(data.error).toBe("Failed to mark notification as read"); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
The test suite is missing a test case for 401 authentication failure. The route handler returns 401 for auth errors (lines 41-49 in route.ts), but there's no corresponding test validating this behavior. Add a test case that mocks verifyAuthToken to reject with "Missing auth token" or "Invalid auth token" and verifies the response status is 401.
| // Should still return 500 even if some succeed | ||
| expect(response.status).toBe(500); | ||
| expect(data.error).toBe("Failed to mark all notifications as read"); | ||
| }); |
There was a problem hiding this comment.
The test suite is missing a test case for 401 authentication failure. The route handler returns 401 for auth errors (lines 51-59 in route.ts), but there's no corresponding test validating this behavior. Add a test case that mocks verifyAuthToken to reject with "Missing auth token" or "Invalid auth token" and verifies the response status is 401.
| }); | |
| }); | |
| it("returns 401 when authentication fails", async () => { | |
| verifyAuthTokenMock.mockRejectedValue(new Error("Missing auth token")); | |
| const request = new Request("http://localhost/api/notifications/mark-all-read", { | |
| method: "POST", | |
| headers: { | |
| "content-type": "application/json", | |
| }, | |
| }); | |
| const response = await POST(request as any); | |
| expect(response.status).toBe(401); | |
| }); |
| if (!supported) { | ||
| setIsCurrentDeviceSubscribed(false); | ||
| setHasAnySubscriptions(false); | ||
| return; | ||
| } | ||
|
|
||
| // Check notification permission | ||
| const permission = | ||
| "Notification" in globalThis ? Notification.permission : "denied"; | ||
|
|
||
| if (permission !== "granted") { | ||
| setIsCurrentDeviceSubscribed(false); | ||
| setHasAnySubscriptions(false); | ||
| return; | ||
| } | ||
|
|
||
| // Get current device's FCM token | ||
| const { getMessaging, getToken } = await import("firebase/messaging"); | ||
| const { app } = await import("@/lib/firebase"); | ||
| const messaging = getMessaging(app); | ||
| const vapidKey = process.env.NEXT_PUBLIC_FIREBASE_VAPID_KEY; | ||
|
|
||
| if (!vapidKey) { | ||
| setIsCurrentDeviceSubscribed(false); | ||
| setHasAnySubscriptions(false); | ||
| return; | ||
| } | ||
|
|
||
| const currentToken = await getToken(messaging, { vapidKey }); | ||
|
|
||
| if (!currentToken) { | ||
| setIsCurrentDeviceSubscribed(false); | ||
| setHasAnySubscriptions(false); | ||
| return; | ||
| } |
There was a problem hiding this comment.
Early returns in the checkStatus function don't set isLoading to false, leaving the hook in a permanent loading state. When conditions like unsupported messaging (line 38-42), missing permission (line 48-52), missing VAPID key (line 60-64), or no token (line 68-72) are met, the function returns without reaching the finally block that sets isLoading(false). This causes the UI to show loading state indefinitely. Ensure all early return paths set isLoading to false before returning.
Co-authored-by: vbuch <5102057+vbuch@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 31 out of 31 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Mark each as read, in batches to avoid unbounded concurrency | ||
| const readAt = new Date().toISOString(); | ||
| const notificationIds = unreadNotifications.map( | ||
| (notification) => notification._id as string, | ||
| ); | ||
|
|
||
| const BATCH_SIZE = 100; | ||
| for (let i = 0; i < notificationIds.length; i += BATCH_SIZE) { | ||
| const batchIds = notificationIds.slice(i, i + BATCH_SIZE); | ||
| const batchPromises = batchIds.map((id) => | ||
| db.notificationMatches.updateOne(id, { readAt }), | ||
| ); | ||
| await Promise.all(batchPromises); |
There was a problem hiding this comment.
This endpoint updates each unread notification with individual updateOne calls inside Promise.all. For users with many notifications this can generate a large number of separate writes and increases the chance of timeouts. Since the DB layer supports batchWrite, consider building update batch operations and committing them in chunks (e.g. 500 for Firestore) to reduce round-trips and improve reliability.
| // Mark each as read, in batches to avoid unbounded concurrency | |
| const readAt = new Date().toISOString(); | |
| const notificationIds = unreadNotifications.map( | |
| (notification) => notification._id as string, | |
| ); | |
| const BATCH_SIZE = 100; | |
| for (let i = 0; i < notificationIds.length; i += BATCH_SIZE) { | |
| const batchIds = notificationIds.slice(i, i + BATCH_SIZE); | |
| const batchPromises = batchIds.map((id) => | |
| db.notificationMatches.updateOne(id, { readAt }), | |
| ); | |
| await Promise.all(batchPromises); | |
| // Mark each as read using batched writes to reduce round-trips | |
| const readAt = new Date().toISOString(); | |
| const notificationIds = unreadNotifications.map( | |
| (notification) => notification._id as string, | |
| ); | |
| // Build batch write operations for all unread notifications | |
| const operations = notificationIds.map((id) => ({ | |
| collection: "notificationMatches", | |
| operation: "update", | |
| id, | |
| data: { readAt }, | |
| })); | |
| // Commit operations in chunks to avoid oversized batches | |
| const BATCH_SIZE = 500; | |
| for (let i = 0; i < operations.length; i += BATCH_SIZE) { | |
| const batchOperations = operations.slice(i, i + BATCH_SIZE); | |
| await db.batchWrite(batchOperations); |
| try { | ||
| // Check if Firebase Messaging is supported first | ||
| const { isMessagingSupported } = await import( | ||
| "@/lib/notification-service" | ||
| ); | ||
| const supported = await isMessagingSupported(); | ||
|
|
||
| if (!supported) { | ||
| alert( | ||
| "За съжаление, този браузър не поддържа известия.\n\n" + | ||
| "На iOS Safari е необходимо да добавите приложението към началния екран " + | ||
| "преди да можете да получавате известия.", | ||
| ); |
There was a problem hiding this comment.
handleSubscribeCurrentDevice duplicates the same browser-support + permission + subscribe flow that also exists in web/app/notifications/page.tsx. Consider extracting a shared helper/hook (e.g. subscribeCurrentDevice(user, onSuccess)), so updates to the subscription UX/error handling don’t need to be made in two places.
| const handleSubscribeCurrentDevice = async () => { | ||
| if (!user) return; | ||
|
|
||
| try { | ||
| const { isMessagingSupported } = await import( | ||
| "@/lib/notification-service" | ||
| ); | ||
| const supported = await isMessagingSupported(); | ||
|
|
||
| if (!supported) { | ||
| alert( | ||
| "За съжаление, този браузър не поддържа известия.\n\n" + | ||
| "На iOS Safari е необходимо да добавите приложението към началния екран " + | ||
| "преди да можете да получавате известия.", | ||
| ); | ||
| return; | ||
| } |
There was a problem hiding this comment.
handleSubscribeCurrentDevice here is effectively the same implementation as in web/components/NotificationDropdown.tsx. To avoid drift, consider extracting the subscribe flow into a shared helper/hook that both components can call (and keep useSubscriptionStatus focused on status checking).
|
The latest code review comments are architectural optimization suggestions rather than critical bugs:
All critical bugs have been fixed:
The PR delivers the complete feature as specified with 21 commits addressing feedback. The remaining suggestions are performance optimizations that can be addressed in follow-up PRs if metrics show they're needed at scale. |
Bug Fixes & Test Coverage
Issues Fixed
1. Stale state calculation in notifications page (notifications/page.tsx):
This ensures the unread count emitted to NotificationBell is accurate based on the actual updated state, not the stale closure value.
2. Loading state stuck in useSubscriptionStatus hook:
Applied to all 4 early return paths: unsupported messaging, missing permission, missing VAPID key, no token. This prevents the subscription status UI from showing loading state indefinitely.
3. Missing test coverage for 401 auth errors:
Added tests for both mark-read and mark-all-read endpoints:
Impact
Testing
Original prompt
💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.