-
Notifications
You must be signed in to change notification settings - Fork 0
fix(notifications,ci): background-safe push dispatcher + unblock RLS required check — helm-review 2026-06-08 #243
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| 'use server'; | ||
|
|
||
| import { createClient } from '@/lib/supabase/server'; | ||
| import { createAdminClient } from '@/lib/supabase/admin'; | ||
| import type { NotificationType, NotificationPreferences } from './types'; | ||
| import { getUserNotificationPreferences } from './email'; | ||
|
|
||
|
|
@@ -125,14 +125,22 @@ export async function sendPushNotification( | |
| data: Record<string, unknown> | ||
| ): Promise<{ success: boolean; error?: string }> { | ||
| try { | ||
| // Check user preferences | ||
| const prefs = await getUserNotificationPreferences(userId); | ||
| // Server-side dispatcher: use the service-role admin client for ALL DB | ||
| // access here. This works in BOTH request and background contexts (cron, | ||
| // roster-sweep, the post-write insight-notifier) — the cookie-backed | ||
| // request client throws "cookies was called outside a request scope" when | ||
| // invoked outside a request, and RLS would also hide the *recipient's* | ||
| // device tokens whenever the sender differs from the recipient. The | ||
| // send-apns-push Edge Function below already authenticates with this same | ||
| // service-role key. | ||
| const supabase = createAdminClient(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Because this module is marked Useful? React with 👍 / 👎. |
||
|
|
||
| // Check user preferences (recipient row, read via the admin client above). | ||
| const prefs = await getUserNotificationPreferences(userId, supabase); | ||
| if (!shouldSendPush(type, prefs)) { | ||
| return { success: true }; // User opted out | ||
| } | ||
|
|
||
| const supabase = await createClient(); | ||
|
|
||
| // Get user's active device tokens | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const { data: tokens, error: tokenError } = await (supabase as any) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| /** | ||
| * Regression test for src/lib/notifications/push.ts — `sendPushNotification`. | ||
| * | ||
| * Guards the production fix for: | ||
| * "insight-notifier: push send reported failure: `cookies` was called | ||
| * outside a request scope" | ||
| * | ||
| * sendPushNotification is invoked from BACKGROUND contexts (the post-write | ||
| * insight-notifier, the event-reminders cron) where there is no active request, | ||
| * so it must NOT use the cookie-backed request client. It now reads prefs + | ||
| * device tokens through the service-role admin client (the same key the | ||
| * send-apns-push Edge Function already uses), which also lets it read a | ||
| * *recipient's* device tokens when the sender differs from the recipient. | ||
| * | ||
| * The cookie client (`@/lib/supabase/server`) is mocked to THROW so the test | ||
| * fails loudly if any code path regresses back to it. | ||
| */ | ||
|
|
||
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; | ||
| import { DEFAULT_NOTIFICATION_PREFERENCES } from '@/lib/notifications/types'; | ||
|
|
||
| // --- Controllable mock state ---------------------------------------------- | ||
|
|
||
| let prefs: Record<string, unknown> = { ...DEFAULT_NOTIFICATION_PREFERENCES, push_events: true }; | ||
| let deviceTokens: Array<{ token: string; platform: string }> = []; | ||
|
|
||
| // The cookie-backed request client MUST NOT be touched in a background context. | ||
| const serverCreateClient = vi.fn(async () => { | ||
| throw new Error('`cookies` was called outside a request scope'); | ||
| }); | ||
| vi.mock('@/lib/supabase/server', () => ({ | ||
| createClient: serverCreateClient, | ||
| })); | ||
|
|
||
| // Service-role admin client — chainable fake for `users` + `device_tokens`. | ||
| const adminFrom = vi.fn((table: string) => { | ||
| if (table === 'users') { | ||
| return { | ||
| select: () => ({ | ||
| eq: () => ({ | ||
| maybeSingle: async () => ({ | ||
| data: { notification_preferences: prefs }, | ||
| error: null, | ||
| }), | ||
| }), | ||
| }), | ||
| }; | ||
| } | ||
| // device_tokens | ||
| return { | ||
| select: () => ({ | ||
| eq: () => ({ | ||
| // .from().select().eq('user_id').eq('active') is awaited directly. | ||
| eq: async () => ({ data: deviceTokens, error: null }), | ||
| }), | ||
| }), | ||
| update: () => ({ | ||
| eq: async () => ({ data: null, error: null }), | ||
| }), | ||
| }; | ||
| }); | ||
| const createAdminClient = vi.fn(() => ({ from: adminFrom })); | ||
| vi.mock('@/lib/supabase/admin', () => ({ createAdminClient })); | ||
|
|
||
| // --- fetch mock ----------------------------------------------------------- | ||
|
|
||
| const fetchSpy = vi.fn(async () => ({ ok: true, text: async () => '' })); | ||
|
|
||
| let consoleErrorSpy: ReturnType<typeof vi.spyOn>; | ||
|
|
||
| beforeEach(() => { | ||
| prefs = { ...DEFAULT_NOTIFICATION_PREFERENCES, push_events: true }; | ||
| deviceTokens = [{ token: 'devicetoken123', platform: 'ios' }]; | ||
| serverCreateClient.mockClear(); | ||
| adminFrom.mockClear(); | ||
| createAdminClient.mockClear(); | ||
| fetchSpy.mockClear(); | ||
| vi.stubGlobal('fetch', fetchSpy); | ||
| consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| consoleErrorSpy.mockRestore(); | ||
| }); | ||
|
|
||
| describe('sendPushNotification (background-safe)', () => { | ||
| it('delivers via the service-role admin client without touching the cookie request client', async () => { | ||
| const { sendPushNotification } = await import('@/lib/notifications/push'); | ||
| const result = await sendPushNotification('coachhelm_insight', 'user-1', { insightTitle: 'x' }); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| expect(createAdminClient).toHaveBeenCalled(); | ||
| // The regression guard: the no-request-scope cookie client is never used. | ||
| expect(serverCreateClient).not.toHaveBeenCalled(); | ||
| expect(fetchSpy).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('honors the recipient opt-out (push_events=false) and sends nothing', async () => { | ||
| prefs = { ...DEFAULT_NOTIFICATION_PREFERENCES, push_events: false }; | ||
| const { sendPushNotification } = await import('@/lib/notifications/push'); | ||
| const result = await sendPushNotification('coachhelm_insight', 'user-1', { insightTitle: 'x' }); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| expect(serverCreateClient).not.toHaveBeenCalled(); | ||
| expect(fetchSpy).not.toHaveBeenCalled(); | ||
| }); | ||
|
Comment on lines
+99
to
+107
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 The Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| it('returns success when the recipient has no active device tokens', async () => { | ||
| deviceTokens = []; | ||
| const { sendPushNotification } = await import('@/lib/notifications/push'); | ||
| const result = await sendPushNotification('coachhelm_insight', 'user-1', { insightTitle: 'x' }); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| expect(serverCreateClient).not.toHaveBeenCalled(); | ||
| expect(fetchSpy).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚩 Email path still uses cookie-backed client in cron contexts
The PR fixes the push notification path to use the admin client in background contexts, but
sendEmailNotificationatsrc/lib/notifications/email.ts:795still callsgetUserNotificationPreferences(recipientId)without passing a client, andgetRecipientGreetingatemail.ts:737also callsawait createClient()directly. The cron event-reminders route (src/app/api/cron/event-reminders/route.ts:251) callssendBulkEmailNotificationwhich flows through both of these, meaning emails from cron would hit the same "cookies outside request scope" error the PR fixes for push. The cron route catches the error (route.ts:251:.catch(() => {...})), so it's non-fatal, but emails silently fail. This is pre-existing and out of the PR's stated scope (push-only fix), but the infrastructure to fix it (the optionalclientparameter) is now in place and could be extended to the email path.(Refers to line 795)
Was this helpful? React with 👍 or 👎 to provide feedback.