Skip to content
Merged
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
15 changes: 15 additions & 0 deletions src/app/actions/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,21 @@ export async function bootstrapProfile(): Promise<Result<BootstrapOutput>> {
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) {
Expand Down
57 changes: 57 additions & 0 deletions src/inngest/functions/help-dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -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 },
}));
Expand Down Expand Up @@ -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',
});
});
});
18 changes: 14 additions & 4 deletions src/inngest/functions/help-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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')
Expand All @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions src/lib/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
18 changes: 18 additions & 0 deletions supabase/migrations/0023_profile_emails.sql
Original file line number Diff line number Diff line change
@@ -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);

Loading