Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/lib/notifications/email.ts

Copy link
Copy Markdown
Contributor

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 sendEmailNotification at src/lib/notifications/email.ts:795 still calls getUserNotificationPreferences(recipientId) without passing a client, and getRecipientGreeting at email.ts:737 also calls await createClient() directly. The cron event-reminders route (src/app/api/cron/event-reminders/route.ts:251) calls sendBulkEmailNotification which 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 optional client parameter) is now in place and could be extended to the email path.

(Refers to line 795)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
* Falls back gracefully if Resend is not configured.
*/

import type { SupabaseClient } from '@supabase/supabase-js';
import { createClient } from '@/lib/supabase/server';
import type { Database } from '@/lib/types/database';
import type { NotificationPreferences, NotificationType, EmailTemplate } from './types';
import { DEFAULT_NOTIFICATION_PREFERENCES } from './types';

Expand Down Expand Up @@ -37,10 +39,15 @@ async function getResendClient() {
* Falls back to defaults if preferences not set or column missing.
*/
export async function getUserNotificationPreferences(
userId: string
userId: string,
client?: SupabaseClient<Database>
): Promise<NotificationPreferences> {
try {
const supabase = await createClient();
// Accept a caller-supplied client so server-side dispatchers (e.g. the
// background insight-notifier / cron push paths) can hand in a service-role
// admin client. The cookie-backed request client throws "cookies was called
// outside a request scope" when invoked with no active request.
const supabase = client ?? (await createClient());
// .maybeSingle() returns null (no error) when the user row does not exist.
// The cron roster sweep can hand us stale or detached player_ids whose
// backing `users` row was deleted; .single() would throw PGRST116
Expand Down
18 changes: 13 additions & 5 deletions src/lib/notifications/push.ts
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';

Expand Down Expand Up @@ -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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Require authorization before bypassing recipient RLS

Because this module is marked 'use server', the exported sendPushNotification is a remotely invokable Server Action, but it accepts an arbitrary userId without authenticating or authorizing the caller. Switching this query to the service-role client removes the previous RLS-based recipient restriction, so a caller who knows another user's ID can trigger crafted pushes to all of that user's devices. Keep the privileged dispatcher behind a non-action server-only boundary, or verify the caller and their permission to notify the recipient before creating the admin client.

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)
Expand Down
118 changes: 118 additions & 0 deletions src/test/lib/notifications/push.test.ts
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 shouldSendPush routes coachhelm_insight to push_events instead of the dedicated push_coachhelm preference

The coachhelm_insight notification type is grouped with general events in shouldSendPush (src/lib/notifications/push.ts:21-23), so it checks prefs.push_events instead of the dedicated prefs.push_coachhelm. The push_coachhelm field exists in NotificationPreferences (src/lib/notifications/types.ts:41), has a default (types.ts:55), is validated in Zod (src/app/actions/notification-preferences.ts:23), and is exposed as its own toggle in the settings UI (settings/page.tsx:967: "CoachHelm Insights — Push when new insights or weekly digests land"). The email side correctly routes to its own email_coachhelm preference with an explicit comment about separating AI insights from team announcements (src/lib/notifications/email.ts:118-121). The consequence: toggling "CoachHelm Insights" push off in settings has zero effect — the user keeps receiving CoachHelm pushes as long as push_events is enabled. The new test at line 100 tests push_events: false for a coachhelm_insight notification, which passes only because of this routing bug and would need to be updated along with the fix.

Prompt for agents
The test at push.test.ts:99-107 tests coachhelm_insight opt-out using push_events: false, but the correct preference for CoachHelm push notifications is push_coachhelm (defined in types.ts:41, rendered in settings/page.tsx:967). Two things need to change together:

1. In src/lib/notifications/push.ts, function shouldSendPush: move the coachhelm_insight case out of the push_events group and give it its own case that returns prefs.push_coachhelm, mirroring how shouldSendEmail in email.ts:120-121 returns prefs.email_coachhelm.

2. In src/test/lib/notifications/push.test.ts:100, change the opt-out test to set push_coachhelm: false instead of push_events: false, so the test validates the intended user-facing behavior.
Open in Devin Review

Was 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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,9 @@ BEGIN
(id, coach_id, player_id, team_id, insight_type, title, content, priority, category)
VALUES
('00000000-0000-0000-0000-0000000000af', v_coach_a, v_player_a, v_team_a,
'value_derived', 'A insight', 'tenant A only', 'high', 'putting'),
'putting', 'A insight', 'tenant A only', 'high', 'putting'),
('00000000-0000-0000-0000-0000000000bf', v_coach_b, v_player_b, v_team_b,
'value_derived', 'B insight', 'tenant B only', 'high', 'putting')
'putting', 'B insight', 'tenant B only', 'high', 'putting')
ON CONFLICT DO NOTHING;
END $$;

Expand Down
Loading