diff --git a/src/app/actions/profile.ts b/src/app/actions/profile.ts index 980fc575..7a1fcba6 100644 --- a/src/app/actions/profile.ts +++ b/src/app/actions/profile.ts @@ -68,6 +68,21 @@ export async function bootstrapProfile(): Promise> { return err('persist_failed', upsertErr?.message ?? 'profile upsert returned nothing'); } + // Upsert the user's email into the private profile_emails table + if (user.email) { + const { error: emailErr } = await service.from('profile_emails').upsert( + { + user_id: user.id, + email: user.email, + }, + { onConflict: 'user_id' }, + ); + + if (emailErr) { + console.error('Failed to upsert profile email', emailErr); + } + } + let auditQueued = false; if (!profile.audit_completed) { diff --git a/src/inngest/functions/help-dispatch.test.ts b/src/inngest/functions/help-dispatch.test.ts index 4a30a630..f1a87a4f 100644 --- a/src/inngest/functions/help-dispatch.test.ts +++ b/src/inngest/functions/help-dispatch.test.ts @@ -1,10 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { rankReviewers } from '@/lib/help/dispatch'; +import { sendHelpDispatchEmail } from '@/lib/email'; import { helpDispatch } from './help-dispatch'; import { sb, wire, step } from './__tests__/test-helpers'; vi.mock('@/lib/supabase/service', () => ({ getServiceSupabase: vi.fn() })); vi.mock('@/lib/help/dispatch', () => ({ rankReviewers: vi.fn() })); +vi.mock('@/lib/email', () => ({ sendHelpDispatchEmail: vi.fn() })); vi.mock('../client', () => ({ inngest: { createFunction: (_c: unknown, _t: unknown, h: Function) => h }, })); @@ -93,4 +95,59 @@ describe('helpDispatch', () => { const result = await run({ event: ev(), step }); expect(result).toEqual({ helpRequestId: 123, notified: 0 }); }); + + it('resolves mentor email from profile_emails and sends email', async () => { + wire({ + profiles: sb({ + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + gte: vi.fn().mockReturnThis(), + neq: vi.fn().mockResolvedValue({ + data: [{ id: 'm1', level: 2, primary_language: 'TypeScript', github_handle: 'mentor1' }], + }), + maybeSingle: vi.fn().mockResolvedValue({ + data: { level: 1, primary_language: 'TypeScript', github_handle: 'mentee' }, + }), + }), + cohort_members: sb({ + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ data: null }), + }), + profile_emails: sb({ + select: vi.fn().mockReturnThis(), + in: vi.fn().mockResolvedValue({ + data: [{ user_id: 'm1', email: 'test_mentor@example.com' }], + }), + }), + help_requests: sb({ + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ + data: { reason: 'stuck on tests', pr_url: 'https://github.com/foo/bar/pull/1' }, + }), + }), + }); + + vi.mocked(rankReviewers).mockReturnValue([ + { + userId: 'm1', + level: 2, + sameOrgReviewed: false, + sameCohort: false, + languageMatch: true, + }, + ]); + + await run({ event: ev(), step }); + + expect(sendHelpDispatchEmail).toHaveBeenCalledWith({ + to: 'test_mentor@example.com', + mentorHandle: 'mentor1', + menteeHandle: 'mentee', + prUrl: 'https://github.com/foo/bar/pull/1', + helpReason: 'stuck on tests', + }); + }); }); diff --git a/src/inngest/functions/help-dispatch.ts b/src/inngest/functions/help-dispatch.ts index fa4d91c3..edcd04a3 100644 --- a/src/inngest/functions/help-dispatch.ts +++ b/src/inngest/functions/help-dispatch.ts @@ -43,7 +43,7 @@ export const helpDispatch = inngest.createFunction( // Pool: all L2+ profiles. In production we'd narrow by recent activity etc. const { data: pool } = await sb .from('profiles') - .select('id, level, primary_language, github_handle, email') + .select('id, level, primary_language, github_handle') .gte('level', 2) .neq('id', userId); @@ -69,6 +69,15 @@ export const helpDispatch = inngest.createFunction( if (targets.length === 0) return { notified: 0 }; + // Fetch emails for the selected targets + const targetUserIds = targets.map((t) => t.userId); + const { data: emailsData } = await sb + .from('profile_emails') + .select('user_id, email') + .in('user_id', targetUserIds); + + const emailMap = new Map(emailsData?.map((row) => [row.user_id, row.email])); + const { data: helpRequest } = await sb .from('help_requests') .select('reason, pr_url') @@ -85,13 +94,14 @@ export const helpDispatch = inngest.createFunction( for (const target of targets) { const mentor = pool?.find((p) => p.id === target.userId); + const mentorEmail = emailMap.get(target.userId); - if (!mentor?.email) continue; + if (!mentorEmail) continue; try { await sendHelpDispatchEmail({ - to: mentor.email, - mentorHandle: mentor.github_handle ?? 'mentor', + to: mentorEmail, + mentorHandle: mentor?.github_handle ?? 'mentor', menteeHandle: mentee?.github_handle ?? 'contributor', prUrl: helpRequest?.pr_url ?? '', helpReason: helpRequest?.reason ?? null, diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 5f499d87..d4351dea 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -61,6 +61,15 @@ export const profiles = pgTable( }), ); +export const profileEmails = pgTable('profile_emails', { + userId: uuid('user_id') + .primaryKey() + .references(() => profiles.id, { onDelete: 'cascade' }), + email: text('email').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); + // ---------- GitHub App installations (the gate) ---------- export const githubInstallations = pgTable( diff --git a/supabase/migrations/0023_profile_emails.sql b/supabase/migrations/0023_profile_emails.sql new file mode 100644 index 00000000..b9ec633e --- /dev/null +++ b/supabase/migrations/0023_profile_emails.sql @@ -0,0 +1,18 @@ +-- Migration 0023: Private Profile Emails + +-- Create the private profile_emails table +CREATE TABLE IF NOT EXISTS profile_emails ( + user_id uuid PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE, + email text NOT NULL, + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now() +); + +-- Enable RLS +ALTER TABLE profile_emails ENABLE ROW LEVEL SECURITY; + +-- Add RLS policy for users to read their own email +DROP POLICY IF EXISTS profile_emails_read_own ON profile_emails; +CREATE POLICY profile_emails_read_own ON profile_emails FOR SELECT + USING (auth.uid() = user_id); +