Engineering Overview
Engineering
This section is the authoritative technical reference for the SONAN DIGITAL CRM platform. It is intended for software engineers, DevOps engineers, and technical leads who build, maintain, deploy, or audit the system.
Audience
| Role | Relevant sections |
|---|---|
| Backend / Full-stack engineers | Architecture, API, Database, Supabase, RLS, Authentication, Authorization |
| DevOps / Infrastructure engineers | CI/CD, Deployment, Monitoring, Disaster Recovery |
| Security engineers | Authentication, RLS, Security |
| New team members (onboarding) | Start with Architecture, then Authentication, then API |
Stack Summary
The CRM is a Next.js 15 App Router application running on Vercel with edge runtime throughout. The database is PostgreSQL via Supabase (Auth + Storage + RLS). Email delivery uses Resend. Payments are handled by Stripe.
Every route file and page must export export const runtime = 'edge'. Forgetting this causes Vercel to silently fall back to Node.js runtime, which breaks the deployment constraints and increases cold-start latency.
Sub-sections
Architecture
System design, component diagram, data flow, and infrastructure topology. Start here if you are new to the codebase.
API Reference
Complete reference for every HTTP endpoint โ admin routes, portal routes, Stripe webhook, and cron endpoints. Includes request/response shapes and authentication requirements.
Database
PostgreSQL schema documentation. Every table, every column, FK relationships, indexes, and an ER diagram of core entities.
Supabase
How Supabase is used in the codebase: client instantiation patterns (createClient vs createServiceClient), Auth, Storage, Migrations, and the typed client. Covers common gotchas including the nested FK array issue.
Row Level Security
Full RLS policy inventory. Policy patterns per table, known RLS incidents and their fixes, and instructions for testing policies in the Supabase dashboard.
Authentication
Auth system design: Supabase Auth with email+password and TOTP MFA, cookie-based SSR sessions, admin vs portal flows, requireAdminWithTenant(), and getPortalClientId().
Authorization
Role hierarchy (super_admin โ admin โ employee โ client), per-role capability matrix, route guards, middleware, multi-tenant isolation, and getActiveTenantId().
CI/CD
GitHub Actions workflows (lint, type-check, build, deploy), Vercel integration, E2E tests with Playwright, and the FUSE-safe commit pattern required when committing from sandbox environments.
Deployment
Vercel project configuration, environment variables, custom domain, FUSE-safe commit procedure (step-by-step), rollback, preview deployments, and post-deployment verification checklist.
Monitoring
Sentry error tracking, Vercel logs, cron job verification, UptimeRobot, recommended health endpoint, alerting rules, and log retention.
Security
Security headers, cookie security, rate limiting roadmap, file upload validation, Stripe webhook signature verification, CRON_SECRET enforcement, RLS defense-in-depth, known fixed vulnerabilities, and accepted risks for v1.0.
Disaster Recovery
Backup strategy, RTO/RPO targets, restore procedures, runbooks for common failure scenarios.
Key Conventions
The following conventions apply across all engineering work on this codebase. Violating them is a common source of production incidents.
// Every route file must have this at the top
export const runtime = 'edge'
// Admin API routes always start with this guard
const caller = await requireAdminWithTenant()
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
// Supabase nested FK joins return arrays โ always index with [0]
const clientName = data.clients?.[0]?.name // correct
const clientName = data.clients?.name // WRONG โ TypeScript error at runtime
// Never use the service client on the frontend
const supabase = createServiceClient() // server-only (API routes, server components)
See each sub-section for full detail.
Architecture
This section covers the technical architecture of the SONAN DIGITAL CRM โ the internal agency operating system built on Next.js 15, Supabase, and Vercel.
What's Covered
| Document | Description |
|---|---|
| Overview | Full system architecture: stack, data flow, multi-tenancy, auth, deployment |
| ADR Index | All Architecture Decision Records โ why each major decision was made |
Architecture at a Glance
The CRM is a multi-tenant SaaS application deployed on Vercel's edge network, backed by Supabase (PostgreSQL + Auth + Storage). Every request is handled by an edge function โ no Node.js server, no long-running process. Tenant isolation is enforced at two layers: the API layer filters by caller.tenantId, and Supabase RLS policies provide a final enforcement backstop.
Key Design Principles
- Edge-first โ all API routes and pages run on Vercel Edge Runtime for low latency globally
- Defense-in-depth tenancy โ API filtering + RLS, never just one layer
- Server-component data fetching โ data is fetched in React Server Components and passed as props to
'use client'children - No shared secrets between tenants โ each tenant's data is isolated at the row level; no cross-tenant queries are possible through the application layer
Related Sections
Architecture Overview
This document describes the full technical architecture of the SONAN DIGITAL CRM โ the agency's internal operating system for managing leads, clients, projects, proposals, invoices, contracts, and team operations.
Stack Summary
| Layer | Technology | Purpose |
|---|---|---|
| Framework | Next.js 15 (App Router) | Full-stack React framework, edge-deployed |
| Runtime | Vercel Edge Runtime | Serverless edge functions, no Node.js APIs |
| Database | Supabase (PostgreSQL 15) | Relational data, RLS, real-time |
| Auth | Supabase Auth | Cookie-based SSR auth, TOTP MFA |
| Storage | Supabase Storage | File uploads (documents, attachments) |
| Hosting | Vercel | Edge deployment, preview environments, cron |
| Resend | Transactional email (invitations, notifications) | |
| Payments | Stripe Checkout | Invoice payment processing |
| Monitoring | Sentry | Error tracking and performance monitoring |
System Architecture Diagram
graph TD
subgraph Client ["Browser / Client Portal"]
UA[User Agent]
end
subgraph Vercel ["Vercel Edge Network"]
EF[Edge Function\nNext.js App Router]
SC[React Server Components]
CC["'use client' Components"]
CRON[Vercel Cron Jobs]
end
subgraph Supabase ["Supabase Platform"]
AUTH[Supabase Auth\nJWT + Cookies]
DB[(PostgreSQL 15\nRLS Enabled)]
STOR[Supabase Storage\nS3-compatible]
RT[Realtime\nWebSocket]
end
subgraph External ["External Services"]
STRIPE[Stripe\nCheckout + Webhooks]
RESEND[Resend\nTransactional Email]
SENTRY[Sentry\nError Tracking]
end
UA -->|HTTPS request| EF
EF --> SC
SC -->|props| CC
SC -->|service client| DB
EF -->|auth cookie| AUTH
AUTH -->|JWT| DB
DB -->|RLS enforced| DB
SC -->|signed URLs| STOR
CRON -->|/api/admin/appointments/cron| EF
EF -->|send email| RESEND
STRIPE -->|webhook POST| EF
EF -->|capture error| SENTRY
CC -->|subscribe| RT
Next.js 15 Edge Runtime
Why Edge Runtime?
Every API route and server page in the CRM is decorated with:
export const runtime = 'edge'
This ensures all compute runs on Vercel's global edge network (200+ locations) rather than a regional Node.js Lambda. The benefits:
- Lower latency for globally distributed team members and clients
- Faster cold starts โ edge functions start in milliseconds vs. seconds for Node.js lambdas
- Cost efficiency โ edge compute is cheaper than Lambda at the request volumes of an agency CRM
Edge Runtime Constraints
The edge runtime is not Node.js. The following are not available:
- `fs` (filesystem) โ use Supabase Storage instead
- `crypto` (Node.js built-in) โ use the Web Crypto API (`globalThis.crypto`)
- `Buffer` โ use `Uint8Array` or `TextEncoder`/`TextDecoder`
- Most npm packages that depend on Node.js internals
Always check that any new dependency is edge-compatible before adding it. The build will fail at deploy time if an incompatible API is used.
Server Component Pattern
Data fetching follows a strict pattern to keep secrets server-side and avoid client bundle bloat:
// app/admin/clients/page.tsx โ Server Component
import { createClient } from '@/lib/supabase/server'
import { ClientList } from './ClientList' // 'use client'
export const runtime = 'edge'
export default async function ClientsPage() {
const supabase = await createClient()
const { data: clients } = await supabase
.from('clients')
.select('id, name, status')
.order('name')
return <ClientList clients={clients ?? []} />
}
// ClientList.tsx โ Client Component
'use client'
export function ClientList({ clients }: { clients: Client[] }) {
// Interactive UI here โ no direct DB access
}
Supabase Architecture
PostgreSQL 15
All application data lives in a single Supabase PostgreSQL instance. The schema is multi-tenant: every table that contains tenant-specific data has a tenant_id UUID column that references a tenants table.
Key schema conventions:
id UUID DEFAULT gen_random_uuid()on all tablescreated_at TIMESTAMPTZ DEFAULT now()on all tablestenant_id UUID NOT NULL REFERENCES tenants(id)on all tenant-scoped tables- Soft deletes are used selectively (e.g.
archived_at) rather than hard deletes
Supabase Auth
Authentication uses Supabase Auth with cookie-based sessions optimized for SSR:
- Sessions are stored as HTTP-only cookies (set by
@supabase/ssr) - TOTP MFA is available and enforced for admin users
- Two separate auth flows exist: admin portal (
/auth/login) and client portal (/client/auth/login) - The
userstable in the public schema extendsauth.userswithtenant_id,role,full_name, andis_active
Supabase Storage
File uploads (proposal attachments, contract PDFs, documents) are stored in Supabase Storage buckets:
documentsbucket โ client-visible and internal files from the Documents moduleavatarsbucket โ user profile photos- Files are accessed via signed URLs generated server-side; clients never get direct bucket access
Row Level Security (RLS)
RLS is enabled on all tables. Policies enforce that:
- Authenticated users can only read/write rows where
tenant_id = auth.jwt() ->> 'tenant_id' - Client portal users can only access rows where
client_idmatches their linked client record - Service role operations (used in API routes via
createServiceClient()) bypass RLS intentionally
See ADR-007 for the full rationale on defense-in-depth tenancy.
Multi-Tenancy Design
The CRM is a single-instance multi-tenant application: one Supabase project, one Vercel deployment, many tenants.
Tenant Isolation Layers
flowchart LR
REQ[Incoming Request] --> AUTH_CHECK{Auth Check}
AUTH_CHECK -->|unauthorized| REJECT[401 Unauthorized]
AUTH_CHECK -->|authorized| API_FILTER[API Layer\ntenant_id filter]
API_FILTER --> RLS[Supabase RLS\ntenant_id policy]
RLS --> DATA[(PostgreSQL\nRow Data)]
Layer 1 โ API route filtering:
Every admin API route calls requireAdminWithTenant() which extracts caller.tenantId from the session JWT. All database queries are scoped to this tenant ID:
const { data } = await supabase
.from('clients')
.select('*')
.eq('tenant_id', caller.tenantId) // explicit filter
Layer 2 โ RLS policies:
Even if Layer 1 has a bug, RLS ensures the database will not return rows belonging to another tenant. The policy pattern:
CREATE POLICY "tenant_isolation" ON clients
USING (tenant_id = (auth.jwt() ->> 'tenant_id')::uuid);
Disabling RLS on a table or dropping tenant isolation policies would expose all tenants' data to any authenticated user. If a policy causes a performance problem, fix the query or add an index โ do not drop the policy.
Tenant ID in the JWT
The user's tenant_id is embedded in the Supabase JWT at login time via a database function hook. This means RLS policies can reference it without an extra table join on every query.
Auth Flow
Admin Auth Flow
sequenceDiagram
participant B as Browser
participant E as Edge Function
participant SA as Supabase Auth
participant DB as PostgreSQL
B->>E: POST /auth/login (email, password)
E->>SA: signInWithPassword()
SA-->>E: session JWT + refresh token
E->>DB: SELECT role, tenant_id FROM users WHERE id = ?
E-->>B: Set-Cookie: sb-session (HTTP-only)
B->>E: GET /admin/dashboard (with cookie)
E->>SA: getUser() from cookie
SA-->>E: user + claims
E->>DB: query with tenant_id filter + RLS
DB-->>E: tenant-scoped data
E-->>B: Rendered page
Client Portal Auth Flow
Clients log in at /client/auth/login using the same Supabase Auth system but are redirected to the client-facing portal (/client/*) routes. Client users have role = 'client' in the users table and their queries are further scoped to their client_id.
TOTP MFA
Admin users can (and are encouraged to) enroll TOTP MFA via /admin/settings/security. After password verification, Supabase Auth requires a TOTP challenge before issuing the full session. This is enforced at the auth middleware level.
Vercel Deployment Architecture
Environments
| Environment | Branch | URL Pattern | Purpose |
|---|---|---|---|
| Production | main |
crm.sonandigital.com |
Live agency operations |
| Preview | Any PR branch | *.vercel.app |
Review before merge |
Preview deployments share the production Supabase project by default โ the same database, same tenants. Be careful running destructive test operations on preview deployments. A staging Supabase project can be configured if needed.
Vercel Cron Jobs
Automated background tasks use Vercel Cron, configured in vercel.json:
{
"crons": [
{
"path": "/api/admin/appointments/cron",
"schedule": "0 8 * * *"
}
]
}
Cron routes are protected by a CRON_SECRET environment variable:
const secret = req.headers.get('authorization')?.replace('Bearer ', '')
if (secret !== process.env.CRON_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
Environment Variables
All secrets are managed in Vercel's environment variable store. See CLAUDE.md ยง5 for the full list. No secrets are committed to the repository.
Data Flow: Request Lifecycle
A typical admin API request flows as follows:
sequenceDiagram
participant C as Client (Browser)
participant V as Vercel Edge
participant SA as Supabase Auth
participant DB as PostgreSQL (RLS)
participant S as External Service
C->>V: HTTP Request + session cookie
V->>SA: Validate session cookie โ user object
SA-->>V: { userId, tenantId, role }
V->>V: requireAdminWithTenant() โ check role
V->>DB: Query with .eq('tenant_id', tenantId)
DB->>DB: RLS policy enforces tenant_id
DB-->>V: Filtered row data
V->>S: Optional: Stripe / Resend / Sentry
S-->>V: API response
V-->>C: JSON response or rendered HTML
Key Architectural Principles
- Secrets never reach the client โ all Supabase service-role operations happen in server components or API routes, never in
'use client'code - Edge runtime everywhere โ
export const runtime = 'edge'on every route and page - Tenant ID on every row โ no table with user data is missing
tenant_id - RLS as the backstop โ even if application code has a bug, RLS prevents cross-tenant data leakage
- Typed Supabase queries โ nested FK joins return arrays; always use
[0]indexing and define array-shaped interfaces - Webhook idempotency โ Stripe webhooks check event type before processing; duplicate deliveries are safe
Related Documents
- ADR Index โ All architecture decisions with rationale
- Database Schema โ Full table definitions
- Auth & Security โ Detailed auth implementation
- API Reference โ Route inventory and patterns
- Deployment Guide โ CI/CD and release process