Architecture Decision Records
Architecture Decision Records (ADRs)
Architecture Decision Records document the significant technical and structural decisions made during the design and evolution of the SONAN DIGITAL CRM. Each ADR captures the context that existed at the time, the decision made, the alternatives that were considered, and the consequences โ both positive and negative.
Why We Write ADRs
ADRs serve three purposes:
- Onboarding โ new engineers understand why the system is built the way it is, not just how
- Avoiding re-litigation โ when someone proposes changing a past decision, the ADR shows what was already considered
- Audit trail โ decisions are traceable to a point in time with explicit reasoning
ADR Format
Each ADR follows this structure:
Status: Accepted | Superseded | Deprecated
Date: YYYY-MM-DD
Context: What problem existed? What forces were at play?
Decision: What was decided?
Alternatives Considered: What else was evaluated?
Consequences: What are the trade-offs?
Future Considerations: What might change this decision later?
ADR Index
| Number | Title | Status | Date |
|---|---|---|---|
| ADR-001 | CRM vs. Customer Solutions โ Scope Separation | Accepted | 2026-06-30 |
| ADR-002 | CRM In-App Wiki vs. This Documentation Portal | Accepted | 2026-06-30 |
| ADR-003 | Separate Repository for Documentation | Accepted | 2026-06-30 |
| ADR-004 | Cloudflare Pages for Docs Portal Hosting | Accepted | 2026-06-30 |
| ADR-005 | Cloudflare Access for Docs Portal Authentication | Accepted | 2026-06-30 |
| ADR-006 | Supabase as Database and Auth Platform | Accepted | 2026-06-30 |
| ADR-007 | RLS + API-Layer Filtering for Multi-Tenant Isolation | Accepted | 2026-06-30 |
| ADR-008 | Stripe Checkout for Invoice Payments | Accepted | 2026-06-30 |
| ADR-009 | Resend for Transactional Email | Accepted | 2026-06-30 |
| ADR-010 | Customer Solutions as Standalone Deployments | Accepted | 2026-06-30 |
| ADR-011 | Documents Module for Both Client and Internal Files | Accepted | 2026-06-30 |
| ADR-012 | Lightweight Client Cost Tracking in the CRM | Accepted | 2026-06-30 |
| ADR-013 | Project Deployment Artifact Tracking | Accepted | 2026-06-30 |
Related
- Architecture Overview โ How all these decisions fit together in the running system
ADR-001: CRM vs. Customer Solutions โ Scope Separation
Status: Accepted Date: 2026-06-30 Deciders: SONAN DIGITAL Engineering
Context
SONAN DIGITAL operates in two distinct modes:
- Internal agency operations โ managing leads, clients, projects, proposals, invoices, contracts, team tasks, and communications. This is the purpose of the CRM.
- Client deliverables โ websites, web applications, and digital products built for clients and deployed under their own domains or under SONAN DIGITAL's client-facing infrastructure.
Early in the design of the CRM, there was a question of whether client-facing websites and applications should be hosted within, served from, or managed as sub-routes of the CRM application itself. This would have made the CRM a "platform" rather than a pure internal tool.
The risk was conflating two fundamentally different things: - The agency's own business tooling (the CRM) - The products the agency delivers to clients (customer solutions)
These have different audiences, different deployment lifecycles, different security boundaries, and different technical owners.
Decision
The CRM is strictly the internal agency operating system. It handles:
- Lead capture and qualification
- Client relationship management
- Project planning and task tracking
- Proposal and contract generation
- Invoice creation and payment collection
- Team scheduling and time logging
- Internal wiki and knowledge base
- Notification and communication tools
Customer solutions โ websites, apps, portals, and any other digital products built for clients โ are separate deployments. They live in their own repositories, are deployed to their own hosting environments, and have no runtime dependency on the CRM.
The CRM may contain records about customer solutions (e.g., project records, deployment logs in the Projects module), but it does not serve or host them.
Alternatives Considered
Alternative A: CRM as a Platform โ Host Client Sites as Sub-Routes
Client sites served at /client-sites/{client-slug}/* from the CRM application.
Rejected because: - A bug or outage in the CRM would take down client-facing websites โ unacceptable coupling - Single Vercel deployment bandwidth and build-time constraints would affect all clients - Security boundary between CRM admin logic and public-facing client pages would be extremely difficult to maintain - Client sites have their own tech stacks (WordPress, custom React, Webflow, etc.) โ they cannot all be crammed into a single Next.js monorepo
Alternative B: Separate "Delivery Platform" Repo That Imports from CRM
A shared library or API that client deployments call into the CRM for data.
Rejected because: - Creates a runtime dependency โ client sites would break if the CRM API changes or goes down - Complicates CRM versioning and API stability requirements significantly - Unnecessary for the actual use cases (most client sites don't need live CRM data)
Consequences
Positive: - Clear mental model for the engineering team โ the CRM scope is well-defined - CRM outages do not affect client-facing deliverables - Each customer solution can be built with the right technology for that client's needs - Security boundary is clean โ no risk of CRM admin data leaking through a client site route
Negative: - Two separate codebases to maintain when a project involves both CRM records and a client-facing app - Engineers must context-switch between CRM conventions and each client solution's tech stack - No single pane of glass showing both CRM operational data and client site analytics (this is an acceptable trade-off)
Future Considerations
If SONAN DIGITAL ever builds a white-label SaaS product for clients (i.e., clients get their own CRM-like dashboard), that would be a new product โ not an extension of this CRM โ and would warrant its own ADR and architecture.
Related
- ADR-010 โ Standalone deployment mechanics for customer solutions
- Architecture Overview
ADR-002: CRM In-App Wiki vs. This Documentation Portal
Status: Accepted Date: 2026-06-30 Deciders: SONAN DIGITAL Engineering
Context
The SONAN DIGITAL CRM contains a built-in Wiki module (/admin/wiki). This module lets admins create and publish articles that appear in the CRM interface as contextual help โ things like "How to create a proposal", "Client onboarding checklist", or "Invoice payment FAQ".
Separately, this documentation portal (sonan-digital-docs) exists to document the CRM itself: its architecture, database schema, API routes, deployment procedures, and operational runbooks.
The question arose: should these two things be merged? Should the engineering documentation live inside the CRM wiki? Or should the contextual help articles be moved here?
Decision
The two systems serve different audiences and must remain separate:
| Dimension | CRM In-App Wiki | This Documentation Portal |
|---|---|---|
| Primary audience | CRM end users (admins, account managers, team members, clients) | Engineers, DevOps, technical leads |
| Content type | How-to guides, FAQs, process documentation for using the CRM | Architecture, schema, API reference, runbooks, ADRs |
| Access | Available inside the authenticated CRM application | Gated behind Cloudflare Access (team + approved clients) |
| Authoring | Non-technical staff via the CRM admin interface (rich text editor) | Engineers via Markdown files in the sonan-digital-docs repo |
| Deployment | Stored in the wiki_articles PostgreSQL table, served dynamically |
Static site generated by MkDocs Material, deployed to Cloudflare Pages |
| Versioning | Live edits, no version history (by design โ it's operational docs) | Git history, PR reviews, changelog |
Merging these would force non-technical users to navigate engineering documentation, or force engineers to write Markdown into a CRM web form. Neither is acceptable.
Alternatives Considered
Alternative A: Move Engineering Docs Into the CRM Wiki
Engineering documentation (ADRs, schema docs, API reference) written as wiki articles inside the CRM.
Rejected because:
- CRM wiki has no support for Mermaid diagrams, code blocks with syntax highlighting, or structured navigation
- Wiki articles are stored in the database as content_html โ no git history, no PR review process
- CRM wiki is not the right place for content that needs version-controlled change management
- Engineering docs must be readable even when the CRM is down (deployment docs, runbooks)
Alternative B: Move CRM Contextual Help Into This Docs Portal
User-facing how-to articles moved into MkDocs.
Rejected because: - Non-technical staff cannot contribute to a git-based Markdown portal - Contextual help belongs in the application where users are working, not in a separate site they have to navigate to - The CRM wiki is designed to surface relevant articles contextually โ that integration would be lost
Alternative C: Single Platform (e.g., Notion) for Both
All documentation โ user-facing and engineering โ in a single Notion workspace.
Rejected because: - Notion has no way to enforce access controls at the article level with the same granularity as Cloudflare Access - Engineering docs need to live close to the code (in a repo), not in a third-party SaaS - See ADR-003 and ADR-004 for hosting rationale
Consequences
Positive: - Each system is optimized for its audience and use case - Non-technical contributors can maintain user-facing help without git or Markdown - Engineering documentation has full version control and review processes - Engineering docs are available even when the CRM is down
Negative: - Two systems to maintain rather than one - Risk of content drift (e.g., a process change is updated in one system but not the other) - Engineers must remember to update both if a change affects user-visible behavior
When a feature change affects both how the system works (engineering docs) and how users should use it (CRM wiki), create a ticket to update both. The engineering PR description should note if a CRM wiki article needs updating.
Future Considerations
If the CRM wiki gains rich authoring features (Markdown support, diagram rendering, git-backed storage), it may become possible to merge some categories of content. This would require a separate ADR.
Related
- ADR-003 โ Why docs live in their own repo
- ADR-004 โ Hosting choice for this portal
- Architecture Overview
ADR-003: Separate Repository for Documentation
Status: Accepted Date: 2026-06-30 Deciders: SONAN DIGITAL Engineering
Context
The SONAN DIGITAL CRM lives in the sonan-digital repository. This documentation portal lives in sonan-digital-docs. The question of whether to co-locate them โ keeping docs in a /docs directory inside the main CRM repo โ was considered during initial setup.
Arguments for co-location: - Docs stay in sync with code changes (same PR updates both) - Single repo to clone for onboarding - No separate CI pipeline to maintain
Arguments for separation: - Different deployment lifecycles - Different contributors - Docs CI should not block app CI
Decision
Documentation lives in its own repository: sonan-digital-docs.
The CRM source code (sonan-digital) and the documentation portal (sonan-digital-docs) are maintained as separate Git repositories with independent CI/CD pipelines.
Alternatives Considered
Alternative A: /docs Directory Inside sonan-digital
Documentation Markdown files in a docs/ folder at the root of the main CRM repo. MkDocs configured to build from that directory.
Rejected because:
- CI coupling โ A failed MkDocs build (broken link, bad Mermaid syntax) would block the CRM CI pipeline from passing. A documentation problem should never prevent a code deployment.
- Bundle size โ MkDocs, its plugins, and Python dependencies would be added to the repo's development environment. CRM developers using Node.js would need Python tooling installed.
- Access control โ The CRM repo contains
.env.examplefiles, migration scripts, and implementation details. Granting documentation contributors (ops staff, technical writers) access to the full CRM repo is a wider blast radius than needed. - Different deployment cadences โ CRM deploys happen frequently (multiple per week). Docs updates are less frequent. Coupling the two means docs updates always go through the full CRM CI (type checking, lint, build) โ a 3โ5 minute pipeline โ just to fix a typo in a Markdown file.
Alternative B: Monorepo with Turborepo
Both the CRM app and the docs site as packages in a single Turborepo monorepo.
Rejected because: - Significant tooling overhead for a docs site that does not share code with the CRM - Turborepo is designed for sharing code between apps โ no code is shared between the CRM and the docs site - Same access-control problem as Alternative A โ contributors need repo access to the full monorepo
Alternative C: Docs Inside the CRM Wiki (Database-Backed)
Already evaluated and rejected in ADR-002.
Consequences
Positive:
- Documentation CI failures never block CRM deployments
- Documentation contributors (ops, technical writers, account managers writing runbooks) only need access to sonan-digital-docs โ not the full CRM source code
- MkDocs Python toolchain is isolated; CRM developers don't need it unless they're updating docs
- Docs and CRM can be deployed on different schedules without coordination
- The docs site can reference a specific CRM version/commit without being tied to it
Negative: - Two repositories to clone, two sets of CI pipelines to monitor - Changes that affect both code and docs require two PRs (acceptable โ they're reviewed by different audiences) - Risk of docs falling out of sync with the code if engineers don't update both (mitigated by process, not tooling)
Reference specific CRM source files in documentation using GitHub permalink URLs (linking to a specific commit SHA) to keep links stable even as the codebase changes. Avoid linking to main branch file paths โ they will drift.
Future Considerations
If the documentation grows to include multiple sub-sites (e.g., a public-facing API reference separate from the internal engineering docs), each may warrant its own repository or MkDocs instance, following the same principle of isolation.
Related
- ADR-002 โ Why the CRM wiki and this portal are separate systems
- ADR-004 โ Where the docs portal is hosted
- Architecture Overview
ADR-004: Cloudflare Pages for Docs Portal Hosting
Status: Accepted Date: 2026-06-30 Deciders: SONAN DIGITAL Engineering
Context
The sonan-digital-docs documentation portal is a static site generated by MkDocs Material. It needs to be hosted somewhere with:
- Global CDN delivery (the team is distributed)
- Access control integration (docs are not public โ see ADR-005)
- Zero or near-zero cost at the team's usage level
- Minimal operational overhead (no servers to manage)
- Support for MkDocs Material's static output (HTML, CSS, JS, assets)
Several hosting platforms were evaluated against these requirements.
Decision
Cloudflare Pages is used to host the documentation portal.
The sonan-digital-docs repository is connected to Cloudflare Pages via the Cloudflare dashboard. On every push to main, Cloudflare Pages:
- Runs
mkdocs build(configured inmkdocs.yml) - Deploys the
site/output to Cloudflare's global edge network - Makes it available at the configured custom domain
Access control is layered on top via Cloudflare Access (see ADR-005).
Alternatives Considered
Alternative A: GitBook
GitBook is a popular hosted documentation platform with a Markdown-based authoring experience and built-in access controls.
Rejected because: - Vendor lock-in โ content stored in GitBook's proprietary format makes migration painful; content in this repo is pure Markdown files (portable to any static site generator) - Cost โ GitBook's team plans are expensive relative to Cloudflare Pages (free tier) - Limited customization โ MkDocs Material offers more precise control over navigation, admonitions, Mermaid diagrams, and custom CSS than GitBook allows - No git-native workflow โ GitBook has its own editor; this team's workflow is PR-based Markdown editing
Alternative B: Confluence
Atlassian Confluence is widely used for internal documentation in larger organizations.
Rejected because: - Not Markdown-native โ Confluence uses its own rich-text format; converting existing Markdown docs to Confluence format is lossy and ongoing maintenance is painful - Heavy โ Confluence is designed for wiki-style collaborative editing, not for a structured technical documentation site - Cost โ Confluence Cloud is expensive; even the free tier is limited to 10 users - No CDN โ Confluence Cloud has no meaningful edge caching; global performance is inconsistent - Overkill โ the team does not need Confluence's page tree management, inline comments, or Jira integration for this use case
Alternative C: GitHub Pages
GitHub Pages deploys static sites directly from a repository, with free hosting and custom domain support.
Rejected because: - No access control integration โ GitHub Pages does not integrate with any identity provider for access control; the only options are making the site public or making the entire GitHub repo private (which still doesn't gate the published site behind auth) - No Cloudflare Access integration โ protecting GitHub Pages with Cloudflare Access requires routing traffic through Cloudflare Workers, adding complexity - Slower CDN โ GitHub Pages is served from GitHub's infrastructure (fastly-backed), which is less globally distributed than Cloudflare's network - See ADR-005 โ access control was a primary requirement
Alternative D: Vercel (Same Platform as CRM)
Deploy the docs site to Vercel alongside the CRM.
Evaluated but not selected because: - Cloudflare Access integration โ Cloudflare Access natively protects Cloudflare Pages without any extra configuration; protecting a Vercel deployment with Cloudflare Access requires Cloudflare proxying and a more complex setup - Cost โ Vercel's free tier has build-minute limits that are better preserved for the CRM; Cloudflare Pages has more generous free build limits for a static site - Separation of concerns โ keeping the docs on a different platform from the CRM means a Vercel outage doesn't affect access to the docs (useful during incident response)
Alternative E: Netlify
Similar to Vercel but for static sites.
Not selected because: - Cloudflare Pages offers equivalent features with better Cloudflare Access integration - No meaningful advantage over Cloudflare Pages for this use case
Consequences
Positive:
- Free tier covers all expected traffic (internal team + approved clients)
- Global CDN โ sub-100ms response times for team members worldwide
- Native Cloudflare Access integration โ no proxy configuration needed
- Git-based deployment โ push to main deploys automatically
- Preview deployments on PRs โ proposed doc changes can be reviewed at a live URL before merge
- MkDocs Material's full feature set is available (Mermaid, admonitions, search, etc.)
Negative:
- Cloudflare-specific deployment configuration (not portable to Vercel/Netlify without reconfiguration โ acceptable)
- Build environment on Cloudflare Pages must have the correct Python version and MkDocs plugin versions (pinned in requirements.txt)
- Cloudflare Pages has a 20,000 files-per-deployment limit โ not an issue for MkDocs output at current scale
The Cloudflare Pages build is configured with:
- Build command: pip install -r requirements.txt && mkdocs build
- Output directory: site
- Python version: set via runtime.txt in the repo root
Future Considerations
If the documentation grows to require server-side rendering (e.g., dynamic search indexing, personalized content), a move to Cloudflare Workers with a server-rendered framework would be evaluated. For a static documentation site, Pages is the right tool.
Related
- ADR-005 โ Access control for the docs portal
- ADR-003 โ Why docs are in a separate repo
- Architecture Overview
ADR-005: Cloudflare Access for Docs Portal Authentication
Status: Accepted Date: 2026-06-30 Deciders: SONAN DIGITAL Engineering
Context
The SONAN DIGITAL documentation portal contains internal engineering documentation, architecture decision records, database schemas, API route details, and operational runbooks. This content must not be publicly accessible โ it would expose implementation details that could assist an attacker.
At the same time, the portal needs to be accessible to:
- All SONAN DIGITAL engineering and operations team members
- Approved external stakeholders (e.g., senior clients who have been granted access to technical documentation)
The portal is hosted on Cloudflare Pages (see ADR-004), which does not provide built-in access control beyond making a site public or private at the account level.
A solution was needed to authenticate individual users before they can view any page of the documentation site.
Decision
Cloudflare Access is used to gate the documentation portal. Every request to the docs domain is intercepted by Cloudflare Access, which requires the user to authenticate before the request reaches Cloudflare Pages.
How It Works
sequenceDiagram
participant U as User
participant CF as Cloudflare Access
participant IDP as Identity Provider (Google / OTP)
participant CP as Cloudflare Pages (Docs Site)
U->>CF: Request docs.sonandigital.com/...
CF->>CF: Check for valid Access JWT cookie
alt No valid JWT
CF->>U: Redirect to Access login page
U->>IDP: Authenticate (Google SSO or one-time PIN)
IDP-->>CF: Auth success
CF-->>U: Set Access JWT cookie
end
CF->>CP: Forward request with JWT header
CP-->>U: Serve documentation page
Access policies control which authenticated users can reach the site:
- SONAN DIGITAL team members authenticate via Google Workspace SSO
- External approved users authenticate via one-time PIN (email-based)
- Access policies can be scoped to specific email addresses or email domains
Alternatives Considered
Alternative A: HTTP Basic Authentication (.htpasswd)
A username and password pair set as an environment variable or Cloudflare Pages secret, enforced via a Cloudflare Worker.
Rejected because: - Shared credentials โ a single password is shared with all users; if one person leaves or shares the password, it must be rotated for everyone - No audit logs โ no way to know who accessed the docs, when, or from where - No per-user revocation โ removing a specific user's access requires rotating the shared password - Poor UX โ browser basic auth prompts are ugly and confusing for non-technical users - Not SSO-capable โ cannot integrate with Google Workspace for single sign-on
Alternative B: Netlify Password Protection
Netlify offers a single-password protection feature for deployed sites.
Rejected because: - Same shared-credential problems as HTTP Basic Auth - SONAN DIGITAL is already using Cloudflare (for DNS and Pages) โ adding Netlify for this feature would be an unnecessary additional vendor - No per-user audit log, no SSO integration
Alternative C: Next.js Auth Inside the Docs Site
Build authentication into the docs site itself using NextAuth.js or a similar library, requiring the docs to be a Next.js server-rendered app rather than a static MkDocs site.
Rejected because: - Requires converting the documentation from MkDocs Material (static, excellent for technical docs) to a Next.js application โ significant engineering overhead for no content benefit - Adds a server-side runtime to what is intentionally a static site โ more infrastructure to manage - Cloudflare Access provides stronger guarantees: auth happens at the network edge, before the request reaches the origin; a compromised origin server cannot bypass it
Alternative D: IP Allowlist
Only allow requests from known SONAN DIGITAL office IP addresses.
Rejected because: - Team members work remotely โ no fixed IP addresses - Clients with approved access have unknown IPs - IP allowlists are brittle (home IPs change, VPNs break them) and provide no audit trail
Consequences
Positive: - Per-user authentication โ each team member and approved client authenticates with their own identity; access can be revoked per-person without affecting others - Audit logs โ Cloudflare Access logs every authentication event and access request; useful for security reviews - SSO integration โ SONAN DIGITAL team members use their existing Google Workspace credentials; no new password to remember - Zero infrastructure โ no auth server to run or maintain; Cloudflare manages it entirely - Auth at the network edge โ authentication happens before any request reaches the docs origin; even a totally static origin is protected - Granular policies โ can grant different email addresses or domains access to different paths if sub-section gating is ever needed - Free tier โ Cloudflare Access is free for up to 50 users (sufficient for the current team size)
Negative: - Cloudflare dependency โ if Cloudflare Access has an outage, the docs become inaccessible even if Cloudflare Pages is healthy (mitigated: Cloudflare Access has 99.99% uptime SLA) - External user friction โ clients authenticating via one-time PIN must enter a code from their email each session; slightly more friction than a remembered password (acceptable โ it is more secure) - 50-user free tier limit โ if the approved user count exceeds 50, a paid Cloudflare Access plan is required
To grant a team member or client access to the docs portal: 1. Log in to the Cloudflare dashboard โ Zero Trust โ Access โ Applications 2. Find the docs portal application 3. Edit the access policy to add the new user's email address or email domain 4. The user will be prompted to authenticate on their next visit โ no account creation required
Future Considerations
If SONAN DIGITAL adopts a corporate identity provider (e.g., Okta, Azure AD), Cloudflare Access can integrate with it via SAML or OIDC โ no change to the docs site or Cloudflare Pages configuration required.
If the user count exceeds 50 (the free tier limit), evaluate Cloudflare Access Teams pricing or consider whether some documentation can be made public.
Related
- ADR-004 โ Docs portal hosting
- ADR-003 โ Why docs are in a separate repo
- Architecture Overview
ADR-006: Supabase as Database and Auth Platform
Status: Accepted Date: 2026-06-30 Deciders: SONAN DIGITAL Engineering
Context
The SONAN DIGITAL CRM required a persistent data layer with the following capabilities:
- Relational data model โ leads, clients, projects, tasks, invoices, and contracts have complex relationships that benefit from a relational schema with foreign keys and joins
- Authentication โ user login, session management, password reset, and ideally MFA support
- Row Level Security โ multi-tenant data isolation enforced at the database level (see ADR-007)
- File storage โ attachments, documents, and contract PDFs
- Real-time subscriptions โ optional but useful for live dashboard updates
- Managed hosting โ no desire to self-host and manage database infrastructure
- Good Next.js ecosystem support โ SSR-compatible client libraries
The team also had a strong preference for PostgreSQL specifically, because of its maturity, JSON support, full-text search, and the ability to write stored procedures and triggers for business logic.
Decision
Supabase is used as the database and authentication platform for the SONAN DIGITAL CRM.
Supabase provides:
- PostgreSQL 15 โ fully managed, with connection pooling via PgBouncer
- Supabase Auth โ JWT-based authentication with SSR cookie support, TOTP MFA, password reset, magic links
- Row Level Security โ first-class RLS policy management via the Supabase dashboard and migrations
- Supabase Storage โ S3-compatible file storage with per-bucket access policies
- Supabase Realtime โ WebSocket-based subscriptions to PostgreSQL change events
@supabase/ssrpackage โ cookie-based auth specifically designed for Next.js App Router SSR
The Supabase project is a single instance hosting all tenants. Tenant isolation is enforced via RLS (see ADR-007).
Client Usage Patterns
Two Supabase client types are used in the codebase:
// Standard SSR client โ uses user's session cookie, respects RLS
import { createClient } from '@/lib/supabase/server'
const supabase = await createClient()
// Service client โ uses service role key, bypasses RLS (admin operations only)
import { createServiceClient } from '@/lib/supabase/service'
const supabase = createServiceClient()
createServiceClient() bypasses all RLS policies. It must only be used in server-side code (API routes and server components) for operations that legitimately need to operate across tenant boundaries (e.g., system-level cron jobs, webhook handlers). Never expose the service role key to the client.
Alternatives Considered
Alternative A: PlanetScale (MySQL-based)
PlanetScale offers managed MySQL with branching and a generous free tier.
Rejected because: - MySQL, not PostgreSQL โ the team's preference and expertise is PostgreSQL; MySQL's RLS support is far more limited - No native RLS โ PlanetScale has no Row Level Security equivalent; tenant isolation would have to be implemented entirely in application code, removing the defense-in-depth layer - No built-in auth โ would require a separate auth service (Auth0, Clerk, etc.), adding another vendor and integration point - No storage โ file storage would require yet another vendor (AWS S3, Cloudflare R2) - PlanetScale shut down its free tier in 2024, increasing its cost disadvantage
Alternative B: Firebase (Firestore + Firebase Auth)
Google Firebase is a popular BaaS with Firestore (document database), Firebase Auth, and Cloud Storage.
Rejected because: - NoSQL document model โ Firestore's document/collection model is poorly suited to a CRM's relational data (clients have projects, projects have tasks, tasks have time logs, etc.); complex relational queries require multiple round-trips or denormalization - No JOINs โ Firestore cannot join collections server-side; all join logic must be in application code, increasing complexity and network round-trips - Vendor lock-in โ Firestore's query model is proprietary; migration away from it is significantly harder than migrating a PostgreSQL schema - Security rules complexity โ Firebase Security Rules are powerful but complex and difficult to audit; Supabase RLS SQL policies are more familiar to engineers with SQL backgrounds
Alternative C: Self-Hosted PostgreSQL (e.g., on Railway, Render, or a VPS)
Running a PostgreSQL instance on a managed platform like Railway or Render, with a separate auth library (e.g., Auth.js / NextAuth).
Rejected because: - Operational burden โ backup management, connection pooling, version upgrades, and monitoring become the team's responsibility - No built-in auth โ NextAuth or Clerk would be required, adding another integration to maintain - No built-in storage โ S3 or Cloudflare R2 required for file uploads, adding another vendor - No RLS management UI โ RLS policies would have to be managed entirely via raw SQL migrations; Supabase's dashboard provides a useful policy editor
Alternative D: Neon (Serverless PostgreSQL)
Neon offers serverless PostgreSQL with branching, similar to PlanetScale but on Postgres.
Evaluated but not selected because: - No built-in auth (would require Clerk or Auth.js) - No built-in storage - No built-in RLS management tooling - Supabase provides the full stack in one platform at comparable cost; Neon would require assembling a similar stack from multiple vendors
Consequences
Positive:
- Single vendor provides database, auth, storage, and realtime โ reduced integration complexity
- PostgreSQL's full feature set is available: JSON operators, full-text search, triggers, stored procedures, pg_cron, and extensions
- Supabase's @supabase/ssr package provides first-class Next.js App Router SSR support
- RLS is first-class in Supabase โ policy management via dashboard and migration files
- TOTP MFA is built into Supabase Auth โ no third-party MFA provider needed
- Generous free tier (sufficient for early-stage operations); predictable paid tier pricing
Negative:
- Supabase dependency โ if Supabase has an outage, the entire CRM is down. Mitigation: Supabase publishes status at status.supabase.com; backups are taken daily
- Nested join type inference quirk โ Supabase's JS SDK types nested FK joins as arrays even for many-to-one relationships; all access must use [0] indexing (documented in the codebase's CLAUDE.md and the TypeScript patterns guide)
- Single Supabase project for all tenants โ if the project's connection limit or database size becomes a constraint, migrating to per-tenant projects or a larger plan is required
Supabase's TypeScript types infer nested foreign-key joins as arrays, not single objects. Always define interfaces to match the array shape:
// Correct
const clientName = data.projects?.[0]?.clients?.[0]?.name
// Wrong โ TypeScript error, runtime undefined
const clientName = data.projects?.clients?.name
Future Considerations
If the CRM grows to a scale where a single Supabase project's connection pool or storage becomes a bottleneck, options include:
- Upgrading to a larger Supabase plan with higher connection limits
- Implementing per-tenant Supabase projects (significant migration effort)
- Moving to a self-hosted Supabase instance on dedicated infrastructure
Related
- ADR-007 โ How RLS is used for multi-tenant isolation
- Architecture Overview
- Database Schema
ADR-007: RLS + API-Layer Filtering for Multi-Tenant Isolation
Status: Accepted Date: 2026-06-30 Deciders: SONAN DIGITAL Engineering
Context
The SONAN DIGITAL CRM is a multi-tenant application: a single Supabase project and a single Vercel deployment serve multiple SONAN DIGITAL tenants (the agency itself, and any additional sub-organizations). Each tenant's data must be completely isolated โ no tenant should ever see, modify, or even be aware of another tenant's records.
The challenge with a single-database multi-tenant architecture is that all tenant data lives in the same PostgreSQL tables. Any query that does not correctly filter by tenant_id will inadvertently return (or modify) data belonging to other tenants.
Defense-in-depth was the guiding principle: rather than relying on a single enforcement mechanism, two independent layers were designed, either of which alone would prevent cross-tenant data leakage.
Decision
Multi-tenant isolation is enforced at two independent layers:
Layer 1: API-Layer Filtering (Application Code)
Every admin API route begins with:
import { requireAdminWithTenant } from '@/lib/admin-auth'
export async function GET(req: Request) {
const caller = await requireAdminWithTenant()
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
// caller.tenantId is the authenticated user's tenant
}
Every database query in an API route explicitly filters by caller.tenantId:
const { data } = await supabase
.from('clients')
.select('*')
.eq('tenant_id', caller.tenantId)
.order('name')
This is the first line of defense โ the application simply never queries for data outside the current tenant's scope.
Layer 2: PostgreSQL Row Level Security (RLS)
RLS is enabled on every table that contains tenant-scoped data. The standard tenant isolation policy is:
-- Enable RLS on the table
ALTER TABLE clients ENABLE ROW LEVEL SECURITY;
-- Authenticated users can only see rows matching their tenant_id from the JWT
CREATE POLICY "tenant_read" ON clients
FOR SELECT
USING (tenant_id = (auth.jwt() ->> 'tenant_id')::uuid);
CREATE POLICY "tenant_write" ON clients
FOR INSERT
WITH CHECK (tenant_id = (auth.jwt() ->> 'tenant_id')::uuid);
CREATE POLICY "tenant_update" ON clients
FOR UPDATE
USING (tenant_id = (auth.jwt() ->> 'tenant_id')::uuid)
WITH CHECK (tenant_id = (auth.jwt() ->> 'tenant_id')::uuid);
CREATE POLICY "tenant_delete" ON clients
FOR DELETE
USING (tenant_id = (auth.jwt() ->> 'tenant_id')::uuid);
The tenant_id is embedded in the user's Supabase Auth JWT at login time via a database function hook. This means RLS policies can reference it without an extra table lookup.
This is the backstop โ even if Layer 1 has a bug (e.g., a missing .eq('tenant_id', ...) filter), the database will refuse to return rows for a different tenant.
Service Role Exemption
The createServiceClient() function uses the Supabase service role key, which bypasses RLS. This is intentional for:
- Webhook handlers (Stripe webhooks do not carry a user session)
- Cron jobs (no authenticated user context)
- System-level admin operations (e.g., sending a broadcast notification to all tenants)
All service client usage must be in server-side code only and must include explicit tenant_id filters.
Alternatives Considered
Alternative A: RLS Only (No API-Layer Filtering)
Rely entirely on RLS, with no explicit tenant_id filter in application code. The developer writes queries without tenant filters; RLS ensures only the right rows are returned.
Rejected because: - A missing or misconfigured RLS policy (e.g., a new table where the developer forgot to add a policy) would silently return all rows with no error - RLS policy errors can be subtle and hard to test in development (where developers often connect with the service role) - Performance: RLS adds a policy evaluation step to every query; if the application-layer filter is also present, PostgreSQL can use it to reduce the scan range before applying the RLS policy โ better query plans
Alternative B: API-Layer Filtering Only (No RLS)
All tenant isolation enforced in application code. No RLS policies.
Rejected because:
- Single point of failure โ if one API route has a missing or incorrect tenant_id filter, that route leaks cross-tenant data with no backstop
- No protection against direct database access by a compromised service (e.g., if a compromised server-side package makes a direct Supabase query without going through the application layer)
- Supabase's anon key is distributed to client-side code โ if a client-side bug or XSS executes a query using the anon key, RLS is the only protection
Alternative C: Per-Tenant Database Schemas
Each tenant gets its own PostgreSQL schema (e.g., tenant_abc.clients, tenant_xyz.clients). No tenant_id column needed.
Rejected because: - Supabase does not natively support per-tenant schema management through its dashboard or migration tooling - Schema migrations would need to be applied to every tenant schema independently โ extremely high operational burden - Cross-tenant reporting (e.g., platform-wide analytics, if ever needed) becomes very complex - Supabase's connection pooling and client SDK are not designed for per-schema isolation
Alternative D: Per-Tenant Supabase Projects
Each tenant gets their own Supabase project (separate database, separate auth).
Rejected because: - At current scale (small number of tenants), this is enormous operational overhead - Each new tenant requires provisioning a new Supabase project, configuring auth, running migrations, setting environment variables - No shared infrastructure for cross-tenant operations - Supabase free tier allows limited projects; costs scale linearly with tenant count - This could be revisited if scale demands it โ see Future Considerations
Consequences
Positive: - Defense in depth โ two independent enforcement layers; a bug in one does not cause a data breach - Clear audit trail โ if a cross-tenant query ever occurs, it will be caught by RLS and logged as a policy violation - Performance โ double filtering (application + RLS) gives the query planner more information to work with, enabling better index use - Simplicity โ developers write SQL; RLS policies are standard PostgreSQL, not a proprietary abstraction - Protection at the DB level โ even direct database connections (e.g., from a DB admin tool, a migration script, or a compromised package) are subject to RLS when authenticated with the anon or user JWT
Negative:
- tenant_id must be on every table โ developers must remember to add this column and the corresponding RLS policy when creating new tables; missing one is a silent failure (mitigated by code review and a database schema checklist)
- RLS policy testing is harder in development โ developers connecting to Supabase with the service role key (e.g., in migrations, in the Supabase dashboard) bypass RLS; testing RLS requires using a real user JWT
- Service client discipline โ createServiceClient() bypasses RLS; every use must be carefully reviewed to ensure it includes explicit tenant filtering
When adding a new table that contains tenant-scoped data, you MUST:
1. Add `tenant_id UUID NOT NULL REFERENCES tenants(id)` to the table
2. Create an index: `CREATE INDEX ON new_table (tenant_id)`
3. Enable RLS: `ALTER TABLE new_table ENABLE ROW LEVEL SECURITY`
4. Create tenant isolation policies for SELECT, INSERT, UPDATE, DELETE
5. Verify the policies in the Supabase dashboard using "Test policies"
Future Considerations
If the CRM scales to a large number of tenants with very different data volumes, per-tenant Supabase projects may become attractive. At that point, the RLS policies could be removed (no longer needed in a per-tenant DB), but the API-layer filtering would remain as good practice.
If Supabase adds native multi-tenancy tooling (e.g., per-tenant JWT claims with automatic RLS), the current custom JWT claim approach may be simplified.
Related
- ADR-006 โ Why Supabase was chosen
- Architecture Overview
- Database Schema
- Auth & Security
ADR-008: Stripe Checkout for Invoice Payments
Status: Accepted Date: 2026-06-30 Deciders: SONAN DIGITAL Engineering
Context
The SONAN DIGITAL CRM includes an Invoicing module that allows the agency to create invoices for clients and track their payment status. A key requirement was the ability for clients to pay invoices online through the client portal โ eliminating the need for manual bank transfers, cheque payments, or off-system payment tracking.
Requirements for the payment integration:
- Clients must be able to pay invoices online without logging in to a complex payment system
- Payment must be secure and PCI-compliant without the agency building and maintaining a cardholder data environment
- The CRM must automatically update invoice status to "paid" when payment is confirmed
- Support for multiple currencies (client base is international)
- Reasonable transaction fees relative to invoice amounts (typically ยฃ1,000โยฃ50,000 per invoice)
- Minimal integration complexity โ the engineering team is small
Decision
Stripe Checkout is used for online invoice payments.
Integration Architecture
sequenceDiagram
participant CP as Client Portal
participant CRM as CRM API (Edge)
participant SC as Stripe Checkout
participant SW as Stripe Webhook
participant DB as Supabase PostgreSQL
CP->>CRM: POST /api/client/invoices/{id}/checkout
CRM->>SC: Create Checkout Session (amount, currency, metadata)
SC-->>CRM: { url: "https://checkout.stripe.com/..." }
CRM-->>CP: { checkoutUrl }
CP->>SC: Redirect browser to Stripe Checkout
Note over SC: Client enters card details on Stripe-hosted page
SC->>SW: POST /api/webhooks/stripe (payment_intent.succeeded)
SW->>DB: UPDATE invoices SET status='paid', paid_at=now() WHERE stripe_payment_intent_id=?
SW-->>SC: 200 OK
SC->>CP: Redirect to success URL
CP->>CRM: GET /api/client/invoices/{id} (show paid status)
Key Implementation Details
- The Stripe Checkout Session is created with
invoice_idin themetadatafield for reliable webhook matching - The webhook handler verifies the Stripe signature using
STRIPE_WEBHOOK_SECRETbefore processing - Webhook processing is idempotent โ if the same
payment_intent.succeededevent is received twice (Stripe guarantees at-least-once delivery), the second update is a no-op - The
invoicestable storesstripe_payment_intent_idandstripe_checkout_session_idfor reconciliation - Supported payment methods: card (Visa, Mastercard, Amex), and any methods enabled in the Stripe dashboard for the connected account
Alternatives Considered
Alternative A: PayPal
PayPal is widely recognized by consumers and businesses globally.
Rejected because: - Poor developer experience โ PayPal's API and SDK documentation is fragmented, with multiple deprecated versions and confusing product naming (PayPal Payments Standard, PayPal Commerce Platform, Braintree, etc.) - Higher fees โ PayPal's transaction fees (typically 3.49% + fixed fee for commercial transactions) are higher than Stripe's (1.5% for European cards + 0.25% for Stripe Billing, or 2.9% + $0.30 for standard) - Worse UX for B2B โ PayPal is associated with consumer e-commerce; B2B clients often prefer card-based payments - Webhook reliability โ PayPal's IPN and webhook system has historically been less reliable than Stripe's
Alternative B: Manual Bank Transfer with Manual Status Update
Invoice sent to client, client sends a bank transfer, account manager manually updates the invoice status in the CRM.
Rejected because: - No automation โ every payment requires manual intervention (receiving email confirmation of transfer, finding the invoice, updating status) - Reconciliation errors โ manual status updates are prone to human error (wrong invoice marked paid, delays in updating) - Client friction โ bank transfers require the client to initiate the payment through their banking app with the correct reference number; higher friction than clicking "Pay Now" - Slow โ bank transfers can take 1โ3 business days; online card payments are instant
Bank transfer is still available as an option for clients who prefer it. The invoice record supports a manual status update by the account manager. The Stripe integration is the primary online payment path but does not replace offline payment methods.
Alternative C: Paddle
Paddle is a merchant-of-record service that handles tax compliance globally.
Rejected because: - Overkill for B2B invoices โ Paddle's value proposition is tax remittance for software subscriptions sold globally; SONAN DIGITAL's invoices are B2B services with tax handled separately - Revenue share model โ Paddle takes a percentage of revenue as their fee; for large invoices (ยฃ20,000+), this is significantly more expensive than Stripe's fixed + percentage model - Less control โ Paddle acts as the merchant of record, which changes the legal relationship with clients in ways that may not be appropriate for a service agency
Alternative D: GoCardless (Direct Debit)
GoCardless enables direct debit payments, common in the UK.
Evaluated but not selected because: - Direct debit requires client setup (mandate authorization) before any payment can be collected โ too much friction for one-off invoice payments - Not suitable for international clients outside the UK/SEPA zone - Stripe supports BACS Direct Debit as an add-on if recurring automated collection ever becomes a requirement
Consequences
Positive: - PCI scope eliminated โ client card data never touches SONAN DIGITAL servers; Stripe handles all cardholder data on their PCI-DSS Level 1 certified infrastructure - Instant status update โ Stripe webhooks trigger automatic invoice-paid status updates within seconds of payment confirmation - Multi-currency support โ Stripe Checkout handles currency conversion and display automatically based on the invoice currency setting - Stripe Dashboard โ financial reconciliation, refunds, dispute management, and payout tracking all handled in the Stripe Dashboard; no custom reporting UI needed for payment operations - 3D Secure / SCA compliance โ Stripe handles Strong Customer Authentication (required in the EU/UK) automatically - Ecosystem โ Stripe's documentation, testing tools (test card numbers, webhook CLI), and support are industry-leading
Negative: - Stripe fees โ every online payment incurs a Stripe processing fee (currently ~1.5% + 20p for UK cards); this is a cost of doing business for online payments - Stripe dependency โ if Stripe is unavailable, clients cannot pay invoices online (they can still pay by bank transfer) - Webhook reliability โ the CRM must handle Stripe webhook delivery failures gracefully; the current implementation relies on Stripe's retry logic (Stripe retries failed webhooks for up to 3 days) - Test/live key management โ developers must take care not to use live Stripe keys in development or staging environments
Use Stripe's test card numbers in development:
- 4242 4242 4242 4242 โ successful payment
- 4000 0025 0000 3155 โ requires 3D Secure
- 4000 0000 0000 9995 โ payment declined
Use the Stripe CLI to forward webhooks to localhost:
```bash
stripe listen --forward-to localhost:3000/api/webhooks/stripe
```
Future Considerations
If SONAN DIGITAL moves to subscription-based retainer billing (recurring monthly invoices), Stripe Billing (subscriptions) or Stripe Invoicing (automated recurring invoices) should be evaluated. Both integrate with the existing Stripe account and would require additions to the CRM's invoice module.
If BACS Direct Debit collection for recurring UK clients is desired, GoCardless or Stripe BACS can be added as an additional payment method alongside Checkout.
Related
ADR-009: Resend for Transactional Email
Status: Accepted Date: 2026-06-30 Deciders: SONAN DIGITAL Engineering
Context
The SONAN DIGITAL CRM sends transactional emails in several scenarios:
- Team member invitations โ when an admin invites a new employee or client user to the CRM
- Proposal notifications โ notifying clients that a proposal is ready for review; notifying the team when a client approves or declines
- Invoice emails โ sending invoice PDFs or payment links to clients
- Contract notifications โ notifying clients that a contract is ready to sign; notifying the team when a client signs or declines
- Support ticket replies โ notifying clients of responses to their support tickets
- System notifications โ password reset emails, MFA code emails (handled by Supabase Auth)
Requirements for the email integration:
- Simple API โ the engineering team is small and does not want to maintain complex SMTP infrastructure
- Good deliverability โ transactional emails must reach client inboxes reliably, not land in spam
- React Email compatibility โ ability to write email templates as React components
- Reasonable cost at low volume (< 10,000 emails/month)
- Simple DNS setup โ SPF, DKIM, DMARC configuration that is well-documented and not complex
Decision
Resend is used for all transactional email sent by the CRM.
Email templates are written using React Email (a companion library from the same team as Resend) and compiled to HTML at send time. The Resend SDK is called from API routes and server actions โ never from client-side code.
Usage Pattern
import { Resend } from 'resend'
import { InviteEmail } from '@/emails/InviteEmail'
const resend = new Resend(process.env.RESEND_API_KEY)
await resend.emails.send({
from: 'SONAN DIGITAL <noreply@mail.sonandigital.com>',
to: [invitee.email],
subject: `You've been invited to SONAN DIGITAL`,
react: <InviteEmail name={invitee.name} inviteUrl={inviteUrl} />,
})
DNS Configuration
Resend requires the following DNS records on the sending domain:
| Type | Host | Value |
|---|---|---|
| TXT | mail.sonandigital.com |
SPF record (v=spf1 include:amazonses.com ~all) |
| CNAME | resend._domainkey.mail.sonandigital.com |
DKIM public key (provided by Resend dashboard) |
| TXT | _dmarc.sonandigital.com |
v=DMARC1; p=none; rua=mailto:dmarc@sonandigital.com |
Alternatives Considered
Alternative A: SendGrid
SendGrid (now part of Twilio) is one of the most widely deployed transactional email services.
Rejected because: - Complex setup โ SendGrid's onboarding requires navigating a large, legacy dashboard with many features that are irrelevant to the CRM's use case - API complexity โ SendGrid's API has multiple versions with different endpoint patterns; its email template system (Dynamic Templates) uses Handlebars, not React, requiring a separate template editing workflow - Cost โ SendGrid's pricing starts at $19.95/month for meaningful features; Resend's free tier (3,000 emails/month) covers the CRM's volume entirely, with a $20/month plan covering 50,000/month - No React Email native integration โ SendGrid templates require a separate authoring workflow; React Email + Resend is a unified DX
Alternative B: AWS SES (Simple Email Service)
Amazon SES is extremely cost-effective at high volume ($0.10 per 1,000 emails) and highly reliable.
Rejected because: - Setup complexity โ SES requires AWS account setup, IAM policy configuration, domain verification in the AWS console, and production access request (SES accounts start in a "sandbox" with sending restrictions) - No React Email SDK โ using SES with React Email requires additional glue code to compile React Email templates to HTML and call the SES API with the raw HTML - Operational overhead โ bounce and complaint handling must be configured via SNS topics; unhandled bounces can result in account suspension - At the CRM's email volume, the cost difference between SES and Resend is negligible (both are effectively free at < 3,000 emails/month)
Alternative C: Nodemailer + SMTP
Using Nodemailer with an SMTP relay (e.g., Gmail Workspace SMTP, Mailgun SMTP) directly from the application.
Rejected because:
- Not compatible with edge runtime โ Nodemailer requires Node.js net/tls modules which are not available in the Vercel Edge Runtime; the CRM uses export const runtime = 'edge' on all routes
- Reliability โ SMTP relays have less reliable deliverability than purpose-built transactional email services with dedicated IP infrastructure
- No API โ no programmatic access to delivery status, bounce data, or analytics
- Credential management โ SMTP credentials must be rotated and managed manually
Alternative D: Postmark
Postmark is a well-regarded transactional email service known for excellent deliverability.
Evaluated but not selected because: - No native React Email integration (would require compiling templates separately) - Pricing is higher than Resend at low volume ($15/month for 10,000 emails vs Resend's free tier for 3,000/month) - Resend's API and DX were preferred by the team; feature parity is sufficient for the CRM's needs
Consequences
Positive:
- React Email integration โ email templates are written as React components in the same codebase, with TypeScript type checking, props validation, and component reuse (e.g., a shared EmailLayout component)
- Edge runtime compatible โ Resend's SDK uses the Fetch API, not Node.js net/tls; it works in the Vercel Edge Runtime
- Simple API โ a single resend.emails.send() call with typed parameters; no template ID lookup, no separate template management dashboard
- Good deliverability โ Resend uses shared IP pools with warm reputation; SPF/DKIM/DMARC setup is well-documented and takes < 30 minutes
- Free tier โ 3,000 emails/month on the free tier covers the CRM's current volume; upgrade path is straightforward
- Dashboard โ email delivery logs, open/click tracking (if enabled), and bounce management in the Resend dashboard
Negative: - Newer platform โ Resend launched in 2023; it has less of a track record than SendGrid or SES (mitigated: it is built on AWS SES infrastructure) - 3,000/month free tier limit โ if email volume exceeds 3,000/month, a paid plan is required ($20/month for 50,000 emails โ acceptable) - No bulk email features โ Resend is designed for transactional email, not marketing campaigns; if SONAN DIGITAL ever needs bulk marketing email, a separate platform (Mailchimp, ConvertKit) would be needed
React Email includes a local development server for previewing templates:
npx email dev
This renders all templates in emails/ at http://localhost:3000 with live reload. Always preview templates in the email dev server before deploying, as email client rendering differs significantly from web browser rendering.
Future Considerations
If email volume grows significantly (> 50,000/month), evaluate whether Resend's pricing remains competitive with AWS SES at scale. At very high volumes, a move to SES with custom React Email compilation may be cost-effective.
If marketing email campaigns are needed, a dedicated marketing email platform (not Resend) should be evaluated โ Resend is intentionally a transactional email tool.
Related
ADR-010: Customer Solutions as Standalone Deployments
Status: Accepted Date: 2026-06-30 Deciders: SONAN DIGITAL Engineering
Context
SONAN DIGITAL delivers digital products to clients: websites, web applications, e-commerce stores, portals, and custom tools. During the design of the CRM, the question arose of how these client deliverables relate to the CRM's deployment.
An early proposal was to serve client websites as sub-routes of the CRM (e.g., crm.sonandigital.com/client-sites/acme-corp/), or to use the CRM as a hosting platform for simple client sites. This would have reduced the number of deployments to manage and allowed the CRM to serve as a unified access point.
This ADR complements ADR-001, which addresses the conceptual scope separation. This ADR addresses the deployment and infrastructure implications of that decision.
Decision
Every customer solution (website, app, portal, or tool) built by SONAN DIGITAL for a client is deployed as a completely standalone deployment:
- Separate Git repository โ each customer solution has its own repository, owned by SONAN DIGITAL (transferred to client on project completion if contractually agreed)
- Separate hosting โ deployed to the appropriate platform for that client's technology: Vercel (Next.js), Netlify (static), Cloudflare Pages (static), WP Engine (WordPress), Shopify (e-commerce), etc.
- Separate domain โ served from the client's own domain or subdomain
- No runtime dependency on the CRM โ the deployed client site does not call any CRM API, does not share the CRM's Supabase instance, and does not import from the CRM codebase
- Independent CI/CD โ the client site's build and deploy pipeline is entirely separate from the CRM's
The CRM may contain records about customer solutions (project records, deployment entries in the deployments module โ see ADR-013), but it does not host or serve them.
Deployment Topology
graph TD
subgraph CRM ["CRM (sonan-digital)"]
CRMAPP[crm.sonandigital.com\nNext.js on Vercel]
CRMDB[(Supabase\nCRM Database)]
CRMAPP --> CRMDB
end
subgraph ClientA ["Client A โ Acme Corp"]
CA[acmecorp.com\nNext.js on Vercel]
CADB[(Acme DB\nSeparate Supabase)]
CA --> CADB
end
subgraph ClientB ["Client B โ Bravo Ltd"]
CB[bravostore.com\nShopify]
end
subgraph ClientC ["Client C โ Charlie Co"]
CC[charliesite.com\nWordPress on WP Engine]
end
CRMAPP -.->|Project record only\n(no runtime link)| CA
CRMAPP -.->|Project record only\n(no runtime link)| CB
CRMAPP -.->|Project record only\n(no runtime link)| CC
Alternatives Considered
Alternative A: Host Client Sites as Sub-Routes of the CRM
Client websites served at /client-sites/{client-slug}/* from the CRM's Next.js application.
Rejected because: - Single point of failure โ a CRM deployment failure or outage would take down all client websites simultaneously; clients' public-facing sites cannot be dependent on an internal agency tool - Technology mismatch โ client solutions are built in different technologies (WordPress, Shopify, plain HTML, various React frameworks); it is not feasible to serve all of these from a single Next.js application - Security risk โ admin routes, service role credentials, and internal CRM logic would live in the same deployment as client-facing public pages; the attack surface is dramatically larger - Performance impact โ high-traffic client sites could consume Vercel bandwidth and compute that degrades CRM performance - Build time โ every client site update would require a CRM rebuild; at scale, build times become unacceptable - No client independence โ when a project ends and the client takes ownership of their site, there is no clean way to extract it from the CRM deployment
Alternative B: CRM as a Shared Headless CMS Backend
Client sites call the CRM's API to fetch their content, with the CRM acting as a headless CMS backend.
Rejected because: - Runtime coupling โ client sites fail if the CRM is down or slow - Auth complexity โ client sites would need a separate auth mechanism to call the CRM API; this crosses the tenant isolation boundary - Wrong tool โ the CRM is a business management tool, not a content management system; using it as a CMS would add unintended complexity to the CRM schema and API - For clients who need a headless CMS, a purpose-built solution (Contentful, Sanity, Payload CMS) is used in the client solution's own stack
Alternative C: Shared Supabase Project for CRM and Client Data
Use the same Supabase project for both CRM data and the client's application database, with separate schemas or tenant isolation.
Rejected because: - Security boundary โ the CRM's Supabase project has service role access to all tenant data; sharing this project with a client's public-facing application is a significant security risk - Connection pool exhaustion โ high-traffic client applications could consume all available connections from the shared pool, degrading CRM performance - Schema coupling โ changes to the CRM schema could accidentally affect client application queries - Client ownership โ when the project ends, there is no clean way to hand over the database to the client without migrating data to a new project
Consequences
Positive: - CRM uptime independent of client sites โ a CRM outage does not affect clients' public-facing websites - Client site uptime independent of CRM โ a client site having issues does not affect the CRM - Technology freedom โ each client solution is built with the technology appropriate for their needs, not constrained to the CRM's stack - Clean project handover โ when the project ends, the client receives a standalone repository and hosting account; no extraction or migration needed - Security isolation โ the CRM's service role key, tenant data, and internal logic are never exposed to client site code or infrastructure - Independent scaling โ a high-traffic client site can be scaled independently without affecting CRM resources
Negative: - Multiple deployments to manage โ each active client project is a separate deployment to monitor, update (security patches), and support - No single pane of glass โ there is no unified dashboard showing both CRM operational metrics and client site performance (Google Analytics/Vercel Analytics on the client sites, Sentry on the CRM โ separate tools) - Onboarding overhead โ starting a new client project requires creating a new repository, configuring CI/CD, setting up the hosting account โ this is mitigated by project templates
Use the SONAN DIGITAL project starter templates for common client tech stacks to reduce the per-project setup overhead:
- sonan-nextjs-starter โ Next.js 15, Supabase, Tailwind, Vercel
- sonan-static-starter โ Astro, Cloudflare Pages, Tailwind
Future Considerations
If SONAN DIGITAL builds a white-label client portal product (where clients manage their own users, content, or orders), that would warrant its own platform architecture โ separate from both the CRM and the client-site pattern described here.
Related
- ADR-001 โ Conceptual scope separation (CRM vs. deliverables)
- ADR-013 โ Tracking deployment artifacts in the CRM Projects module
- Architecture Overview
ADR-011: Documents Module for Both Client and Internal Files
Status: Accepted Date: 2026-06-30 Deciders: SONAN DIGITAL Engineering
Context
Two distinct file storage needs emerged during the CRM's development:
- Client-facing document sharing โ sharing signed contracts, proposal PDFs, reports, deliverables, and invoices with clients through the client portal
- Internal document storage โ storing internal operational files (vendor agreements, internal reports, compliance documents, team reference materials) that clients should not see
The initial design of the Documents module focused on client-facing document sharing. As the CRM expanded, the team identified the need for internal document storage as well. The question was whether to build a separate "Internal Files" module or extend the existing Documents module.
Decision
The Documents module is extended to serve both purposes through a visibility scoping mechanism: each document record has a visibility field that determines whether it appears in the client portal.
visibility value |
Who can see it | Where it appears |
|---|---|---|
client |
The linked client + all admins | Admin Documents view + Client Portal documents tab |
internal |
Admins only | Admin Documents view only (never exposed to client portal) |
A single module, single table (documents), and single Supabase Storage bucket handles both use cases. The client portal query filters to visibility = 'client' before returning results.
Schema (Relevant Fields)
CREATE TABLE documents (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
client_id UUID REFERENCES clients(id),
uploaded_by UUID REFERENCES users(id),
name TEXT NOT NULL,
file_path TEXT NOT NULL, -- Supabase Storage path
file_size INTEGER,
mime_type TEXT,
visibility TEXT NOT NULL DEFAULT 'internal'
CHECK (visibility IN ('client', 'internal')),
description TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
Access Control
RLS policies on the documents table enforce:
- Admins: can read/write all documents where tenant_id matches their tenant
- Client portal users: can only read documents where visibility = 'client' AND client_id matches their linked client
-- Client portal users: only client-visible documents for their client
CREATE POLICY "client_portal_documents" ON documents
FOR SELECT
USING (
visibility = 'client'
AND client_id = (
SELECT client_id FROM users WHERE id = auth.uid()
)
);
Alternatives Considered
Alternative A: Separate "Internal Files" Module
Build a completely separate module (/admin/internal-files) with its own table, its own Supabase Storage bucket, and its own UI.
Rejected because: - Duplication โ the UI for uploading, listing, filtering, downloading, and deleting files would be nearly identical between the two modules; maintaining two separate implementations doubles the maintenance burden - No shared context โ documents often relate to a specific client regardless of visibility; a signed NDA (client-visible) and a related internal compliance checklist (internal-only) both relate to the same client record; separate modules make this relationship harder to model - User confusion โ team members would need to remember two different places to upload files, with no clear rule for which to use
Alternative B: Visibility as a Separate "Shared" Flag
Keep the Documents module client-facing only, and add a is_shared boolean to other modules (e.g., a is_shared flag on proposal records, invoice records, etc.) to control client visibility.
Rejected because: - Fragmented storage โ document-type files scattered across multiple tables (proposals, invoices, documents) with no unified search or listing - Inconsistent UX โ the file management UI would differ depending on what the file is attached to - Harder to query โ a "show me all files related to client X" query would require UNION across multiple tables
Alternative C: Two Supabase Storage Buckets
One bucket (documents-client) for client-visible files, one bucket (documents-internal) for internal files, with separate tables.
Rejected because: - Bucket-level separation provides no benefit over row-level visibility scoping in a single table - RLS policies already control access at the row level; the bucket distinction would be redundant - Adds operational complexity (two bucket policies to manage, two upload paths in the UI)
Consequences
Positive:
- Single module to maintain โ one UI, one table, one storage bucket, one set of RLS policies
- Unified search and filtering โ admins can search across all documents (client-visible and internal) in one place
- Client context preserved โ both client-facing and internal documents can be linked to the same client_id, keeping the relationship intact
- Easy default โ new document uploads default to visibility = 'internal'; a deliberate action is required to make a document client-visible (reducing accidental exposure)
- Extensible โ additional visibility values can be added in the future (e.g., 'team' for documents visible to all employees but not admins) without schema changes
Negative: - Single point of failure โ a bug in the visibility filter could expose internal documents to clients; this risk is mitigated by RLS enforcement at the database level (the RLS policy, not just the application query, enforces visibility) - Storage bucket is shared โ if a signed URL for an internal document were obtained (e.g., by a compromised admin account), it could be shared with a client; Supabase Storage signed URLs expire (configurable, default 1 hour) to limit this window
Any query to the documents table from the client portal MUST include .eq('visibility', 'client'). The RLS policy enforces this as a backstop, but defence-in-depth requires the application query to filter explicitly.
Future Considerations
If document management requirements grow significantly (version history, collaborative editing, folder structures, granular permissions), a dedicated document management platform (e.g., Google Drive integration, SharePoint, or Notion) may be worth evaluating. The current lightweight implementation is sufficient for the agency's current scale.
Related
- ADR-007 โ RLS policy patterns
- Architecture Overview
- Database Schema
ADR-012: Lightweight Client Cost Tracking in the CRM
Status: Accepted Date: 2026-06-30 Deciders: SONAN DIGITAL Engineering
Context
SONAN DIGITAL manages advertising budgets and vendor costs on behalf of clients. Account managers needed visibility into what had been spent per client, per category, per period โ to reconcile costs against client retainer agreements, identify over-spend, and report cost breakdowns in client reviews.
The options were:
- Continue using external spreadsheets (the existing approach at the time)
- Integrate with a full accounting platform
- Build a lightweight ledger inside the CRM
The existing spreadsheet approach had clear pain points: data was disconnected from client records, multiple account managers had different spreadsheet formats, historical data was hard to query, and there was no audit trail of who entered what cost.
Decision
A lightweight client cost tracking module is built directly into the CRM, implemented as a simple ledger table linked to the clients table.
Schema
CREATE TABLE client_costs (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
client_id UUID NOT NULL REFERENCES clients(id),
category TEXT NOT NULL, -- e.g. 'Ad Spend', 'Design Tools', 'Hosting'
amount NUMERIC(12,2) NOT NULL,
currency TEXT NOT NULL DEFAULT 'GBP',
cost_date DATE NOT NULL,
description TEXT,
vendor TEXT, -- e.g. 'Google Ads', 'Adobe', 'Vercel'
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT now()
);
Features
- Per-client cost log โ all costs for a client displayed chronologically in the client detail view
- Category filtering โ filter by category (Ad Spend, Design Tools, Hosting, Freelancer, etc.)
- Date range filtering โ filter by period for monthly or quarterly reporting
- CSV export โ export the filtered cost log as a CSV for inclusion in client reports
- Internal visibility only โ costs are never shown in the client portal;
visibilityis implicit (admin-only module, no client portal exposure) - Audit trail โ
created_bytracks which team member logged each cost
What It Is Not
The client cost tracking module is not an accounting system. It does not:
- Sync with bank accounts or payment processors
- Generate journal entries or trial balances
- Handle accounts receivable or payable beyond what the Invoicing module covers
- Replace the agency's accounting software (e.g., Xero, QuickBooks) for statutory accounts
Alternatives Considered
Alternative A: External Spreadsheet (Status Quo)
Continue tracking costs in shared Google Sheets or Excel files, one per client or one combined.
Rejected because: - Disconnected from client records โ no link between a cost entry and the CRM client record; reconciliation is manual - No consistent structure โ different account managers used different column names, currencies, and date formats - No audit trail โ no record of who entered a cost or when; spreadsheets can be edited without history - No filtering or querying โ filtering by date range, category, or vendor in a spreadsheet is clumsy compared to a database query - Version control โ multiple versions of spreadsheets exist simultaneously; "which is the current one?" is a recurring question - Access control โ sharing spreadsheets with the right people (and not others) is manual and error-prone
Alternative B: Full Accounting Integration (Xero, QuickBooks)
Integrate the CRM with Xero or QuickBooks via their APIs to pull cost data directly into the CRM view.
Rejected because: - Scope mismatch โ the need is for a per-client cost log visible to account managers in the CRM context; full accounting API integration introduces significant complexity (OAuth flows, API rate limits, data mapping between accounting entities and CRM entities) - Not all costs are in the accounting system โ some ad spend costs are tracked manually from platform reports (Google Ads, Meta Ads Manager), not as purchase invoices in the accounting system; an integration would not capture these - Accounting software access โ not all account managers have (or should have) access to the agency's accounting software; the CRM cost module provides a safe, role-scoped view - Over-engineering โ the agency's cost tracking need is relatively simple; building and maintaining a bi-directional accounting integration is significant engineering effort for marginal benefit at current scale
Alternative C: Dedicated Cost Management Tool (e.g., Spendesk, Ramp)
Use a dedicated expense management or cost tracking SaaS product.
Rejected because: - Another vendor to manage โ account managers would need to context-switch between the CRM (for client info, projects) and a separate cost tool - No client context โ expense tools are designed for employee expenses, not per-client cost attribution - Cost โ adds a monthly per-seat fee for a capability that can be built as a simple table in the CRM - Data silo โ costs in a separate tool cannot be correlated with invoices, project timelines, or client status in the CRM without manual export/import
Consequences
Positive:
- Costs in context โ account managers see cost data alongside client details, project status, and invoices in one screen
- No additional vendor โ no additional SaaS subscription, no OAuth integration to maintain, no data sync to worry about
- Queryable data โ standard SQL queries for monthly summaries, category breakdowns, and client comparisons
- CSV export โ sufficient for inclusion in client-facing reports without building a custom reporting UI
- Audit trail โ created_by and created_at fields track who logged what and when
Negative:
- Not a replacement for accounting software โ the agency still needs its accounting software (Xero, etc.) for statutory accounts; costs entered in the CRM do not flow into the accounting system automatically
- Manual entry required โ costs must be entered by hand; there is no automatic import from Google Ads, Meta Ads Manager, or vendor invoices (a future enhancement could address this)
- No approval workflow โ any admin can log a cost for any client; there is no spend approval or budget limit enforcement (acceptable at current team size)
- Currency handling is simple โ multi-currency costs are stored with a currency field but totals are not automatically converted; account managers must note the currency when filtering or exporting
Use consistent category names to ensure filters and reports are meaningful. Recommended categories:
| Category | Examples |
|---|---|
| Ad Spend | Google Ads, Meta Ads, LinkedIn Ads |
| Design Tools | Adobe CC, Figma, Canva Pro |
| Hosting & Infrastructure | Vercel, AWS, Cloudflare, Supabase |
| Freelancer | Design contractor, copywriter, developer |
| Software & Subscriptions | Any SaaS purchased specifically for a client |
| Other | Anything that doesn't fit the above |
Future Considerations
If the agency grows and cost tracking needs become more sophisticated (budget limits per client, approval workflows, automated import from ad platforms), the following enhancements should be evaluated:
- Google Ads API and Meta Marketing API integration for automatic ad spend import
- Monthly budget alerts (notify if cost log total exceeds a configured budget for the month)
- Approval workflow for costs above a threshold
If the volume of cost data grows significantly, consider a materialized view or summary table for performance on the per-client cost summary query.
Related
ADR-013: Project Deployment Artifact Tracking
Status: Accepted Date: 2026-06-30 Deciders: SONAN DIGITAL Engineering
Context
SONAN DIGITAL's Projects module tracks client projects from initiation through delivery. As projects mature, they go through deployment cycles โ releasing code to staging environments for client review, then to production. The team identified a recurring pain point: there was no structured record of what was deployed, to where, and when.
This information was scattered across Vercel deployment logs, GitHub releases and tags, Slack messages, and email threads between the team and client. When a client asks "what changed in last week's release?" or "is the fix live on production yet?", answering required checking multiple external systems. When a new team member joined a project, they had no historical record of deployment milestones.
Decision
A deployments table is added to the CRM, linked to the projects table. Deployment records are created manually by the engineer or account manager when a deployment occurs.
Schema
CREATE TABLE project_deployments (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
project_id UUID NOT NULL REFERENCES projects(id),
environment TEXT NOT NULL CHECK (environment IN ('development', 'staging', 'production')),
url TEXT,
version TEXT,
deployed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deployed_by UUID REFERENCES users(id),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
Each record captures: environment (development / staging / production), the live URL, a version string (semver, date, commit SHA โ freeform), the timestamp, who deployed it, and release notes describing what changed.
Deployments appear on a dedicated "Deployments" tab on the project detail page, filterable by environment. Records are internal-only by default.
Alternatives Considered
External Changelog Document
Maintain a shared changelog document (Google Doc, Notion page) per project.
Rejected because: disconnected from project records; access control must be managed separately; cannot be queried across projects; no CRM notification integration.
GitHub Releases
Use GitHub Releases as the canonical deployment record.
Rejected because: not all projects use GitHub (Shopify themes, WordPress plugins, other platforms have no GitHub releases); account managers and clients do not have GitHub access; staging deployments are not suited to GitHub Releases; no CRM integration without custom webhook work.
Vercel Deployment Webhooks
Automatically capture deployment records via Vercel webhooks.
Not selected as primary approach because: only covers Vercel-hosted projects; integration complexity is not justified when manual logging takes seconds; can be added later as an enhancement layer on top of the existing schema without schema changes.
Embedded Timeline Notes
Add deployment notes as a special entry in a project activity feed rather than a dedicated table.
Rejected because: deployments have structured data (environment, URL, version) that does not fit a generic notes model; dedicated table enables environment-filtered views and structured queries not achievable from free-text search.
Consequences
Positive:
- Single source of truth for deployment history linked to the CRM project and client
- Environment distinction makes it clear what clients can access vs. what is in progress
- Freeform version field accommodates semver, date-based, or commit-SHA versioning without schema changes
- No external tool required โ accessible to anyone with CRM access
- Notification hooks can fire when a production deployment is logged
Negative: - Manual entry โ engineers must remember to log deployments after each release - No automatic sync โ deployments made without CRM logging create gaps in the record - Limited audit depth โ no link to the specific diff or build log, only the version string and notes provided by the engineer
A useful note answers: what features or fixes are included? Any breaking changes? Anything the client needs to test or configure post-deployment?
> **v2.4.0 โ Production**
> Added: Contact form with email notification. Fixed: Mobile menu on iOS Safari.
> Client should test the contact form and confirm email delivery to their inbox.
Vercel webhook integration can be added as a convenience layer โ automatically creating deployment records for Vercel-hosted projects. The existing schema is already compatible with this; the deployed_by would be a system user and notes would be populated from the Vercel deployment message.
Future Considerations
- Vercel webhook integration for automatic deployment logging on Vercel-hosted projects
- Client portal visibility for deployment notes (release milestone communication)
statusfield to track whether a deployment is currently live or has been rolled back- Rollback tracking linking a deployment to the one it superseded
Related
- ADR-001 โ Customer solutions are separate deployments
- ADR-010 โ Standalone deployment architecture
- Architecture Overview
- Database Schema