| status | done | |
|---|---|---|
| depends | ||
| specs |
|
|
| issues |
|
|
| pr | 99 |
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 already in place, the welcome email lands as soon as RESEND_API_KEY is sealed; until then, it logs via LoggingNotifier.
Closes #43.
- api/auth.md — the GitHub OAuth flow's create-fresh user shape gets a notification side-effect.
In apps/api/src/notify/index.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.
apps/api/src/notify/templates.ts gains renderWelcomeEmail(n, siteHost). Short, warm, single-CTA body:
- Subject:
Welcome to Code for Philly, <fullName> - 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)
In apps/api/src/auth/github-oauth.ts's create-fresh branch, after createFresh resolves:
// 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.
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 + returnsdelivered: 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.
- Three Notifier impls (interface + LoggingNotifier + EmailNotifier) all have
notifyWelcomeOnSignup. - EmailNotifier sends with the right Resend payload (subject + text + html + to).
- Welcome template HTML-escapes
fullName(test asserts<script>becomes<script>). - OAuth
create-freshpath fires the notifier via spy assertion in github-oauth.test.ts — payload matches{ email, fullName, slug }. - Existing OAuth tests pass (15 pre-existing + 1 new = 16 in that file).
- 310/310 API tests pass;
npm run type-check && npm run lintclean.
- 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-freshonly fires when a Person was actually created (thekind === '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_KEYmid-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.
One-shot implementation across two commits (plan + impl/tests).
Surprises:
encodeURIComponentfor the profile-link slug. Slugs are[a-z0-9-]per the spec so they never need encoding today, but the template usesencodeURIComponentdefensively. Test asserts that a hypothetical slug with spaces producesname%20with%20spacesin the href, which gives the template the same robustness as the existing interest/filled renderers.- Spy timing in the OAuth test. Fire-and-forget worried me — would the spy assertion race the route handler's return? But
void notifier.notifyWelcomeOnSignup(...)synchronously invokes the function (queuing the promise) before the next statement runs. By the time the route handler resolves and the test awaits the response, the spy has been called at least once. The Resend HTTPS send happens later in the promise chain; we don't await it. - No new env needed. The notifier already had a
siteHostfromCFP_SITE_HOST(added in #82's email-notifier wiring); the welcome template reuses it for the profile/projects/help-wanted links. Zero new config.
- Slack #welcome bump. Once Slack DM lands (#95), the welcome path could DM the new user with a #welcome channel invite link. Out of scope here; tracked alongside the Slack notifier work.
- Email verification reminder. If the GitHub primary email later fails delivery (Resend bounces it), today the user has no recovery path beyond editing their profile. Tied to the bounce-webhook follow-up from #82.
- A/B-able copy. The body is currently hardcoded. If marketing ever wants to experiment, lift into a small key/value content table in a future plan. Deferred — premature for v1.