feat(notifications): instant mutual-match email + daily digest (Resend + React Email) - #2
Conversation
When a swipe completes a mutual match (every household member has kept/shortlisted a cluster), recordSwipe claims the notification atomically in a new match_notifications table (unique household+cluster, so concurrent swipes can't double-send) and fires send-match-email. The task runs on Trigger.dev (Node) where React Email renders and the synced RESEND_API_KEY is available; it emails each member a 'you both want this' email naming the other member(s), linking into the listing. RESEND_API_KEY is optional in env + synced as optional (R2 pattern) so it never re-triggers the secret-drift 500. Email images use the public portal CDN URL (app R2 photos sit behind Access). Notification failures are swallowed — the swipe is already recorded.
A declarative scheduled task emails each household a rich digest of the in-band listings that appeared since the last digest. households.last_digest_at is the watermark (null treated as the last 24h so the first send isn't a backlog dump; advanced every run so the window never widens). Preserves blind review (lists places to review, not peers' verdicts) and excludes household-vetoed clusters. Sends via Resend + React Email, links into the review screen.
ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Free Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (16)
WalkthroughThis pull request introduces email notification infrastructure for two key household features: a scheduled daily digest of new listings matching saved searches, and instant notifications when a mutual match is discovered. It adds database schema changes to track notification state, Resend email client integration with React Email templates, two Trigger.dev scheduled/event-driven tasks, and integrates mutual-match detection into the existing swipe flow. ChangesHousehold notifications via email
🎯 3 (Moderate) | ⏱️ ~25 minutes Note 🎁 Summarized by CodeRabbit FreeYour organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login. Comment |
…islabelled stops Two compounding bugs let listings far from any station into the queue: 1. classifyKind mapped Google's generic 'transit_station' type to 'rail'. In residential London that type is mostly bus-stop clusters, so bus stops were stamped kind='rail' at ~0.03mi with no walkMinutes — and the transport filter's straight-line heuristic then read 'rail ~0.6 min away', passing the 15-min rule. Treat a bare transit_station as unclassified (dropped) instead. 2. clusterPassesSearch judged tube/rail targets on that nearbyTransit data. Switch station kinds to the Google Routes walk times in stationRoutes — the same source the review card shows (e.g. Bush Hill Park 19 min) — so a cluster whose nearest real station is over the limit is now dropped. Bus/tram keep nearbyTransit; targets with no data stay pending. Fix #1 needs a nearby-transit re-enrichment to clean existing rows; fix #2 takes effect immediately for Rightmove-sourced clusters (stationRoutes).
…islabelled stops Two compounding bugs let listings far from any station into the queue: 1. classifyKind mapped Google's generic 'transit_station' type to 'rail'. In residential London that type is mostly bus-stop clusters, so bus stops were stamped kind='rail' at ~0.03mi with no walkMinutes — and the transport filter's straight-line heuristic then read 'rail ~0.6 min away', passing the 15-min rule. Treat a bare transit_station as unclassified (dropped) instead. 2. clusterPassesSearch judged tube/rail targets on that nearbyTransit data. Switch station kinds to the Google Routes walk times in stationRoutes — the same source the review card shows (e.g. Bush Hill Park 19 min) — so a cluster whose nearest real station is over the limit is now dropped. Bus/tram keep nearbyTransit; targets with no data stay pending. Fix #1 needs a nearby-transit re-enrichment to clean existing rows; fix #2 takes effect immediately for Rightmove-sourced clusters (stationRoutes).
…islabelled stops Two compounding bugs let listings far from any station into the queue: 1. classifyKind mapped Google's generic 'transit_station' type to 'rail'. In residential London that type is mostly bus-stop clusters, so bus stops were stamped kind='rail' at ~0.03mi with no walkMinutes — and the transport filter's straight-line heuristic then read 'rail ~0.6 min away', passing the 15-min rule. Treat a bare transit_station as unclassified (dropped) instead. 2. clusterPassesSearch judged tube/rail targets on that nearbyTransit data. Switch station kinds to the Google Routes walk times in stationRoutes — the same source the review card shows (e.g. Bush Hill Park 19 min) — so a cluster whose nearest real station is over the limit is now dropped. Bus/tram keep nearbyTransit; targets with no data stay pending. Fix #1 needs a nearby-transit re-enrichment to clean existing rows; fix #2 takes effect immediately for Rightmove-sourced clusters (stationRoutes).
What
The notifications half of the roadmap — email built with React Email, sent via Resend, designed in the grill session (calm daily digest + one instant exception).
recordSwipeclaims the notification atomically in a newmatch_notificationstable (unique household+cluster → concurrent swipes can't double-send) and firessend-match-email. Each member gets a "you both want this" email naming the other member(s), linking into the listing.daily-digest, declarative schedule 08:00 Europe/London) — emails each household the in-band listings that appeared sincehouseholds.last_digest_at(null = last 24h so the first send isn't a backlog dump; advanced every run). Rich because overnight enrichment is done. Preserves blind review and excludes household-vetoed clusters.Both email tasks run on Trigger.dev (Node) where React Email renders and the synced
RESEND_API_KEYis available.RESEND_API_KEYis optional inenv.ts+ synced as optional (R2 pattern) so it never re-triggers the secret-drift 500; the email client enforces it at send time. Email images use the public portal CDN URL (app R2 photos sit behind Access).Verified
Both React Email templates render to valid HTML locally (match 4.5KB, digest 6.2KB — CTAs/headlines present).
bun run buildclean, 154 tests pass, typecheck + biome clean.Before/with deploy
BETTER_AUTH_URL— it's currentlyhttp://localhost:3000in prod Doppler; it should behttps://gaff.timothygithinji.com. Invite and email links depend on it. (Human-set in Doppler.)match_notifications) + 0016 (households.last_digest_at) and registers thedaily-digestschedule.RESEND_API_KEYis already in Doppler (dev + prd).updates.timothygithinji.comsender).🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes
New Features
Chores