PRFC Outreach is a Next.js application for the Paso Robles Food Co-op. It handles member referrals, contact groups, group and blast messaging, calendar events, and member notifications.
| Layer | Technology | Purpose |
|---|---|---|
| Framework | Next.js 16 | App Router, server rendering |
| UI | React 19 | Components |
| Language | TypeScript | Type safety |
| Database | MySQL + Prisma 7 | Data storage and ORM |
| Styling | Tailwind CSS 3 | Utility classes |
| Components | shadcn/ui | UI primitives over Radix |
| Validation | Zod | Runtime type checking |
| Brevo | Transactional email over REST | |
| SMS | Twilio | A2P 10DLC SMS (disabled at launch) |
| Rate limiting | Upstash Redis | Throttling, daily email quota, idempotency |
| File storage | Vercel Blob | Profile photo uploads |
| Tables/lists | @tanstack/react-table + react-virtual | Large referral and member tables |
| Search | fuse.js | Client-side fuzzy search |
| Calendar | Schedule-X | Events week and month views |
The code is organized in layers, each with one responsibility:
- Pages (
src/app/) - server components fetch data and pass it to client components. - Actions (
src/actions/) -"use server"functions. They validate with Zod, call services, and returnActionResult<T>. Every action callsverifySession()first. - Services (
src/services/) -import "server-only". Database access through Prisma. Never imported by client code. - Components (
src/components/) - UI."use client"only when needed. - Types (
src/types/) - shared interfaces that cross layers. Isomorphic, no runtime code. - Schema (
src/schema/) - Zod schemas and their inferred types. - Lib (
src/lib/) - server integrations: db, dal, encryption, csrf, rate-limit, idempotency, API clients. - Utils (
src/utils/) - pure helpers and constants. Isomorphic. - Hooks (
src/hooks/) - generic shared hooks.
Group, message, event, and settings mutations use Server Actions, co-located with their features. A form calls an action, which validates with Zod, calls a service, and returns { success, error?, data? }. See the Server Actions ADR.
API routes cover external integrations and reads, not the in-app group, message, and event mutations (those are server actions). Current routes:
| Endpoint | Method | Purpose |
|---|---|---|
/api/referrals |
POST / GET | Create a referral (public form) / list (admin) |
/api/referrals/[id] |
PATCH / DELETE | Update or delete a referral (admin) |
/api/referrals/export |
GET | Server-side PDF export (admin) |
/api/members, /api/members/[id] |
GET | Member lookups |
/api/auth/callback |
POST | Accept a signed portal token, set the auth cookie |
/api/auth/logout |
POST | Clear the auth cookie |
/api/cron/process-email-queue |
GET | Drain the email queue (Vercel cron, Bearer secret) |
/api/sms/inbound |
POST | Twilio inbound webhook (STOP handling) |
/api/unsubscribe |
GET / POST | Email unsubscribe |
Login starts at the PRFC member portal. On click-through the portal hands a signed token to POST /api/auth/callback. The callback does not trust that token directly. It posts the token back to the portal's validatetoken endpoint, which returns the ownerid, the admin flag, and the seconds left. Validation is delegated to the portal, so this code never has to match the portal's exact token format, timezone, or signing.
On a valid response the callback mints the app's own session cookie, prfc_auth:
ownerid|isAdmin|timestamp|signature
ownerid- member id from the portalisAdmin-1for admin,0otherwisetimestamp- mint time in epoch milliseconds (60-minute expiry)signature- the first 8 hex characters of an HMAC-SHA256 overownerid|isAdmin|timestamp, keyed with the sharedPRFC_PORTAL_SECRET
Every later request validates this cookie in the DAL, so most requests need no portal round-trip. The callback also stores the raw portal token in a second cookie, prfc_portal_token, which the member-roster read (listmembers) sends back to the portal. The public referral URL carries the same 8-hex signature scheme in a cs parameter, over name|email|code with the same secret, verified before any referral is accepted.
The callback answers with a 303, not the framework-default 307, so the SameSite=Lax cookie survives the cross-site hop to /home. The Auth Patterns ADR explains why.
Next.js 16 uses src/proxy.ts, not middleware.ts. The proxy checks for the auth cookie on protected paths and redirects when it is missing, which is a fast UX guard. The real security boundary is verifySession() in src/lib/dal.ts, which verifies the HMAC and expiry, and requireAdmin() gates admin-only features. See the Auth Patterns ADR.
Member identity comes from the co-op's member portal over HTTP, not from our database. Contact reads (getmembercontacts) authenticate with the MEMBER_API_SECRET; the roster read (listmembers) sends the member's portal token. Both go through src/lib/api/portal-api.ts.
The portal is a single dependency, so the integration is defensive. Each call carries an 8-second AbortSignal.timeout, so a hung portal fails fast instead of holding a serverless function open. The portal answers 200 even on failure (an INVALID_KEY sentinel for a bad secret, a PHP notice for a missing token), so the client treats those shapes as errors and logs a truncated raw snippet rather than failing opaquely.
We memoize reads per request with React cache(), so one request makes at most one portal call per distinct member read. We do not cache member data across requests. It is PII (name, email, phone), a Redis copy would be a new exposure surface for little gain at this roster size, and a stale entry could show wrong contact info.
The Prisma schema defines the app's tables. Referral PII and SMS/email PII are encrypted at the service layer with AES-256-GCM, alongside HMAC blind-index columns for lookup without decryption.
The four remaining tables - Referral, SmsConsent, EmailSuppression, and UserPreference - have no foreign keys. They key off the portal memberId rather than a local table, so they sit in their own diagram below.
| Model | Purpose |
|---|---|
Referral |
Public referral submissions (encrypted PII) |
ContactGroup, ContactGroupMember |
Groups and membership with per-group notification preferences |
Message, MessageGroup, MessageRecipient |
Message history and per-recipient delivery status |
Event, EventInvitee, EventRsvp |
Calendar events, invitations, and RSVPs |
SmsConsent |
TCPA consent records (encrypted phone, blind index) |
EmailSuppression |
Bounces, complaints, and unsubscribes (global suppression) |
UserPreference |
Per-member notification defaults, photo, notification watermark |
Member identity (name, email, phone) lives in the co-op's MySQL tblowner table and is read through the member-portal API, not stored here. In local development it is backed by a 389-row mock in src/lib/mock-members.ts.
Profile photos upload to Vercel Blob through src/services/user-preference.ts, and UserPreference.photoUrl stores the returned URL. Production needs a BLOB_READ_WRITE_TOKEN, which Vercel injects into the project when a Blob store is created in its Storage tab.
Compliance:
- SMS (TCPA + A2P 10DLC): explicit consent (timestamp, method, and text), quiet hours from 8 AM to 9 PM, and consent retention.
- Email (CAN-SPAM): one-click unsubscribe, a physical mailing address, and honored opt-outs.
- Field-level AES-256-GCM encryption for referral, SMS-consent, and email-suppression PII, with HMAC blind indexes.
- Nonce-based CSP and security headers set in
src/proxy.ts. - Rate limiting, idempotency keys, and a daily email quota through Upstash Redis (required in production).
- Origin checks and
verifySession()on state-changing routes and actions.
- Getting Started - Local setup
- Contributing - Development workflow
- ADRs in
decisions/- Why we made specific choices




