CRM and operations platform for PauseAI Global. Built with Next.js 16 (App Router), PostgreSQL, Drizzle ORM, Graphile Worker. Supports multi-tenancy via workspaces (Global + chapter workspaces).
- API docs: See docs/api-reference.md for all endpoints, request/response schemas, and auth requirements
- DB schemas:
src/db/schema/*.ts(Drizzle ORM, PostgreSQL) - API validation schemas:
src/lib/schemas/*.ts(Zod — source of truth for request validation) - Route handlers:
src/app/api/**/route.ts - Business logic:
src/lib/*.ts - Connectors:
src/lib/connectors/(Airtable, Notion, Demo) - Sync engine:
src/lib/sync-engine.ts - Email connections schema:
src/db/schema/email-connections.ts(email_connections + email_contact_settings tables) - Gmail client:
src/lib/gmail.ts(OAuth, message fetching, address parsing) - Encryption:
src/lib/encryption.ts(AES-256-GCM),src/lib/credentials-encryption.ts(connection credential encrypt/decrypt) - Email event processing:
src/lib/email-events.ts(shared webhook/sandbox event logic) - Sandbox schema:
src/db/schema/sandbox-emails.ts(sandbox_emails table) - Background workers:
src/worker/(Graphile Worker) - UI components:
src/components/(React + shadcn/ui) - Shared field mapper:
src/lib/field-mapper-utils.ts(types + conversion),src/components/field-mapper.tsx(UI),src/lib/hooks/use-crm-fields.ts(hook) - Type-aware field editor:
src/components/field-value-editor.tsx— renders appropriate input control based on field type (select, multiselect, tags, boolean, number, date, text) - Workspace design: See docs/specs/workspaces.md for the multi-tenancy specification
- Dev log:
DEVLOG.md— reverse-chronological session log (updated via/wrapup) - Bug tracker:
BUGS.md— security audit findings and fix status
The app supports multiple workspaces: one global workspace (PauseAI Global) and chapter workspaces (e.g., Pause IA France). Key concepts:
- Workspace context: Determined by cookie (
pauseai_workspace), header (X-Workspace-Id), or query param. Server components usegetServerWorkspaceId(), client components useuseWorkspace()/useWorkspaceFetch(). - Contacts: Exist once globally, linked to workspaces via
contact_workspacesjunction table. A workspace only sees its own contacts. - Effective role:
max(global role, workspace role)— computed byuseEffectiveRole()(client) orgetEffectiveRole()(server). A user with global "member" role but workspace "admin" role is an admin in that workspace. - Workspace-scoped entities: Tags, segments, campaigns, communication categories, connections, custom fields (scope: core/global_internal/workspace), user memberships, API keys, automations (scripts + rules).
- Workspace provider:
src/components/workspace-provider.tsx— providesactiveWorkspace,useWorkspaceId(),useWorkspaceFetch()(auto-injectsX-Workspace-Idheader). - Server-side workspace:
src/lib/workspace-server.ts—getServerWorkspaceId()reads from cookies,isServerWorkspaceGlobal(). - API workspace context:
src/lib/workspace-context.ts—getActiveWorkspaceId(request)reads from header/query/cookie.
- Google OAuth via NextAuth (
src/lib/auth.ts) - Dev login with Credentials provider (development only) — preset users + custom email form with workspace selector
- API keys:
Authorization: Bearer pai_<key>(src/lib/api-auth.ts) — workspace-scoped, use creator's actual role (not hardcoded). Holder passesX-Workspace-Idper-request, effective role checked same as session auth - Admin role from
ADMIN_EMAILSenv var - Two-layer roles: global role (users table) + workspace role (user_workspaces table)
npm run dev— dev server with Turbopacknpm run test— run tests (Vitest)npm run build— production buildnpm run worker— background job workernpm run db:migrate— run migrationsnpm run db:seed— seed default field definitionsnpm run docs:api— regenerate API docs from Zod schemas
- All API validation uses Zod schemas in
src/lib/schemas/ - Error format:
{ error: string, details?: string[] } - Route handlers use
validateBody()fromsrc/lib/api-validate.ts - Tests required for all backend features (
src/lib/__tests__/) - Client-side API calls MUST use
useWorkspaceFetch()to include workspace context header - Communication preference keys are namespaced:
workspaceId:categoryName - Segment tag conditions use operator
has/not_has(noteq). Emptiness checks:is_set/is_not_set(canonical) oris_not_empty/is_empty(aliases) - Email template merge variables (
{{firstName}}, etc.) are HTML-escaped byrenderTemplate()insrc/lib/mailersend.ts— never render user data as raw HTML - Workspace switching triggers
window.location.reload()— don't use refs to detect changes; check entity workspace ownership after fetch instead - Connections UI lives at
/dashboard/connections(top-level sidebar, admin-only), not under Settings - When using
stripNulls()in API routes, extract nullable fields that carry meaning (likesegmentId,categoryId) before stripping - Email connections are user-scoped (not workspace-scoped like Airtable/Notion connections). Imported contacts belong to the active workspace.
- Email interaction visibility: a user's own synced emails are always visible to them; other users only see interactions where
visible_to_team = true - OAuth tokens for email connections are encrypted at rest with AES-256-GCM (
EMAIL_ENCRYPTION_KEYenv var) - Connection credentials (Airtable API keys, Notion tokens) are also encrypted at rest using
src/lib/credentials-encryption.ts(sameEMAIL_ENCRYPTION_KEY) - Webhook endpoints (Mailersend, Tally) require HMAC signature verification — set
MAILERSEND_WEBHOOK_SIGNING_SECRETandTALLY_WEBHOOK_SIGNING_SECRETenv vars - Contact API endpoints (
GET/PUT /api/contacts/:id) enforce workspace scoping — non-admin users can only access contacts linked to their active workspace - Categorized campaigns enforce unsubscribe mechanism:
sendCampaign()refuses to send unlessallowNoUnsubscribeistrueon the campaign record. The UI warns at save-time and sets the flag only with explicit user acknowledgment - Sandbox mode:
EMAIL_MODE=sandbox(default if unset) intercepts all outbound email insendEmail()and writes tosandbox_emailstable instead of calling Mailersend.EMAIL_MODE=livesends real email. All email-sending paths MUST go throughsendEmail()insrc/lib/mailersend.ts - Sandbox API endpoints (
/api/sandbox/*) only function whenEMAIL_MODE=sandbox; they return 404 in live mode. All require admin role - Sandbox event simulation (
POST /api/sandbox/emails/:id/simulate) reuses the sameprocessEmailEvent()logic as the Mailersend webhook handler — tests the real code path
At the end of every coding session, run /wrapup. This updates the dev log (DEVLOG.md), syncs all documentation, and commits. If the user seems to be wrapping up (e.g., "let's commit and push", "I think that's it for today"), suggest running /wrapup before ending.