From 2b2211b782fd1ab25c22afcaa04afe510adc6a03 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 30 May 2026 07:29:37 -0400 Subject: [PATCH 1/3] chore(plans): add welcome-notification in-progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third Notifier method (notifyWelcomeOnSignup) + new template + wire into github-oauth.ts create-fresh path. Fire-and-forget so OAuth redirect latency is unaffected. Builds on the EmailNotifier from #82 — lands in inboxes as soon as RESEND_API_KEY is sealed; logs via LoggingNotifier until then. Closes #43. Co-Authored-By: Claude Opus 4.7 (1M context) --- plans/welcome-notification.md | 107 ++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 plans/welcome-notification.md diff --git a/plans/welcome-notification.md b/plans/welcome-notification.md new file mode 100644 index 0000000..cc7f177 --- /dev/null +++ b/plans/welcome-notification.md @@ -0,0 +1,107 @@ +--- +status: in-progress +depends: [] +specs: + - specs/api/auth.md +issues: [43] +--- + +# Plan: Welcome notification on fresh OAuth signup + +## Scope + +[`apps/api/src/auth/github-oauth.ts`](../apps/api/src/auth/github-oauth.ts) handles the GitHub OAuth callback. The `create-fresh` outcome — a brand-new user with no laddr-account-claim to do — writes a new `Person` + `PrivateProfile`, mints a session, and redirects. No welcome notification today. + +This plan adds the third method to the `Notifier` interface (`notifyWelcomeOnSignup`) and fires it from the `create-fresh` path. With the `EmailNotifier` from [#82](https://github.com/CodeForPhilly/codeforphilly-ng/pull/98) already in place, the welcome email lands as soon as `RESEND_API_KEY` is sealed; until then, it logs via `LoggingNotifier`. + +Closes [#43](https://github.com/CodeForPhilly/codeforphilly-ng/issues/43). + +## Implements + +- [api/auth.md](../specs/api/auth.md) — the GitHub OAuth flow's create-fresh user shape gets a notification side-effect. + +## Approach + +### 1. Extend the `Notifier` interface + +In `apps/api/src/notify/index.ts`: + +```ts +export interface WelcomeNotification { + readonly email: string; // PrivateProfile.email + readonly fullName: string; // Person.fullName + readonly slug: string; // Person.slug — used in the profile link +} + +export interface Notifier { + notifyHelpWantedInterest(n: HelpWantedInterestNotification): Promise<{ delivered: boolean }>; + notifyHelpWantedFilled(n: HelpWantedFillNotification): Promise<{ delivered: boolean }>; + notifyWelcomeOnSignup(n: WelcomeNotification): Promise<{ delivered: boolean }>; +} +``` + +Add the no-op `LoggingNotifier.notifyWelcomeOnSignup` and the real `EmailNotifier.notifyWelcomeOnSignup` — same shape as the existing two methods. + +### 2. Welcome email template + +`apps/api/src/notify/templates.ts` gains `renderWelcomeEmail(n, siteHost)`. Short, warm, single-CTA body: + +- Subject: `Welcome to Code for Philly, ` +- Text body: 2-3 sentence intro pointing at the projects directory + Slack workspace +- HTML body: same content + styled link buttons +- Like the existing templates, HTML-escapes user-supplied fields (`fullName`) + +### 3. Wire into the OAuth callback + +In `apps/api/src/auth/github-oauth.ts`'s `create-fresh` branch, after `createFresh` resolves: + +```ts +// Fire-and-forget the welcome notification — never block the redirect on +// notifier latency or failures. The spec for express-interest applies here +// too: returning 202/302 to the caller regardless of notification outcome. +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)'); + }); +``` + +Fire-and-forget — Notifier.notifyXxx already returns `{ delivered }` and swallows errors internally, but the outer `.catch` covers any unforeseen sync-throw before the SDK is reached. The OAuth callback's redirect happens on the next line, unblocked. + +### 4. Tests + +`apps/api/tests/welcome-notification.test.ts`: + +- Template renderers — interest-style assertions for subject + body interpolation, HTML escape of `fullName` +- `EmailNotifier.notifyWelcomeOnSignup` — Resend success, Resend-error, SDK-throw, missing email (defensive — shouldn't happen on the create-fresh path since OAuth requires a primary email, but the interface accepts any string) +- `LoggingNotifier.notifyWelcomeOnSignup` — logs + returns `delivered: true` + +Wiring-level test: the existing `github-oauth.test.ts` covers the `create-fresh` outcome end-to-end. Extend it with one case that asserts `fastify.notifier.notifyWelcomeOnSignup` was called with the right payload (vi.spyOn on the notifier). Don't test that the email *delivered* — that's a notifier-unit concern. + +## Validation + +- [ ] Three Notifier impls (interface + LoggingNotifier + EmailNotifier) all have the new method. +- [ ] EmailNotifier sends with the right Resend payload (subject + text + html + to). +- [ ] Welcome template HTML-escapes `fullName`. +- [ ] OAuth `create-fresh` path fires the notifier (verified via spy in github-oauth.test.ts). +- [ ] Existing OAuth tests continue to pass (the notifier call is fire-and-forget; doesn't change response shape). +- [ ] `npm run type-check && npm run lint && npm test` clean. + +## Risks / unknowns + +- **Fire-and-forget vs await.** If we await, OAuth callback latency includes one Resend HTTPS round-trip — slow on a cold path, and a Resend outage would stall logins. Fire-and-forget trades email guarantees (a crash before the SDK enqueues drops the email silently) for response latency. Trade is right for v1; if delivery guarantees become important, a small outbox table is the canonical answer. +- **No retry on Resend failures.** Same trade-off — one shot, log the error, move on. Resend's own retry is good enough for transient blips. +- **No double-fire on duplicate signups.** OAuth `create-fresh` only fires when a Person was actually created (the `kind === 'create-fresh'` branch), so re-running the callback for an existing user doesn't re-send. Safe. +- **First-time-Login race with not-yet-sealed Resend key.** If the sandbox flips `RESEND_API_KEY` mid-signup, the in-flight request uses whatever notifier was installed at boot. Negligible — sealing the secret is a deploy event, the pod restart resets everything. + +## Notes + +*(filled at done time)* + +## Follow-ups + +*(filled at done time)* From 64c517a31a6add7289476721bc0adf020b7324b6 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 30 May 2026 07:47:56 -0400 Subject: [PATCH 2/3] feat(api): welcome email on fresh OAuth signup (closes #43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third method on the Notifier interface — notifyWelcomeOnSignup — wired into the github-oauth.ts create-fresh outcome. Fires fire-and-forget so the OAuth redirect latency is unaffected; the notifier's own error-handling translates Resend failures to delivered:false without throwing. Template body: warm 2-3 sentence intro pointing at the user's profile, projects directory, help-wanted board, and #welcome Slack channel. Plain-text + HTML alternatives, fullName HTML-escaped, slug URL-encoded. EmailNotifier.notifyWelcomeOnSignup mirrors the existing two methods — same Resend response-shape branching, same delivered:false on missing email / SDK throw / Resend-reported error. Wiring is hidden behind the existing LoggingNotifier → EmailNotifier fallback in the services plugin. When RESEND_API_KEY is unset (sandbox today), welcomes log to pod stdout; once sealed, real emails go out. Tests: 7 new in email-notifier.test.ts (template renderer + 4 notifier paths), 1 new in github-oauth.test.ts (spy assertion that the create-fresh path fires the notifier with the right payload). 310/310 API tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/src/auth/github-oauth.ts | 15 +++++ apps/api/src/notify/email-notifier.ts | 41 ++++++++++++- apps/api/src/notify/index.ts | 18 ++++++ apps/api/src/notify/templates.ts | 59 ++++++++++++++++++- apps/api/tests/email-notifier.test.ts | 83 ++++++++++++++++++++++++++- apps/api/tests/github-oauth.test.ts | 41 ++++++++++++- 6 files changed, 253 insertions(+), 4 deletions(-) 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 }): EmailNotifier { return new EmailNotifier({ // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -155,6 +166,76 @@ describe('EmailNotifier.notifyHelpWantedInterest', () => { }); }); +describe('renderWelcomeEmail', () => { + it('builds subject + text + html with the user fullName + slug', () => { + const tpl = renderWelcomeEmail(baseWelcome, 'codeforphilly.org'); + expect(tpl.subject).toContain('New User'); + expect(tpl.text).toContain('Hey New User'); + expect(tpl.text).toContain('https://codeforphilly.org/members/new-user'); + expect(tpl.text).toContain('https://codeforphilly.org/projects'); + expect(tpl.html).toContain('New User'); + expect(tpl.html).toContain('href="https://codeforphilly.org/members/new-user"'); + }); + + it('escapes HTML in fullName', () => { + const tpl = renderWelcomeEmail( + { ...baseWelcome, fullName: '' }, + 'codeforphilly.org', + ); + expect(tpl.html).not.toContain('