diff --git a/apps/api/src/auth/github-oauth.ts b/apps/api/src/auth/github-oauth.ts index ff1bc3b..84eef68 100644 --- a/apps/api/src/auth/github-oauth.ts +++ b/apps/api/src/auth/github-oauth.ts @@ -169,6 +169,21 @@ export async function completeCallback( ); result.value.stateApply.apply(fastify.inMemoryState, fastify.fts); + // Fire-and-forget the welcome notification — never block the OAuth + // redirect on notifier latency or failures. The notifier already + // swallows errors internally and returns `{ delivered: false }`; the + // outer .catch handles any unforeseen sync-throw before the SDK is + // reached. See plans/welcome-notification.md. + void fastify.notifier + .notifyWelcomeOnSignup({ + email: result.value.profile.email, + fullName: result.value.person.fullName, + slug: result.value.person.slug, + }) + .catch((err) => { + fastify.log.error({ err }, 'welcome notification threw (fire-and-forget)'); + }); + const minted = await mintSessionFor( result.value.person.id, result.value.person.accountLevel, diff --git a/apps/api/src/notify/email-notifier.ts b/apps/api/src/notify/email-notifier.ts index f28a3bb..1bcd5bc 100644 --- a/apps/api/src/notify/email-notifier.ts +++ b/apps/api/src/notify/email-notifier.ts @@ -18,8 +18,9 @@ import type { HelpWantedFillNotification, HelpWantedInterestNotification, Notifier, + WelcomeNotification, } from './index.js'; -import { renderFilledEmail, renderInterestEmail } from './templates.js'; +import { renderFilledEmail, renderInterestEmail, renderWelcomeEmail } from './templates.js'; export interface EmailNotifierOptions { /** Resend client (constructed at boot with the API key from env). */ @@ -100,6 +101,44 @@ export class EmailNotifier implements Notifier { } } + async notifyWelcomeOnSignup(n: WelcomeNotification): Promise<{ delivered: boolean }> { + if (!n.email) { + this.#log.warn( + { kind: 'auth.welcome', slug: n.slug }, + 'welcome: no email address; skipped', + ); + return { delivered: false }; + } + const tpl = renderWelcomeEmail(n, this.#siteHost); + try { + const result = await this.#resend.emails.send({ + from: this.#from, + to: n.email, + subject: tpl.subject, + text: tpl.text, + html: tpl.html, + }); + if (result.error) { + this.#log.error( + { kind: 'auth.welcome', err: result.error, slug: n.slug }, + 'welcome: Resend reported delivery failure', + ); + return { delivered: false }; + } + this.#log.info( + { kind: 'auth.welcome', slug: n.slug, resendId: result.data?.id }, + 'welcome: email queued for delivery', + ); + return { delivered: true }; + } catch (err) { + this.#log.error( + { kind: 'auth.welcome', err, slug: n.slug }, + 'welcome: email send threw', + ); + return { delivered: false }; + } + } + async notifyHelpWantedFilled( n: HelpWantedFillNotification, ): Promise<{ delivered: boolean }> { diff --git a/apps/api/src/notify/index.ts b/apps/api/src/notify/index.ts index c750726..91a9eb2 100644 --- a/apps/api/src/notify/index.ts +++ b/apps/api/src/notify/index.ts @@ -30,9 +30,22 @@ export interface HelpWantedFillNotification { readonly filledBySlug: string | null; } +/** + * Welcome notification — fires once per Person on the GitHub OAuth + * `create-fresh` outcome (a brand-new signup with no laddr-account-claim + * candidates). The email comes from the new PrivateProfile; fullName and + * slug from the public Person record. + */ +export interface WelcomeNotification { + readonly email: string; + readonly fullName: string; + readonly slug: string; +} + export interface Notifier { notifyHelpWantedInterest(n: HelpWantedInterestNotification): Promise<{ delivered: boolean }>; notifyHelpWantedFilled(n: HelpWantedFillNotification): Promise<{ delivered: boolean }>; + notifyWelcomeOnSignup(n: WelcomeNotification): Promise<{ delivered: boolean }>; } /** @@ -55,4 +68,9 @@ export class LoggingNotifier implements Notifier { this.#log.info({ kind: 'help-wanted.filled', ...n }, 'help-wanted fill notification'); return { delivered: true }; } + + async notifyWelcomeOnSignup(n: WelcomeNotification): Promise<{ delivered: boolean }> { + this.#log.info({ kind: 'auth.welcome', ...n }, 'welcome notification'); + return { delivered: true }; + } } diff --git a/apps/api/src/notify/templates.ts b/apps/api/src/notify/templates.ts index 974abcb..3adfc0a 100644 --- a/apps/api/src/notify/templates.ts +++ b/apps/api/src/notify/templates.ts @@ -6,7 +6,11 @@ * overhead than the strings themselves. URLs are absolute (resolved * against the configured siteHost) so links work from email clients. */ -import type { HelpWantedFillNotification, HelpWantedInterestNotification } from './index.js'; +import type { + HelpWantedFillNotification, + HelpWantedInterestNotification, + WelcomeNotification, +} from './index.js'; /** Strip an absolute URL down to scheme + host + path. Useful for log lines. */ export function buildRoleUrl(siteHost: string, projectSlug: string, roleId: string): string { @@ -85,6 +89,59 @@ export interface FilledTemplate { readonly html: string; } +export interface WelcomeTemplate { + readonly subject: string; + readonly text: string; + readonly html: string; +} + +export function renderWelcomeEmail( + n: WelcomeNotification, + siteHost: string, +): WelcomeTemplate { + const profileUrl = `https://${siteHost}/members/${encodeURIComponent(n.slug)}`; + const projectsUrl = `https://${siteHost}/projects`; + const helpWantedUrl = `https://${siteHost}/help-wanted`; + const subject = `Welcome to Code for Philly, ${n.fullName}`; + + const text = [ + `Hey ${n.fullName} — welcome to Code for Philly!`, + '', + `You're signed in. A few places to start:`, + ` - Your profile: ${profileUrl}`, + ` - Browse projects: ${projectsUrl}`, + ` - Help wanted: ${helpWantedUrl}`, + '', + `Code for Philly's community lives in Slack — most coordination happens there.`, + `Drop into #welcome to introduce yourself.`, + '', + `— Code for Philly`, + ].join('\n'); + + const html = ` + +
+Hey ${escapeHtml(n.fullName)} — welcome to Code for Philly!
+You're signed in. A few places to start:
++ Code for Philly's community lives in Slack — most coordination happens there. + Drop into #welcome to introduce yourself. +
++ You're receiving this because you just signed in to Code for Philly with GitHub. +
+ +`; + + return { subject, text, html }; +} + export function renderFilledEmail( n: HelpWantedFillNotification, siteHost: string, diff --git a/apps/api/tests/email-notifier.test.ts b/apps/api/tests/email-notifier.test.ts index 32510a3..69629a1 100644 --- a/apps/api/tests/email-notifier.test.ts +++ b/apps/api/tests/email-notifier.test.ts @@ -11,10 +11,15 @@ */ import { describe, expect, it, vi } from 'vitest'; import { EmailNotifier } from '../src/notify/email-notifier.js'; -import { renderFilledEmail, renderInterestEmail } from '../src/notify/templates.js'; +import { + renderFilledEmail, + renderInterestEmail, + renderWelcomeEmail, +} from '../src/notify/templates.js'; import type { HelpWantedFillNotification, HelpWantedInterestNotification, + WelcomeNotification, } from '../src/notify/index.js'; const noopLogger = { @@ -51,6 +56,12 @@ const baseFill: HelpWantedFillNotification = { filledBySlug: 'jane-doe', }; +const baseWelcome: WelcomeNotification = { + email: 'new-user@example.com', + fullName: 'New User', + slug: 'new-user', +}; + function makeNotifier(emails: { send: ReturnType