Skip to content

feat(notifications): instant mutual-match email + daily digest (Resend + React Email) - #2

Merged
timothygithinji merged 2 commits into
mainfrom
feat/notifications
May 27, 2026
Merged

feat(notifications): instant mutual-match email + daily digest (Resend + React Email)#2
timothygithinji merged 2 commits into
mainfrom
feat/notifications

Conversation

@timothygithinji

@timothygithinji timothygithinji commented May 27, 2026

Copy link
Copy Markdown
Owner

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).

  • Instant mutual-match email — when a swipe completes a mutual match, recordSwipe claims the notification atomically in a new match_notifications table (unique household+cluster → concurrent swipes can't double-send) and fires send-match-email. Each member gets a "you both want this" email naming the other member(s), linking into the listing.
  • Daily digest (daily-digest, declarative schedule 08:00 Europe/London) — emails each household the in-band listings that appeared since households.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_KEY is available. RESEND_API_KEY is optional in env.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 build clean, 154 tests pass, typecheck + biome clean.

Before/with deploy

  • Fix BETTER_AUTH_URL — it's currently http://localhost:3000 in prod Doppler; it should be https://gaff.timothygithinji.com. Invite and email links depend on it. (Human-set in Doppler.)
  • Deploy applies migrations 0015 (match_notifications) + 0016 (households.last_digest_at) and registers the daily-digest schedule. RESEND_API_KEY is already in Doppler (dev + prd).
  • Sending only actually works once deployed (tasks run on Trigger with the synced key + a verified updates.timothygithinji.com sender).

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Introduced scheduled daily digest emails that deliver new property listings to households at 08:00 UK time, with intelligent grouping and filtering based on household preferences
    • Added instant email notifications to household members when a mutual match is detected on a property
  • Chores

    • Extended database schema to track notification history and digest delivery watermarks
    • Integrated email service infrastructure for reliable notification delivery

Review Change Stack

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.
@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown
ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 8c6ab4e7-78e4-453b-b405-7b4b40276922

📥 Commits

Reviewing files that changed from the base of the PR and between eac4e39 and c0ac172.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (16)
  • db/schema.ts
  • drizzle/0015_robust_skrulls.sql
  • drizzle/0016_lean_blink.sql
  • drizzle/meta/0015_snapshot.json
  • drizzle/meta/0016_snapshot.json
  • drizzle/meta/_journal.json
  • package.json
  • src/lib/email/client.ts
  • src/lib/email/config.ts
  • src/lib/email/digest-email.tsx
  • src/lib/email/match-email.tsx
  • src/lib/env.ts
  • src/server/functions/review.ts
  • src/trigger/daily-digest.tsx
  • src/trigger/send-match-email.tsx
  • trigger.config.ts

Walkthrough

This 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.

Changes

Household notifications via email

Layer / File(s) Summary
Database schema: notifications and digest watermark
db/schema.ts, drizzle/0015_robust_skrulls.sql, drizzle/0016_lean_blink.sql, drizzle/meta/*
households table gains nullable lastDigestAt timestamp watermark column. New matchNotifications table tracks sent notifications per (householdId, clusterId) pair with unique constraint and cascading foreign keys.
Email infrastructure and configuration
package.json, src/lib/email/client.ts, src/lib/email/config.ts, src/lib/env.ts
Adds resend and @react-email/components dependencies. Exports getResend() factory reading RESEND_API_KEY, centralised FROM_EMAIL and appUrl() helper, and optional environment schema entry.
Email templates for digest and match notifications
src/lib/email/digest-email.tsx, src/lib/email/match-email.tsx
React Email components DigestEmail and MatchEmail render styled HTML with listing cards, optional images, pricing, and call-to-action buttons; export types DigestItem/DigestEmailProps and MatchEmailProps.
Daily digest scheduled task
src/trigger/daily-digest.tsx
Scheduled at 08:00 Europe/London. Loads per-household lastDigestAt watermark, queries new listings from active searches, selects headline per cluster (cheapest in price band), filters swiped-skip clusters, caps to MAX_ITEMS, enriches with first photo, advances watermark unconditionally, sends pluralised DigestEmail to all members via Resend.
Mutual match notification task
src/trigger/send-match-email.tsx
Event-driven task for (householdId, clusterId) payloads. Fetches members/cluster/cheapest listing, validates all exist, enriches with photo and formatted details (address, price string, beds, outcode), sends personalised MatchEmail to each member via Resend, returns sent count.
Swipe recording integration and trigger config
src/server/functions/review.ts, trigger.config.ts
Adds notifyMutualMatchIfNew() helper that queries vMutualMatches, atomically claims a matchNotifications row (conflict-free), and dispatches send-match-email task. Integrates into recordSwipe() for non-skip outcomes in a best-effort try/catch. Adds RESEND_API_KEY to optional Doppler secrets.

🎯 3 (Moderate) | ⏱️ ~25 minutes


Note

🎁 Summarized by CodeRabbit Free

Your 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 @coderabbitai help to get the list of available commands and usage tips.

@timothygithinji
timothygithinji merged commit cf87c51 into main May 27, 2026
4 checks passed
@timothygithinji
timothygithinji deleted the feat/notifications branch May 27, 2026 20:22
timothygithinji added a commit that referenced this pull request Jun 6, 2026
…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).
timothygithinji added a commit that referenced this pull request Jun 21, 2026
…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).
timothygithinji added a commit that referenced this pull request Jun 21, 2026
…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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant