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.

โ„น๏ธ
Edge runtime is non-negotiable

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.

โ„น๏ธ
Scope

This documentation covers the CRM itself (the internal agency tool). Customer-facing websites and applications built for clients are standalone deployments and are documented separately. See ADR-001 and ADR-010 for the rationale.

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

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
Email 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

โš ๏ธ
No Node.js APIs on the Edge

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 tables
  • created_at TIMESTAMPTZ DEFAULT now() on all tables
  • tenant_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 users table in the public schema extends auth.users with tenant_id, role, full_name, and is_active

Supabase Storage

File uploads (proposal attachments, contract PDFs, documents) are stored in Supabase Storage buckets:

  • documents bucket โ€” client-visible and internal files from the Documents module
  • avatars bucket โ€” 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:

  1. Authenticated users can only read/write rows where tenant_id = auth.jwt() ->> 'tenant_id'
  2. Client portal users can only access rows where client_id matches their linked client record
  3. 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);
๐Ÿšจ
Never Remove RLS Policies

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 Environments

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

  1. Secrets never reach the client โ€” all Supabase service-role operations happen in server components or API routes, never in 'use client' code
  2. Edge runtime everywhere โ€” export const runtime = 'edge' on every route and page
  3. Tenant ID on every row โ€” no table with user data is missing tenant_id
  4. RLS as the backstop โ€” even if application code has a bug, RLS prevents cross-tenant data leakage
  5. Typed Supabase queries โ€” nested FK joins return arrays; always use [0] indexing and define array-shaped interfaces
  6. Webhook idempotency โ€” Stripe webhooks check event type before processing; duplicate deliveries are safe