Modular Platform Architecture
We're building a reusable, modular backend platform that lets us ship multiple software products on top of shared infrastructure. Instead of rebuilding auth, permissions, billing, and data layers from scratch for every new product, each product is an independent deployment cell that picks the modules it needs from a common codebase. The result is faster time-to-market, consistent security posture, and one team maintaining the core instead of many teams reinventing it.
- ◆ Cell-based isolation — every product runs in its own hermetically sealed deployment cell; a failure in one can never cascade to another.
- ◆ Pick-your-modules — Auth, IAM, CRM, Tasks, Documents, Finance, Notifications, and more are all independent modules a product can opt into at deploy time.
- ◆ Enterprise-grade AWS infrastructure — Aurora PostgreSQL, ECS Fargate, ElastiCache Redis, CloudFront, Secrets Manager, and full CDK IaC across multi-account environments.
- ◆ Zero-trust security by default — Zanzibar-style permissions, row-level security, HMAC-signed webhooks, MFA/TOTP, and scoped API keys baked into the core.
- ◆ Phases 1–3 complete, live on AWS — full backend, all modules, and cloud infrastructure are built and deployed. Phase 4 (frontend) is next.
I. Foundational Design Philosophy
System Overview
┌─────────────┐
│ CloudFront │
│ (CDN) │
└──────┬──────┘
│
┌────────────┴────────────┐
│ │
┌────────▼────────┐ ┌──────────▼──────────┐
│ Frontend App │ │ API Gateway │
│ (Next.js/React)│ │ (Auth, Rate Limit) │
└─────────────────┘ └──────────┬───────────┘
│
┌────────────┴────────────┐
│ Core API Service │
│ (NestJS) │
│ │
│ ┌─────────────────────┐ │
│ │ Module: Auth │ │
│ │ Module: Teams │ │
│ │ Module: Tasks │ │
│ │ Module: Contacts │ │
│ │ Module: Documents │ │
│ │ Module: Leads │ │
│ │ Module: Notes │ │
│ │ Module: Notifs │ │
│ │ Module: Finance │ │
│ └─────────────────────┘ │
└────────────┬─────────────┘
│
┌──────────────────┼──────────────────┐
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌───────▼──────┐
│ PostgreSQL │ │ Redis │ │ S3 / Files │
│ (Aurora) │ │ (Cache + │ │ │
│ │ │ Queues) │ │ │
└─────────────┘ └─────────────┘ └──────────────┘
1. Cell-Based Architecture
Google's approach to shared-infrastructure isolation.
Every deployment is a "cell" — an independent, hermetically sealed unit containing the full stack. Each product is a cell. Cells share nothing at runtime but share the same codebase modules. A failure in one cell cannot cascade to another.
2. Zero Trust Security Model
Based on NIST 800-207.
No implicit trust. Every request is authenticated, authorized, and encrypted — even internal service-to-service calls. Network location (VPC, subnet) grants zero privilege. Identity is the only perimeter.
3. AWS Multi-Account Isolation
AWS Well-Architected Framework.
Separate AWS accounts are hard security boundaries. Each concern gets its own blast radius. You don't share accounts between products.
II. AWS Multi-Account Strategy
Account Topology
AWS Organization (Root)
│
├── Management Account (billing, SCPs, Organization policies ONLY)
│ └── No workloads ever run here
│
├── OU: Security
│ ├── Security Tooling Account
│ │ ├── GuardDuty delegated admin
│ │ ├── Security Hub aggregator
│ │ ├── CloudTrail organization trail (immutable S3)
│ │ ├── AWS Config aggregator
│ │ └── IAM Access Analyzer
│ │
│ └── Log Archive Account
│ ├── Centralized CloudWatch Logs
│ ├── CloudTrail logs (write-once, read-many)
│ ├── VPC Flow Logs & S3 access logs
│ └── Retention: 7 years (compliance)
│
├── OU: Shared Services
│ ├── Network Hub Account
│ │ ├── Transit Gateway (hub-and-spoke)
│ │ ├── Route 53 Hosted Zones
│ │ ├── AWS Certificate Manager
│ │ └── VPN / Direct Connect termination
│ │
│ ├── Shared Services Account
│ │ ├── ECR (container registry)
│ │ ├── Artifact stores (npm, pip)
│ │ ├── Cognito / Keycloak (IdP)
│ │ └── Secrets Manager
│ │
│ └── CI/CD Account
│ ├── GitHub Actions self-hosted runners
│ ├── CDK Pipelines (deploys via cross-account roles)
│ └── Artifact signing (cosign / Sigstore)
│
├── OU: Workloads
│ ├── Product A — Dev / Staging / Prod (3 accounts)
│ ├── Product B — Dev / Staging / Prod (3 accounts)
│ └── ... (each new product gets 3 accounts)
│
└── OU: Sandbox
└── Developer Sandbox Accounts
Why This Matters
- Blast radius isolation — a misconfigured IAM policy in Product A's dev cannot touch Product B's prod. Hard AWS boundary.
- Cost attribution — each product's AWS bill is isolated automatically.
- Compliance — Security OU locked with SCPs. Even root can't delete logs or disable GuardDuty.
- Modularity — new product = one CDK script → 3 accounts with standard config. Minutes.
Cross-Account Access Patterns
CI/CD Account Product A Prod Account
┌──────────────┐ ┌──────────────────────┐
│ CDK Pipeline │───AssumeRole──▶│ DeploymentRole │
│ │ (cross-acct) │ (ECS, RDS, S3 only) │
└──────────────┘ └──────────────────────┘
Shared Services Account Product A Prod Account
┌──────────────┐ ┌──────────────────────┐
│ Cognito │◀──────────────│ API Gateway validates │
│ (IdP) │ JWT issued │ JWT via JWKS endpoint │
└──────────────┘ └──────────────────────┘
III. Security Architecture
Defense in depth — five layers from edge to data.
Layer 1: Edge — CloudFront + WAF
Internet → CloudFront (TLS 1.3 only)
│
├── AWS WAF v2
│ ├── Managed Rules (OWASP Top 10)
│ ├── Rate limiting (2000 req/5min per IP)
│ ├── Geo-blocking
│ ├── Bot Control
│ └── Custom rules (SQLi, XSS)
│
└── AWS Shield Advanced (DDoS)
Layer 2: API Gateway
- Request validation (JSON Schema)
- Mutual TLS for service-to-service
- Usage plans + API keys for external consumers
- Request/response logging → Log Archive Account
- Lambda Authorizer or Cognito Authorizer
Layer 3: Application — Zero Trust Pipeline
Every request — even internal — goes through:
Request
→ TLS termination (ALB)
→ JWT verification (signature + expiry + audience + issuer)
→ Tenant extraction (org_id from token claims)
→ Permission evaluation (RBAC + ABAC)
→ Rate limiting (per-user, per-tenant, per-endpoint)
→ Input validation (Zod schemas, strict mode)
→ Audit logging (who, what, when, from where)
→ Business logic
→ Output sanitization (strip internal fields)
→ Response
Permission Model — Google Zanzibar
Relationship-based access control at scale. Permissions are stored as tuples and checks are graph traversals.
document:doc_123#viewer@user:alice
document:doc_123#editor@team:engineering#member
team:engineering#member@user:bob
org:acme#admin@user:carol
// "Can Bob edit doc_123?"
// → doc_123#editor includes team:engineering#member
// → team:engineering#member includes user:bob
// → YES
Use SpiceDB or OpenFGA (open-source Zanzibar). Every module calls the permission service. No module implements its own auth logic.
CREATE TABLE permission_tuples (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
namespace VARCHAR(100) NOT NULL,
object_id VARCHAR(200) NOT NULL,
relation VARCHAR(100) NOT NULL,
subject_ns VARCHAR(100) NOT NULL,
subject_id VARCHAR(200) NOT NULL,
subject_rel VARCHAR(100),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(namespace, object_id, relation, subject_ns, subject_id, subject_rel)
);
CREATE INDEX idx_perm_object ON permission_tuples(namespace, object_id);
CREATE INDEX idx_perm_subject ON permission_tuples(subject_ns, subject_id);
CREATE INDEX idx_perm_check ON permission_tuples(namespace, object_id, relation);
Layer 4: Data Security
Row-Level Security — even if app code has a bug, the database enforces tenant isolation:
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON tasks
USING (org_id = current_setting('app.current_org_id')::UUID);
-- Set on every DB connection:
SET app.current_org_id = 'org_xyz';
-- SELECT * FROM tasks only returns org_xyz's data
- Encryption: AES-256 at rest, TLS 1.3 in transit, app-level encryption for PII via AWS KMS (per-tenant keys)
- Data classification: columns tagged PUBLIC / INTERNAL / CONFIDENTIAL / RESTRICTED. Serializers auto-strip based on clearance.
Layer 5: Supply Chain Security
- Container images scanned with Trivy/Snyk on every build
- Dependency audit on every PR
- Image signing with cosign — ECS only runs signed images
- SBOM generation for every release
IV. Database Architecture
Aurora PostgreSQL — Serverless v2
| Feature | Benefit |
|---|---|
| 3-5x faster than standard PG | Rewritten storage engine, 6-way replication, parallel query |
| Serverless v2 | Scales 0.5 → 128 ACUs in seconds. Dev costs near zero. |
| Up to 15 read replicas | <20ms lag. Route dashboards/reports to replicas. |
| Global Database | Multi-region replication <1s lag |
| 100% PG compatible | Every extension, ORM, and tool works |
Write Path Read Path
│ │
▼ ▼
┌──────────────┐ ┌──────────────────┐
│ Aurora Writer │ │ Aurora Reader x3 │
│ (Primary) │ │ (Auto-scaling) │
└──────┬───────┘ └────────┬─────────┘
│ │
▼ ▼
┌──────────────────────────────────┐
│ Aurora Storage (distributed, │
│ 6-way replicated, auto-healing) │
└──────────────────────────────────┘
┌──────────────────────────────────────────┐
│ Redis Cluster │
│ Sessions │ Query Cache │ Rate Limits │
└──────────────────────────────────────────┘
Caching Strategy — Cache-Aside with Invalidation
async function getTask(taskId: string, orgId: string): Promise<Task> {
const cacheKey = `task:${orgId}:${taskId}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const task = await auroraReader.query(
'SELECT * FROM tasks WHERE id = $1 AND org_id = $2',
[taskId, orgId]
);
await redis.setex(cacheKey, 300, JSON.stringify(task));
return task;
}
async function updateTask(taskId, orgId, data) {
await auroraWriter.query(/* ... */);
await redis.del(`task:${orgId}:${taskId}`);
await eventBus.emit('task.updated', { taskId, orgId, changes: data });
}
Data Migration from Legacy Systems
Legacy System AWS
┌──────────────┐ ┌───────────────────────────┐
│ SQL Server │ │ DMS Replication Instance │
│ Oracle │──DMS ────▶│ ├── Full Load (bulk) │
│ MySQL 5.x │ (CDC) │ └── CDC (continuous) │
│ MongoDB │ │ │ │
└──────────────┘ │ ▼ │
│ ┌──────────────┐ │
│ │ Aurora PG │ │
│ └──────────────┘ │
└───────────────────────────┘
Migration Steps
- SCT — Schema Conversion Tool analyzes legacy schema, converts 90%+ to PG DDL automatically
- DMS Full Load — bulk copies all data (handles type conversions, encoding)
- DMS CDC — continuous replication while legacy still runs. Zero downtime.
- Validation — row counts + data integrity verification
- Cutover — flip DNS, stop CDC, legacy goes read-only
Non-Database Legacy Data (CSV, Excel, XML, APIs)
Source (S3 upload) → Step Function
├── Validate schema
├── Transform (dates, currencies, encodings)
├── Deduplicate
├── Map to target schema (configurable)
├── Batch insert into Aurora
├── Generate migration report
└── Notify (success/failure + row counts)
V. Application — Module System
Core Services (Always Present)
| Module | Responsibility |
|---|---|
| Auth | Signup, login, logout, password reset, MFA, OAuth, session management |
| Users | Profiles, preferences, avatars |
| Organizations | Multi-tenancy, org settings, billing tier |
| Teams | Create teams, add/remove members, team roles |
| Permissions | Zanzibar RBAC + ABAC, permission checks as middleware |
| Entity Links | Universal cross-module linking (any entity to any entity) |
| Notifications | In-app, email, push, webhooks (event-driven) |
| Files / Media | S3 upload/download, presigned URLs, file metadata |
| Audit Log | Immutable record of who did what, when (enterprise compliance) |
| Search | Full-text search via PG tsvector → OpenSearch when needed |
| Settings / Config | Feature flags, app config, per-tenant configuration |
Core Infrastructure Services
| Service | Technology | Purpose |
|---|---|---|
| Event Bus | SNS/SQS or Redis Streams | Modules communicate via events, not direct calls. When a task is created, an event fires and the notification module picks it up. |
| Job Queue | BullMQ (Redis-backed) | Background jobs: emails, report generation, data exports, scheduled tasks |
| Caching Layer | Redis (ElastiCache) | Session data, frequently accessed data, rate limiting counters |
| Logging & Monitoring | CloudWatch + structured JSON | Centralized, queryable logs. Optionally Datadog or Grafana. |
| Health Checks | Per-module /health | Every module exposes a health endpoint for load balancers and orchestration |
Monorepo Structure
platform/
├── packages/
│ ├── core/ # Shared kernel — NEVER optional
│ │ ├── auth/ # JWT, sessions, OAuth
│ │ ├── iam/ # Zanzibar permission engine
│ │ ├── tenancy/ # Org isolation, RLS
│ │ ├── events/ # Event bus abstraction
│ │ ├── storage/ # S3 abstraction
│ │ ├── notifications/ # Multi-channel engine
│ │ ├── audit/ # Immutable audit log
│ │ ├── search/ # Full-text search
│ │ ├── migrations/ # ETL framework
│ │ └── common/ # DTOs, validators, errors
│ │
│ ├── modules/ # Optional business modules
│ │ ├── teams/
│ │ ├── tasks/
│ │ ├── contacts/
│ │ ├── documents/
│ │ ├── notes/
│ │ ├── finance/
│ │ └── [custom]/
│ │
│ ├── sdk/ # Auto-generated TS SDK
│ └── ui/ # Shared UI components
│
├── apps/
│ ├── api/ # Deployable API server
│ ├── worker/ # Background jobs
│ └── web/ # Next.js frontend
│
├── infra/ # AWS CDK
│ ├── lib/
│ │ ├── account-baseline.ts
│ │ ├── networking.ts
│ │ ├── database.ts
│ │ ├── compute.ts
│ │ ├── cdn.ts
│ │ ├── security.ts
│ │ └── observability.ts
│ └── bin/
│ ├── deploy-shared-services.ts
│ └── deploy-product.ts
│
└── tools/
├── migrate/ # Data migration CLI
├── scaffold/ # New module generator
└── sdk-gen/ # OpenAPI → SDK
Module Registration
interface PlatformModule {
name: string;
version: string;
dependencies: string[];
onRegister(container: DependencyContainer): void;
onDatabaseSetup(migrator: Migrator): Promise<void>;
onPermissionsSetup(engine: PermissionEngine): void;
onEventsSetup(bus: EventBus): void;
onReady(): Promise<void>;
onShutdown(): Promise<void>;
}
Example: TasksModule Implementation
export class TasksModule implements PlatformModule {
name = 'tasks';
version = '1.0.0';
dependencies = ['core', 'teams'];
onRegister(container) {
container.register(TasksService);
container.register(TasksController);
container.register(TaskBoardsController);
}
onDatabaseSetup(migrator) {
return migrator.runModuleMigrations('tasks');
}
onPermissionsSetup(engine) {
engine.defineNamespace('task', {
relations: {
org: 'organization',
owner: 'user',
assignee: 'user | team#member',
viewer: 'user | team#member | org#member',
editor: 'user | team#member',
},
permissions: {
view: 'viewer + editor + owner + org->admin',
edit: 'editor + owner',
delete: 'owner + org->admin',
assign: 'editor + owner',
}
});
}
onEventsSetup(bus) {
bus.subscribe('team.member_removed', this.handleTeamMemberRemoved);
bus.subscribe('contact.deleted', this.handleContactDeleted);
}
}
Per-Product Configuration
import { TasksModule } from '@platform/modules/tasks';
import { ContactsModule } from '@platform/modules/contacts';
import { DocumentsModule } from '@platform/modules/documents';
import { NotesModule } from '@platform/modules/notes';
// import { FinanceModule } from '@platform/modules/finance';
export const platformConfig = {
modules: [
new TasksModule(),
new ContactsModule(),
new DocumentsModule(),
new NotesModule(),
// FinanceModule — not loaded, endpoints don't exist,
// DB tables not created, events not subscribed
],
aws: {
region: 'us-east-1',
accountId: process.env.AWS_ACCOUNT_ID,
},
database: {
writer: process.env.AURORA_WRITER_ENDPOINT,
reader: process.env.AURORA_READER_ENDPOINT,
},
features: {
enableRedlining: true,
enableKanban: true,
maxTeamSize: 50,
}
};
Cross-Module Entity Linking
Universal linking — any entity to any entity. This is the glue that lets teams, tasks, docs, leads all reference each other.
CREATE TABLE entity_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_type VARCHAR(50) NOT NULL,
source_id UUID NOT NULL,
target_type VARCHAR(50) NOT NULL,
target_id UUID NOT NULL,
relationship VARCHAR(50) NOT NULL,
org_id UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(source_type, source_id, target_type, target_id, relationship)
);
EntityLinkService Implementation
class EntityLinkService {
async link(params: {
source: { type: string; id: string };
target: { type: string; id: string };
relationship: string;
orgId: string;
actorId: string;
}) {
// 1. Verify both entities exist (calls respective module)
await this.verifyEntity(params.source);
await this.verifyEntity(params.target);
// 2. Check permission (actor must have 'link' permission on both)
await this.permissionEngine.check(params.actorId, 'link', params.source);
await this.permissionEngine.check(params.actorId, 'link', params.target);
// 3. Create the link
await this.repository.createLink(params);
// 4. Emit event (other modules can react)
await this.eventBus.emit('entity.linked', params);
// 5. Audit
await this.auditLog.record('entity.linked', params);
}
async getLinkedEntities(
source: { type: string; id: string },
targetType: string,
orgId: string
) {
return this.repository.findLinks(source, targetType, orgId);
}
}
Auto-Generated Endpoints
| Endpoint | Description |
|---|---|
POST /api/v1/links | Create a link between any two entities |
DELETE /api/v1/links/:id | Remove a link |
GET /api/v1/teams/:id/linked/documents | Docs linked to a team |
GET /api/v1/teams/:id/linked/tasks | Tasks linked to a team |
GET /api/v1/documents/:id/linked/contacts | Contacts linked to a doc |
GET /api/v1/contacts/:id/linked/* | Everything linked to a contact |
VI. Business Modules (Deep Dive)
The "Modules Within Modules" Pattern
Business modules aren't flat. A module like Documents contains submodules (Redline, Approvals, Signatures) that have their own tables, endpoints, and events — but can't exist without the parent.
// Submodules declare their parent as a hard dependency
export class RedlineModule implements PlatformModule {
name = 'redline';
version = '1.0.0';
dependencies = ['core', 'documents']; // can't load without documents
onRegister(container) {
container.register(RedlineService);
container.register(DiffEngine);
container.register(RedlineController);
}
}
// Parent module optionally loads submodules
export class DocumentsModule implements PlatformModule {
name = 'documents';
version = '1.0.0';
dependencies = ['core', 'files'];
submodules = ['redline', 'approvals', 'signatures']; // optional
}
If a product needs Documents but not Redline, just don't load the submodule. The parent works without it.
Cross-Module Data Loading Endpoints
Every module exposes standard "loader" endpoints for other modules and frontends — entity link UIs, activity feeds, notifications, global search, dashboard widgets.
interface ModuleLoader {
resolve(ids: string[], orgId: string): Promise<EntitySummary[]>;
search(query: string, orgId: string, limit?: number): Promise<EntitySummary[]>;
count(orgId: string, filter?: Record<string, unknown>): Promise<number>;
}
interface EntitySummary {
id: string;
type: string; // 'task', 'contact', 'document'
title: string;
subtitle?: string; // "Acme Corp", "Due tomorrow"
status?: string;
avatar?: string;
url: string; // deep link path
}
// Auto-generated for every module:
POST /api/v1/resolve — { type: "task", ids: ["id1","id2"] } → summaries
GET /api/v1/search?type=contact&q=acme — cross-module search
GET /api/v1/counts — { tasks: 12, contacts: 340, documents: 28 }
Tasks Module (Deep Dive) Optional
Same data powers Kanban boards, traditional lists, calendar views, and Gantt charts. The view_type on boards determines rendering — not data structure.
Core Schema
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
title VARCHAR(500) NOT NULL,
description TEXT,
status VARCHAR(50) NOT NULL DEFAULT 'todo',
priority VARCHAR(20), -- 'urgent', 'high', 'medium', 'low'
due_date TIMESTAMPTZ,
start_date TIMESTAMPTZ,
board_id UUID REFERENCES task_boards(id),
column_id UUID REFERENCES task_columns(id),
position FLOAT, -- fractional indexing for drag-and-drop
parent_id UUID REFERENCES tasks(id), -- subtask (unlimited nesting)
metadata JSONB DEFAULT '{}', -- custom fields, labels, story points
assignee_id UUID,
created_by UUID NOT NULL,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE task_boards (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
name VARCHAR(200),
view_type VARCHAR(50) DEFAULT 'kanban', -- kanban, list, calendar, gantt
config JSONB DEFAULT '{}'
);
CREATE TABLE task_columns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
board_id UUID REFERENCES task_boards(id),
name VARCHAR(200),
position FLOAT,
config JSONB DEFAULT '{}' -- color, WIP limits, automation rules
);
Checklists
CREATE TABLE task_checklists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
title VARCHAR(200) NOT NULL,
position FLOAT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE checklist_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
checklist_id UUID NOT NULL REFERENCES task_checklists(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
done BOOLEAN DEFAULT false,
done_by UUID,
done_at TIMESTAMPTZ,
position FLOAT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
Task Dependencies
Critical for Gantt views and workflow automation. Circular dependency detection via topological sort.
CREATE TABLE task_dependencies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
depends_on_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
type VARCHAR(20) DEFAULT 'finish_to_start',
-- 'finish_to_start', 'start_to_start', 'finish_to_finish'
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(task_id, depends_on_id),
CHECK(task_id != depends_on_id)
);
Watchers
CREATE TABLE task_watchers (
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY(task_id, user_id)
);
Recurring Tasks
CREATE TABLE task_recurrence_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
template_task_id UUID NOT NULL REFERENCES tasks(id),
cron_expression VARCHAR(50) NOT NULL, -- '0 9 1 * *' (1st of month at 9am)
next_run_at TIMESTAMPTZ NOT NULL,
assignee_strategy VARCHAR(20) DEFAULT 'same', -- 'same', 'rotate', 'round_robin'
enabled BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
Time Tracking (Optional Submodule)
CREATE TABLE time_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
task_id UUID REFERENCES tasks(id),
user_id UUID NOT NULL,
started_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ, -- NULL = timer running
duration_seconds INT,
description TEXT,
billable BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
Task Automations
Rule engine that fires on task events. Stored as JSON rules, evaluated by the event bus.
CREATE TABLE task_automations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
board_id UUID REFERENCES task_boards(id),
name VARCHAR(200),
trigger_event VARCHAR(100) NOT NULL, -- 'task.status_changed', 'task.overdue'
conditions JSONB DEFAULT '[]',
actions JSONB DEFAULT '[]',
enabled BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
// Example rules:
{ trigger: 'task.status_changed',
conditions: [{ field: 'status', to: 'done' }],
actions: [
{ type: 'notify', target: 'creator', template: 'task_completed' },
{ type: 'set_field', field: 'completed_at', value: '{{now}}' }
] }
{ trigger: 'task.overdue',
conditions: [{ field: 'overdue_days', gte: 3 }],
actions: [
{ type: 'reassign', target: 'team_lead' },
{ type: 'set_field', field: 'priority', value: 'urgent' },
{ type: 'notify', target: 'assignee', template: 'task_escalated' }
] }
Full Task Endpoints
// Core CRUD
GET /api/v1/tasks — list (standard pagination/filtering)
POST /api/v1/tasks — create
GET /api/v1/tasks/:id — get (includes subtasks, checklists, watchers)
PATCH /api/v1/tasks/:id — update
DELETE /api/v1/tasks/:id — soft delete
// Views
GET /api/v1/tasks?board_id=X&view=kanban — grouped by column
GET /api/v1/tasks?board_id=X&view=list — flat sorted list
GET /api/v1/tasks?view=calendar&from=X&to=Y — calendar range
GET /api/v1/tasks?view=gantt&board_id=X — with dependencies & timeline
// Board management
POST /api/v1/boards — create board
PATCH /api/v1/boards/:id/columns — add/reorder columns
PATCH /api/v1/tasks/:id/move — reorder / move between columns
// Hierarchy & Checklists
POST /api/v1/tasks/:id/subtasks — create subtask
POST /api/v1/tasks/:id/checklists — add checklist
PATCH /api/v1/checklists/:id/items/:itemId — toggle done
// Dependencies & Watchers
POST /api/v1/tasks/:id/dependencies — add dependency
POST /api/v1/tasks/:id/watchers — add watcher
// Time Tracking
POST /api/v1/tasks/:id/timer/start — start timer
POST /api/v1/tasks/:id/timer/stop — stop timer
GET /api/v1/time-entries?task=X&from=Y&to=Z — time report
// Automations
GET /api/v1/boards/:id/automations — list rules
POST /api/v1/boards/:id/automations — create rule
Contacts / Leads / Client Management (Deep Dive) Optional
Core Schema
Leads and contacts share one table with a type discriminator. A lead converts to a client by changing type.
CREATE TABLE contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
type VARCHAR(20) NOT NULL, -- 'lead', 'client', 'vendor', 'partner'
status VARCHAR(50), -- 'active', 'inactive', 'churned'
first_name VARCHAR(100),
last_name VARCHAR(100),
email VARCHAR(255),
phone VARCHAR(50),
company VARCHAR(200),
job_title VARCHAR(200),
avatar_url VARCHAR(1000),
source VARCHAR(50), -- 'website', 'referral', 'cold_outreach', 'import'
custom_fields JSONB DEFAULT '{}',
pipeline_stage VARCHAR(50),
assigned_to UUID,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_contacts_org_type ON contacts(org_id, type);
CREATE INDEX idx_contacts_email ON contacts(org_id, email);
CREATE INDEX idx_contacts_company ON contacts(org_id, company);
Pipelines & Stages
CREATE TABLE pipelines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
name VARCHAR(200) NOT NULL, -- 'Sales Pipeline', 'Onboarding Pipeline'
type VARCHAR(20) DEFAULT 'deal', -- 'deal', 'lead', 'onboarding'
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE pipeline_stages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pipeline_id UUID NOT NULL REFERENCES pipelines(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL, -- 'Qualified', 'Proposal', 'Negotiation', 'Closed Won'
position FLOAT,
color VARCHAR(7),
probability INT, -- 10, 25, 50, 75, 100 — for weighted pipeline
is_closed BOOLEAN DEFAULT false,
is_won BOOLEAN DEFAULT false, -- closed-won vs closed-lost
created_at TIMESTAMPTZ DEFAULT NOW()
);
Deals
One contact can have multiple deals. Deals have value, probability, expected close date.
CREATE TABLE deals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
contact_id UUID NOT NULL REFERENCES contacts(id),
pipeline_id UUID NOT NULL REFERENCES pipelines(id),
stage_id UUID NOT NULL REFERENCES pipeline_stages(id),
title VARCHAR(500) NOT NULL,
value DECIMAL(15, 2),
currency VARCHAR(3) DEFAULT 'USD',
probability INT, -- auto-set from stage, overridable
expected_close_date DATE,
actual_close_date DATE,
lost_reason VARCHAR(200),
assigned_to UUID,
metadata JSONB DEFAULT '{}',
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_deals_pipeline ON deals(org_id, pipeline_id, stage_id);
CREATE INDEX idx_deals_contact ON deals(org_id, contact_id);
Interactions / Touchpoints
Every email, call, meeting logged against a contact. "Last contacted 3 days ago."
CREATE TABLE interactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
contact_id UUID NOT NULL REFERENCES contacts(id),
deal_id UUID REFERENCES deals(id), -- optionally tied to a deal
type VARCHAR(20) NOT NULL, -- 'email', 'call', 'meeting', 'note', 'sms'
direction VARCHAR(10), -- 'inbound', 'outbound'
subject VARCHAR(500),
body TEXT,
duration_seconds INT, -- for calls/meetings
occurred_at TIMESTAMPTZ NOT NULL,
logged_by UUID NOT NULL,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_interactions_contact ON interactions(org_id, contact_id, occurred_at DESC);
Full CRM Endpoints
// Contacts
GET /api/v1/contacts — list with standard filtering
POST /api/v1/contacts — create
GET /api/v1/contacts/:id — full profile (deals, interactions, links)
POST /api/v1/contacts/:id/convert — lead → client (changes type, triggers event)
GET /api/v1/contacts/:id/timeline — merged activity + interactions
// Deals
GET /api/v1/deals?pipeline_id=X — deal board (grouped by stage)
POST /api/v1/deals — create deal (linked to contact)
PATCH /api/v1/deals/:id/move — move between pipeline stages
GET /api/v1/deals/forecast — weighted pipeline: SUM(value * probability)
// Pipelines
GET /api/v1/pipelines — list org pipelines
POST /api/v1/pipelines — create
PATCH /api/v1/pipelines/:id/stages — configure stages
// Interactions
GET /api/v1/contacts/:id/interactions — interaction history
POST /api/v1/contacts/:id/interactions — log interaction
Documents / Contracts (Deep Dive) Optional
Core Schema
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
title VARCHAR(500),
type VARCHAR(50), -- 'contract', 'proposal', 'note', 'template', 'sow'
content TEXT, -- rich text / markdown / structured JSON
status VARCHAR(50), -- 'draft', 'internal_review', 'client_review', 'sent', 'signed'
version INT DEFAULT 1,
parent_id UUID REFERENCES documents(id), -- version chain
template_id UUID REFERENCES documents(id), -- created from this template
metadata JSONB DEFAULT '{}',
file_url VARCHAR(1000),
expires_at TIMESTAMPTZ, -- contract expiration
created_by UUID NOT NULL,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_documents_org_type ON documents(org_id, type, status);
Submodule: Redline / Revision Engine
Key design decision: field-based change tracking, NOT algorithmic diffing.
Generic text diffing (diff, diff-match-patch) produces unreliable results on rich HTML contracts — breaks on tag boundaries, misidentifies changes. Instead: track changes at the field level. Contract HTML uses data-field attributes on editable sections, so changes map cleanly to the document structure.
1. Structured edits (Edit Agreement modal) — user changes specific fields (payment terms, duration). Each field change becomes a
document_revision_changes row.2. Free-text edits (TipTap editor) — user edits the body directly. Changes stored inline via custom TipTap marks (
data-inserted, data-deleted).
CREATE TABLE document_revisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
revision_number INT NOT NULL,
content TEXT NOT NULL, -- full HTML snapshot at this revision
change_description VARCHAR(500),
feedback_id UUID, -- if created from a suggestion
created_by_id UUID,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(document_id, revision_number)
);
-- Structured field changes — the ONLY reliable way to track changes
CREATE TABLE document_revision_changes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
revision_id UUID NOT NULL REFERENCES document_revisions(id) ON DELETE CASCADE,
field_name VARCHAR(200) NOT NULL, -- 'cash_mg_amount', 'payment_terms'
field_label VARCHAR(200), -- 'Cash MG Amount', 'Payment Terms'
old_value TEXT NOT NULL,
new_value TEXT NOT NULL,
change_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
Revision 0 pattern: On first-ever revision, auto-create revision 0 with original content as baseline. Always possible to diff back to original.
Revision Diff Rendering (Cheerio + data-field)
Highlighting done at render time using stored field changes — NOT by running a diff algorithm.
import * as cheerio from 'cheerio';
const HL_ADD_STYLE = 'background-color: rgba(239, 68, 68, 0.25); color: #dc2626; font-weight: 600;';
const HL_DEL_STYLE = 'background-color: rgba(107, 114, 128, 0.2); color: #6b7280; text-decoration: line-through;';
function highlightByField(html, fieldName, oldValue, newValue, showOldText) {
let replacement = '';
if (showOldText && oldValue && oldValue !== newValue) {
replacement += `<span style="${HL_DEL_STYLE}">${escapeHtml(oldValue)}</span>`;
}
if (newValue?.trim()) {
replacement += `<span style="${HL_ADD_STYLE}">${escapeHtml(newValue)}</span>`;
}
// Strategy 1: Find element by data-field attribute (reliable)
const $ = cheerio.load(html);
const $el = $(`[data-field="${fieldName}"]`);
if ($el.length) { $el.html(replacement); return $.html(); }
// Strategy 2: Direct text search fallback
return html.split(searchText).join(replacement);
}
Display modes: Cumulative (all changes since original), Historical (one revision's changes), Eye toggle (showOldText shows/hides deleted text).
TipTap Track Changes Editor
For free-text edits — Word-style "Track Changes" in a web editor.
@tiptap/react, @tiptap/core, @tiptap/pm, @tiptap/starter-kit, @tiptap/extension-underline, cheerio (server-side HTML parsing)
// Custom TipTap marks for tracked changes
const InsertMark = Mark.create({
name: 'trackInsert',
parseHTML() { return [{ tag: 'span[data-inserted]' }]; },
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes({
style: 'background: rgba(34,197,94,0.25); color: #16a34a; border-bottom: 2px solid #16a34a;',
'data-inserted': 'true'
}, HTMLAttributes), 0];
},
});
const DeleteMark = Mark.create({
name: 'trackDelete',
parseHTML() { return [{ tag: 'span[data-deleted]' }]; },
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes({
style: 'background: rgba(239,68,68,0.2); color: #dc2626; text-decoration: line-through;',
'data-deleted': 'true'
}, HTMLAttributes), 0];
},
});
How it works:
- Typing → new text wrapped in
<span data-inserted="true">(green) - Backspace/Delete → text wrapped in
<span data-deleted="true">(red strikethrough), skips already-deleted spans - Accept all → strip deleted spans, unwrap inserted spans (keep text)
- Reject all → strip inserted spans, unwrap deleted spans (restore text)
// Accept: remove deleted text, keep inserted text
acceptAll() {
html = html.replace(/<span[^>]*data-deleted="true"[^>]*>.*?<\/span>/g, '');
html = html.replace(/<span[^>]*data-inserted="true"[^>]*>(.*?)<\/span>/g, '$1');
}
// Reject: remove inserted text, restore deleted text
rejectAll() {
html = html.replace(/<span[^>]*data-inserted="true"[^>]*>.*?<\/span>/g, '');
html = html.replace(/<span[^>]*data-deleted="true"[^>]*>(.*?)<\/span>/g, '$1');
}
Edit Suggestions (External Review)
For external parties reviewing contracts — side-by-side comparison, not inline diffs. Approving creates a new revision.
CREATE TABLE document_edit_suggestions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES documents(id),
section VARCHAR(200),
field_key VARCHAR(200),
original_text TEXT NOT NULL,
suggested_text TEXT NOT NULL,
creator_request TEXT, -- what the reviewer asked for
ai_reasoning TEXT, -- optional AI analysis
status VARCHAR(20) DEFAULT 'PENDING', -- 'PENDING','APPROVED','REJECTED'
reviewed_by UUID,
reviewed_at TIMESTAMPTZ,
review_notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
Submodule: Clause Library
Reusable contract sections. Orgs build a library; templates are assembled from clauses.
CREATE TABLE clauses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
title VARCHAR(200) NOT NULL, -- 'Standard Indemnity', 'Net-30 Payment Terms'
category VARCHAR(50), -- 'indemnity', 'payment', 'confidentiality'
content TEXT NOT NULL,
version INT DEFAULT 1,
is_default BOOLEAN DEFAULT false, -- included in new contracts by default
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE document_clauses (
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
clause_id UUID NOT NULL REFERENCES clauses(id),
position FLOAT,
override_content TEXT, -- NULL = use clause content, set = custom version
PRIMARY KEY(document_id, clause_id)
);
Submodule: Approval Workflow
Multi-step approval before documents go out. Configurable per org and document type.
CREATE TABLE approval_workflows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
name VARCHAR(200),
document_type VARCHAR(50), -- 'contract', 'proposal'
steps JSONB NOT NULL,
-- [{ "order": 1, "role": "legal_reviewer", "action": "approve", "required": true },
-- { "order": 2, "role": "executive", "action": "sign", "required": true }]
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE approval_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES documents(id),
workflow_id UUID NOT NULL REFERENCES approval_workflows(id),
current_step INT DEFAULT 1,
status VARCHAR(20) DEFAULT 'pending',
created_by UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE approval_decisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
request_id UUID NOT NULL REFERENCES approval_requests(id),
step_order INT NOT NULL,
reviewer_id UUID NOT NULL,
decision VARCHAR(20) NOT NULL, -- 'approved', 'rejected', 'returned_for_changes'
comment TEXT,
decided_at TIMESTAMPTZ DEFAULT NOW()
);
Submodule: Signature Integration
Hooks for DocuSign / HelloSign / Adobe Sign. Platform handles orchestration, signing is external.
CREATE TABLE signature_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES documents(id),
provider VARCHAR(50) NOT NULL, -- 'docusign', 'hellosign', 'adobe_sign'
provider_envelope_id VARCHAR(200),
status VARCHAR(20) DEFAULT 'pending',
signers JSONB NOT NULL,
-- [{ "email": "client@acme.com", "name": "Jane Doe", "order": 1, "signed_at": null }]
signed_document_url VARCHAR(1000),
sent_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
Full Document Endpoints
// Core CRUD
GET /api/v1/documents — list
POST /api/v1/documents — create
GET /api/v1/documents/:id — get (revision count, approval status)
PATCH /api/v1/documents/:id — update content
// Templates & Clauses
GET /api/v1/documents/templates — list org templates
POST /api/v1/documents/from-template/:id — create from template
GET /api/v1/clauses — clause library
POST /api/v1/documents/:id/clauses — insert clause
// Revisions / Redline
GET /api/v1/documents/:id/revisions — history
POST /api/v1/documents/:id/revisions — create (computes diff)
GET /api/v1/documents/:id/redline — redline view
POST /api/v1/revisions/:id/accept/:opIndex — accept change
POST /api/v1/revisions/:id/reject/:opIndex — reject change
// Approvals
POST /api/v1/documents/:id/submit-for-approval
POST /api/v1/approval-requests/:id/decide
// Signatures
POST /api/v1/documents/:id/send-for-signature
GET /api/v1/documents/:id/signature-status
// Versioning
GET /api/v1/documents/:id/versions
GET /api/v1/documents/:id/compare?v1=2&v2=3 — diff between versions
Notifications (Deep Dive) Core
Event-driven. Other modules emit events. The notification module subscribes and routes based on user preferences. Never called directly.
Core Schema
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
user_id UUID NOT NULL,
type VARCHAR(100) NOT NULL, -- 'task.assigned', 'document.shared'
channel VARCHAR(20) NOT NULL, -- 'in_app', 'email', 'push', 'sms'
title VARCHAR(200) NOT NULL,
body TEXT,
data JSONB DEFAULT '{}', -- { entityType, entityId, url }
read_at TIMESTAMPTZ,
seen_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_notifications_user ON notifications(user_id, created_at DESC);
CREATE INDEX idx_notifications_unread ON notifications(user_id, read_at) WHERE read_at IS NULL;
Notification Preferences
CREATE TABLE notification_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
org_id UUID NOT NULL,
event_type VARCHAR(100) NOT NULL, -- 'task.assigned', 'document.*', '*'
channels VARCHAR(20)[] NOT NULL, -- {'in_app', 'email'} or {'in_app'} only
muted BOOLEAN DEFAULT false,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, org_id, event_type)
);
Routing Logic
class NotificationRouter {
async route(event: PlatformEvent, recipients: string[]) {
for (const userId of recipients) {
const prefs = await this.getPreferences(userId, event.type);
if (prefs.muted) continue;
for (const channel of prefs.channels) {
switch (channel) {
case 'in_app':
await this.createInAppNotification(userId, event);
await this.websocketGateway.push(userId, event); // real-time
break;
case 'email':
await this.emailQueue.add(userId, event); // batched into digest
break;
case 'push':
await this.pushService.send(userId, event); // FCM/APNS
break;
}
}
}
}
}
Digest Aggregation
CREATE TABLE notification_digests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
org_id UUID NOT NULL,
frequency VARCHAR(20) DEFAULT 'instant', -- 'instant', 'hourly', 'daily', 'weekly'
last_sent_at TIMESTAMPTZ,
next_send_at TIMESTAMPTZ,
UNIQUE(user_id, org_id)
);
If frequency = daily, the email worker collects unsent notifications since last_sent_at, groups by type, renders a single digest email.
Real-Time (WebSocket)
@WebSocketGateway({ path: '/ws' })
export class NotificationGateway {
// On connect: authenticate via JWT, join user's room
async push(userId: string, event: PlatformEvent) {
this.server.to(`user:${userId}`).emit('notification', {
type: event.type, title: event.title, body: event.body, data: event.data,
});
}
}
Notification Endpoints
GET /api/v1/notifications — list (paginated, newest first)
GET /api/v1/notifications/unread-count — badge count
PATCH /api/v1/notifications/:id/read — mark read
PATCH /api/v1/notifications/read-all — mark all read
GET /api/v1/notification-preferences — get my prefs
PATCH /api/v1/notification-preferences — update per event type
WS /ws — real-time (JWT auth)
Finance & Payments (Deep Dive) Optional
The Boundary — What Belongs vs. What Doesn't
| Belongs in the Platform Module | Too Specific — Leave to Integrations |
|---|---|
| Invoices (CRUD, line items, status) | Tax calculation rules (per jurisdiction) |
| Payments received / sent ledger | Payroll |
| Recurring invoices | Industry-specific billing logic |
| Expense tracking | Complex financial modeling / forecasting |
| Payment processing (Stripe integration) | Accounting standards (GAAP/IFRS) |
| Basic reporting (revenue, AR, P&L) | Multi-currency hedging |
| Credit notes / refunds | ERP integration (SAP, Oracle) |
| QuickBooks / Xero sync hooks | Custom chart of accounts |
Invoices
CREATE TABLE invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
invoice_number VARCHAR(50) NOT NULL, -- auto: INV-2025-0001
contact_id UUID REFERENCES contacts(id),
deal_id UUID REFERENCES deals(id),
status VARCHAR(20) NOT NULL DEFAULT 'draft',
-- 'draft','sent','viewed','partially_paid','paid','overdue','void','refunded'
issue_date DATE NOT NULL DEFAULT CURRENT_DATE,
due_date DATE NOT NULL,
currency VARCHAR(3) DEFAULT 'USD',
subtotal DECIMAL(15,2) NOT NULL DEFAULT 0,
tax_amount DECIMAL(15,2) DEFAULT 0,
discount_amount DECIMAL(15,2) DEFAULT 0,
total DECIMAL(15,2) NOT NULL DEFAULT 0,
amount_paid DECIMAL(15,2) DEFAULT 0,
amount_due DECIMAL(15,2) GENERATED ALWAYS AS (total - amount_paid) STORED,
notes TEXT,
terms TEXT,
is_recurring BOOLEAN DEFAULT false,
recurrence_rule_id UUID,
sent_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
voided_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
created_by UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_invoice_number ON invoices(org_id, invoice_number);
CREATE TABLE invoice_line_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
description VARCHAR(500) NOT NULL,
quantity DECIMAL(10,2) NOT NULL DEFAULT 1,
unit_price DECIMAL(15,2) NOT NULL,
amount DECIMAL(15,2) GENERATED ALWAYS AS (quantity * unit_price) STORED,
tax_rate DECIMAL(5,2) DEFAULT 0,
position FLOAT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
Payments
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
invoice_id UUID REFERENCES invoices(id),
contact_id UUID REFERENCES contacts(id),
type VARCHAR(20) NOT NULL, -- 'payment', 'refund', 'credit_note'
direction VARCHAR(10) NOT NULL, -- 'inbound' (received), 'outbound' (sent)
method VARCHAR(30), -- 'credit_card','bank_transfer','ach','check','cash'
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- 'pending','processing','completed','failed','refunded','cancelled'
amount DECIMAL(15,2) NOT NULL,
currency VARCHAR(3) DEFAULT 'USD',
fee DECIMAL(15,2) DEFAULT 0, -- processing fee (Stripe ~2.9%)
net_amount DECIMAL(15,2) GENERATED ALWAYS AS (amount - fee) STORED,
provider VARCHAR(30), -- 'stripe', 'manual', 'bank'
provider_payment_id VARCHAR(200), -- Stripe charge ID: ch_xxx
provider_receipt_url VARCHAR(1000),
reference VARCHAR(200), -- check number, wire reference
description TEXT,
metadata JSONB DEFAULT '{}',
paid_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
refunded_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_payments_invoice ON payments(org_id, invoice_id);
CREATE INDEX idx_payments_contact ON payments(org_id, contact_id);
CREATE INDEX idx_payments_date ON payments(org_id, paid_at DESC);
Stripe Integration
class PaymentService {
async createPaymentIntent(invoiceId: string, orgId: string) {
const invoice = await this.invoiceService.get(invoiceId, orgId);
const stripeAccount = await this.getStripeAccount(orgId);
const intent = await stripe.paymentIntents.create({
amount: Math.round(invoice.amount_due * 100), // cents
currency: invoice.currency,
customer: await this.getOrCreateStripeCustomer(invoice.contact_id),
metadata: { invoiceId, orgId },
}, { stripeAccount: stripeAccount.id });
return { clientSecret: intent.client_secret };
}
async handleWebhook(event: Stripe.Event) {
switch (event.type) {
case 'payment_intent.succeeded':
await this.recordPayment(event.data.object);
await this.updateInvoiceStatus(event.data.object.metadata.invoiceId);
await this.eventBus.emit('payment.received', { ... });
break;
case 'payment_intent.payment_failed':
await this.recordFailure(event.data.object);
await this.eventBus.emit('payment.failed', { ... });
break;
}
}
}
Recurring Invoices
CREATE TABLE invoice_recurrence_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
template_invoice_id UUID NOT NULL REFERENCES invoices(id),
frequency VARCHAR(20) NOT NULL, -- 'weekly','monthly','quarterly','annually'
next_issue_date DATE NOT NULL,
end_date DATE,
auto_send BOOLEAN DEFAULT false,
enabled BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
Cron job checks daily for invoices due, clones the template, sets new dates, optionally auto-sends.
Expenses
CREATE TABLE expenses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
category VARCHAR(50), -- 'software','travel','office','payroll','other'
description VARCHAR(500),
amount DECIMAL(15,2) NOT NULL,
currency VARCHAR(3) DEFAULT 'USD',
vendor VARCHAR(200),
receipt_url VARCHAR(1000), -- S3 link
status VARCHAR(20) DEFAULT 'pending', -- 'pending','approved','rejected','reimbursed'
expense_date DATE NOT NULL,
submitted_by UUID NOT NULL,
approved_by UUID,
approved_at TIMESTAMPTZ,
metadata JSONB DEFAULT '{}',
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
Full Finance & Payments Endpoints
// Invoices
GET /api/v1/invoices — list (filter by status, contact, date)
POST /api/v1/invoices — create
GET /api/v1/invoices/:id — get (line items, payments, status)
POST /api/v1/invoices/:id/send — send to contact (triggers email)
POST /api/v1/invoices/:id/void — void invoice
GET /api/v1/invoices/:id/pdf — generate PDF (presigned S3 URL)
POST /api/v1/invoices/:id/duplicate — clone
// Line Items
POST /api/v1/invoices/:id/line-items — add
PATCH /api/v1/invoices/:id/line-items/:itemId — update
DELETE /api/v1/invoices/:id/line-items/:itemId — remove
// Payments
GET /api/v1/payments — payment ledger
POST /api/v1/payments — record manual payment
POST /api/v1/invoices/:id/pay — Stripe payment (returns client secret)
POST /api/v1/payments/:id/refund — issue refund
// Recurring
GET /api/v1/invoices/recurring — list schedules
POST /api/v1/invoices/:id/make-recurring — set up recurrence
// Expenses
GET /api/v1/expenses — list
POST /api/v1/expenses — submit expense
POST /api/v1/expenses/:id/approve — approve
POST /api/v1/expenses/:id/reject — reject
// Reporting
GET /api/v1/finance/revenue?from=X&to=Y — revenue over period
GET /api/v1/finance/ar-aging — accounts receivable aging
GET /api/v1/finance/expenses-by-category — expense breakdown
GET /api/v1/finance/profit-loss?from=X&to=Y — basic P&L
// Integrations
POST /api/v1/finance/connect/stripe — connect Stripe (OAuth)
POST /api/v1/finance/connect/quickbooks — connect QuickBooks (OAuth)
POST /api/v1/finance/sync/quickbooks — push to QuickBooks
User Flows & API Plans
Every step a user takes through each module, mapped to the exact API call, database write, and event emitted.
Tasks — User Flows
Flow 1: Create a Task
Step 1: User opens board, clicks "New Task"
API: POST /api/v1/tasks
Body: { title, board_id, column_id, assignee_id?, due_date?, priority? }
DB: INSERT INTO tasks (position = last in column via fractional index)
Event: 'task.created' → activity feed. If assignee set: 'task.assigned' → notify
Flow 2: Assign a Task
Step 1: User selects assignee from dropdown (cross-module loader)
API: PATCH /api/v1/tasks/:id { assignee_id }
DB: UPDATE tasks SET assignee_id = ...
Event: 'task.assigned' → notification to assignee (in-app + email per prefs)
→ activity feed: "Alice assigned this to Bob"
Flow 3: Move Task (Drag & Drop)
Step 1: User drags task to new column/position
API: PATCH /api/v1/tasks/:id/move { column_id, position: 2.5 }
DB: UPDATE tasks SET column_id, position (fractional indexing)
Event: 'task.status_changed' → may trigger automations
→ activity feed: "Alice moved this to In Review"
Flow 4: Subtasks & Checklists
Step 1: Add subtask → POST /api/v1/tasks/:id/subtasks
Step 2: Add checklist → POST /api/v1/tasks/:id/checklists
Step 3: Check off item → PATCH /api/v1/checklists/:id/items/:itemId { done: true }
Event: 'task.checklist_completed' (if all done) → activity feed
Flow 5: Dependencies
Step 1: Link "B depends on A"
API: POST /api/v1/tasks/:id/dependencies { depends_on_id, type }
DB: INSERT (after circular dep check — 409 if cycle detected)
Step 2: A completes → B unblocked
Event: 'task.unblocked' → notify B's assignee
Flow 6: Time Tracking
Step 1: Start timer → POST /api/v1/tasks/:id/timer/start (one active per user)
Step 2: Stop timer → POST /api/v1/tasks/:id/timer/stop (computes duration)
Step 3: View report → GET /api/v1/time-entries?task=X&from=Y&to=Z
Flow 7: Recurring & Automations
Recurring: Cron checks task_recurrence_rules → clones template task → notifies assignee
Automations: Event fires → match trigger → evaluate conditions → execute actions
Actions: notify, set_field, reassign (each may emit cascading events)
CRM — User Flows
Flow 1: Create Contact → Qualify → Convert
Step 1: Create → POST /api/v1/contacts { type:'lead', name, email, company, source }
Step 2: Add deal → POST /api/v1/deals { contact_id, pipeline_id, stage_id, value }
Step 3: Move deal → PATCH /api/v1/deals/:id/move { stage_id } (probability auto-updates)
Step 4: Convert → POST /api/v1/contacts/:id/convert { new_type:'client' }
Event: 'contact.converted' → notification + activity feed
Notes: All linked deals, tasks, documents carry over
Flow 2: Log Interactions
Step 1: Log call/email/meeting
API: POST /api/v1/contacts/:id/interactions
Body: { type:'call', direction:'outbound', subject, body, duration_seconds, deal_id? }
Step 2: View timeline → GET /api/v1/contacts/:id/timeline
Returns: Merged activity feed + interactions + comments (chronological)
Flow 3: Pipeline Forecast
API: GET /api/v1/deals/forecast
Returns: { total_pipeline_value, weighted_value: SUM(value * probability),
deals_by_stage, expected_revenue_this_month }
Contracts — User Flows
Flow 1: Create → Edit → Review → Send
Step 1: Create from template
API: POST /api/v1/documents/from-template/:id { title, type:'contract', contact_id }
DB: Clone content + default clauses. Status = 'draft'
Step 2: Edit (structured fields)
API: POST /api/v1/documents/:id/revisions
Body: { content, fieldChanges: [{ field_name, old_value, new_value }] }
DB: Rev 0 auto-created (original baseline). New revision + change rows.
Step 3: Edit (free-text TipTap)
Typing → <span data-inserted> (green)
Deleting → <span data-deleted> (red strikethrough)
Save → POST /api/v1/documents/:id/revisions { content with inline marks }
Step 4: Submit for approval
API: POST /api/v1/documents/:id/submit-for-approval
DB: Lookup workflow → create approval_request → notify first reviewer
Step 5: Send to client
API: POST /api/v1/documents/:id/send { recipient_email }
DB: Status → 'sent'. Generate review token.
Event: Email with review link → 'document.sent'
Flow 2: Client & Team Revisions
Client reviews via token link:
API: GET /api/v1/documents/review/:token (loads contract + revisions)
API: POST /api/v1/documents/review/:token/suggest { section, original_text, suggested_text }
DB: INSERT edit_suggestion (status = PENDING)
Event: 'document.suggestion_received' → notify owner
Team reviews suggestions (side-by-side: original vs suggested):
Approve → PATCH /api/v1/documents/:id/suggestions/:id { status:'APPROVED' }
Creates new revision with change applied
Reject → PATCH { status:'REJECTED', review_notes }
Flow 3: Sign → Deliverables → Track
Step 1: Send for signature
API: POST /api/v1/documents/:id/send-for-signature
Body: { provider:'docusign', signers:[{ email, name, order }] }
System: DocuSign API → sends signing emails
Step 2: Provider webhook on completion
API: POST /api/v1/webhooks/docusign
DB: Status → 'signed'. Store signed PDF in S3.
Event: 'document.signed' → notify all parties
Step 3: Capture deliverables
API: POST /api/v1/documents/:id/deliverables
Body: { title, quantity, frequency, platform, due_date }
DB: INSERT INTO contract_deliverables
Step 4: Track deliverables
API: GET /api/v1/documents/:id/deliverables (progress, is_on_track)
API: PATCH /api/v1/deliverables/:id { current_count, status }
Cron: Daily check → if behind pace, set is_on_track = false → notify owner
Notifications — User Flows
Flow: Event → Route → Deliver
1. Any module emits event (e.g. 'task.assigned')
2. NotificationRouter determines recipients (assignee + watchers + creator)
3. Check preferences: SELECT channels, muted FROM notification_preferences
4. Create notification per channel (in_app, email, push)
5. Deliver:
- in_app: WebSocket push to user's room (instant)
- email: BullMQ queue → respects digest frequency (instant/hourly/daily)
- push: FCM/APNS (instant)
Reading:
GET /api/v1/notifications (paginated)
GET /api/v1/notifications/unread-count (badge)
PATCH /api/v1/notifications/:id/read
PATCH /api/v1/notifications/read-all
Preferences:
GET /api/v1/notification-preferences
PATCH /api/v1/notification-preferences { event_type, channels, muted }
Finance & Payments — User Flows
Flow 1: Invoice → Send → Get Paid
Step 1: Create invoice
API: POST /api/v1/invoices { contact_id, due_date, currency }
DB: Auto-generate invoice_number (INV-2025-0001), status = 'draft'
Step 2: Add line items
API: POST /api/v1/invoices/:id/line-items { description, quantity, unit_price }
DB: amount = quantity * unit_price (computed). Recalculate invoice totals.
Step 3: Send
API: POST /api/v1/invoices/:id/send
System: Email with PDF + payment link → status = 'sent'
Step 4: Stripe payment
API: POST /api/v1/invoices/:id/pay → returns { clientSecret }
Stripe: paymentIntents.create → client pays → webhook fires
API: POST /api/v1/webhooks/stripe
DB: INSERT payment (completed). UPDATE invoice amount_paid.
If fully paid: status = 'paid'. Else: 'partially_paid'
Event: 'payment.received' → notify finance team
Step 5: Manual payment
API: POST /api/v1/payments { invoice_id, amount, method:'check', reference }
Step 6: Refund
API: POST /api/v1/payments/:id/refund { amount? }
System: If Stripe → stripe.refunds.create(). Recalculate invoice.
Flow 2: Recurring & Expenses
Recurring:
POST /api/v1/invoices/:id/make-recurring { frequency:'monthly', auto_send }
Cron: Daily → clone template → new invoice_number → optionally auto-send
Expenses:
POST /api/v1/expenses { category, amount, vendor, receipt_url }
POST /api/v1/expenses/:id/approve (or /reject)
Events: 'expense.submitted' → notify approver. 'expense.approved' → notify submitter.
Reporting:
GET /api/v1/finance/revenue?from=X&to=Y
GET /api/v1/finance/ar-aging
GET /api/v1/finance/profit-loss?from=X&to=Y
GET /api/v1/finance/expenses-by-category
VII. Deployment — Cell Provisioning
One command provisions a full product cell in a target AWS account:
npx cdk deploy ProductCell \
--context productName=acme-crm \
--context modules=tasks,contacts,documents,finance \
--context environment=prod \
--profile product-a-prod-account
What Gets Provisioned
- VPC (3 AZs, private subnets, NAT Gateways)
- Aurora Serverless v2 cluster (writer + reader)
- ElastiCache Redis cluster
- ECS Fargate service (API + Worker)
- S3 buckets (files, backups)
- CloudFront distribution + WAF rules
- CloudWatch dashboards + alarms
- Cross-account roles (CI/CD, logging)
- DNS records in Network Hub account
tasks module → deploys to all products that use it.VIII. Deployment Strategy — Platform by Platform
No single platform is optimal for every workload. Use the right tool for each layer.
Backend API Servers
| Option | Best For | Trade-off |
|---|---|---|
| ECS Fargate | Steady-state workloads, full VPC control, WebSockets, complex networking | More config, you manage scaling policies |
| AWS App Runner | Simpler APIs, auto-scaling zero config, small teams | Less VPC control, no WebSocket support |
| Lambda + API GW | Event-driven, spiky traffic, low-traffic modules | Cold starts, 15-min timeout, harder to debug |
Background Workers / Jobs
| Option | Best For |
|---|---|
| ECS Fargate (separate service) | Long-running job processors (BullMQ), always-on |
| Lambda | Short-lived event handlers (S3 triggers, SQS consumers, cron) |
| Step Functions + Lambda | Multi-step workflows (ETL, document processing) |
Frontend Applications
| Option | Best For | Trade-off |
|---|---|---|
| Vercel | Next.js apps, best DX, instant previews, edge SSR | Vendor lock-in, cost at scale, data leaves AWS |
| Cloudflare Pages | Static sites, simple SPAs, global edge, cheap | Limited SSR, no tight AWS integration |
| AWS Amplify Hosting | Next.js/React needing tight AWS integration | Slower builds, weaker DX vs. Vercel |
| CloudFront + S3 | Pure SPAs (React/Vue), full control, cheapest | No SSR, manual cache invalidation |
Frontend Recommendation
- Customer-facing products → Vercel (Next.js with edge SSR, preview deployments per PR)
- Internal tools / admin panels → CloudFront + S3 (static React SPA, cheapest, stays in AWS)
- Architecture docs / marketing → Cloudflare Pages (static HTML, free tier)
The Recommended Mix
EDGE / CDN
Customer Frontends --> Vercel (Next.js, edge SSR)
Internal Tools --> CloudFront + S3 (React SPA)
Architecture Docs --> Cloudflare Pages (static HTML)
|
| HTTPS (API calls)
v
AWS PRODUCT ACCOUNT
API Gateway --> ECS Fargate (Core API, NestJS)
SQS/SNS --> ECS Fargate (BullMQ Worker)
S3 Events --> Lambda (file processing)
Scheduled --> Lambda (cron jobs, cleanup)
ETL --> Step Functions + Lambda
|
Aurora PostgreSQL | Redis | S3
Why NOT All-Serverless for Backend
| Concern | Vercel / Lambda Serverless | ECS Fargate |
|---|---|---|
| Timeout | 10-300s depending on plan | Unlimited |
| Cold starts | 100-500ms per invocation | None (always running) |
| DB connections | New connection per invocation (kills DB) | Connection pool (Prisma) |
| WebSockets | Not supported | Full support |
| Background jobs | Not supported | BullMQ workers |
| VPC access | Not possible (Vercel) / complex (Lambda) | Native (same VPC as Aurora/Redis) |
| Cost at scale | Unpredictable (per-invocation) | Predictable (per-container) |
How Vercel Works with This Architecture
Vercel is the frontend deployment platform only. It does NOT run business logic.
User's Browser
|
v
Vercel (Frontend ONLY)
Next.js App
| fetch('https://api.yourproduct.com/v1/tasks')
|
v HTTPS
AWS (Your Backend)
CloudFront --> API Gateway --> ECS (NestJS on Fargate)
|
Aurora | Redis | S3
- Server Components / SSR: Vercel renders HTML by calling your ECS API, sends finished page to browser
- Client-side fetching: Browser calls your ECS API directly — Vercel not involved
- Next.js API routes: Only for thin proxies (OAuth callbacks, cookie handling) — never business logic
- WebSockets: Browser connects directly to ECS — Vercel can't handle these
Per-Environment Strategy
| Environment | Backend | Frontend |
|---|---|---|
| Dev | ECS Fargate (min capacity, Aurora at 0.5 ACU) | Vercel preview deployments (auto per PR) |
| Staging | ECS Fargate (mirrors prod, smaller scale) | Vercel staging branch |
| Prod | ECS Fargate (auto-scaling 2-10 tasks, multi-AZ) | Vercel production |
IX. Observability — SRE Approach
SLO-Driven, Not Alert-Driven
| SLO | Target | Budget |
|---|---|---|
| API Availability | 99.9% | 43 min downtime/month |
| API Latency (p99) | < 500ms | — |
| API Latency (p50) | < 100ms | — |
| Data Durability | 99.999999999% | Aurora handles this |
Three Pillars
| Pillar | Tool | Purpose |
|---|---|---|
| Logs | CloudWatch → OpenSearch | Structured JSON, cross-account |
| Metrics | CloudWatch + EMF | Business + infra metrics |
| Traces | X-Ray / OpenTelemetry | End-to-end request tracing |
Every request gets a correlation ID flowing through logs, metrics, and traces.
X. Tech Stack Summary
| Concern | Choice |
|---|---|
| Backend | NestJS (TypeScript) — module system built-in |
| ORM | Prisma — type-safe queries, auto-migrations, built-in connection pooling |
| Database | Aurora PostgreSQL Serverless v2 |
| Cache / Queue | Redis (ElastiCache) + BullMQ |
| Auth | AWS Cognito or Keycloak |
| Permissions | SpiceDB / OpenFGA (Zanzibar) |
| File Storage | AWS S3 + presigned URLs |
| Search | PG full-text → OpenSearch later |
| Events | AWS SNS/SQS or Redis Streams |
| Deployment | ECS Fargate + CDK for IaC |
| Frontend | Next.js |
| Monorepo | Turborepo or Nx |
| CI/CD | GitHub Actions → CDK Pipelines |
| Observability | CloudWatch + X-Ray + OpenSearch |
XI. Build Order
Module Dependency Map
Core (always loaded):
├── Auth
├── Users
├── Organizations
├── Teams
├── Permissions (Zanzibar)
├── Entity Links (cross-module glue)
├── Notifications (event-driven)
├── Files
├── Audit Log
└── Search
Optional (plug in per product):
├── Tasks → depends on Core
├── Contacts → depends on Core
├── Documents → depends on Core, Files
├── Notes → depends on Core
├── Finance → depends on Core, Contacts
└── [Custom] → depends on Core
Recommended Sequence
- Monorepo scaffold — Turborepo + NestJS + shared types package
- Core kernel — Auth, Users, Orgs, Teams, Permissions, Entity Links
- Event bus + notifications
- Tasks — first optional module, proves the architecture
- Contacts/Leads, Documents, Notes
- Finance — most domain-specific, last
- CDK infrastructure — multi-account provisioning
- CI/CD pipeline — automated deploy across accounts
XII. Module Update & Versioning Strategy
How to ship changes to shared infrastructure without breaking products already running on it.
Scenario 1 — Updating an Existing Core Module
Say you update Auth to add MFA, change a JWT claim, or refactor the permission middleware. Every product using that module gets the change. If it breaks, everything breaks.
Solution: Semantic Versioning + Changesets
| Version Bump | When | Rollout |
|---|---|---|
patch (1.2.0 → 1.2.1) | Bug fix | Auto-deploy to all products |
minor (1.2.0 → 1.3.0) | New backwards-compatible feature | Auto-deploy, products adopt when ready |
major (1.x → 2.0.0) | Breaking change | Each product opts in on its own schedule |
Breaking changes live in a parallel package until products migrate:
packages/core/auth/ ← v1.2.0 (Product A still here)
packages/core/auth-v2/ ← v2.0.0 (Product B migrated)
Use Changesets to manage this in the monorepo:
# Developer describes what changed
npx changeset add
# → "auth: added MFA, new required field mfa_enabled on users table"
# → type: minor
# CI bumps versions and generates changelogs
npx changeset version
In CI, every PR to a shared module runs tests against every product that uses it:
# .github/workflows/test.yml
test-all-products:
- test product-a against updated auth module
- test product-b against updated auth module
- test product-c against updated auth module
# If any fail → PR is blocked
Scenario 2 — Adding a New Module to an Existing Product
The easy case. Just add it to platform.config.ts:
// Before
modules: [new TasksModule(), new ContactsModule()]
// After — Finance added
modules: [new TasksModule(), new ContactsModule(), new FinanceModule()]
On next cdk deploy: Finance migrations run automatically, new routes register, event subscriptions set up. Nothing else changes — existing data is untouched.
Scenario 3 — Database Schema Changes
The hardest problem. Change a core table and every product on that schema is affected.
Rule 1: Migrations are forwards-only and backwards-compatible
Never rename or delete a column in a single migration. Always do it in phases:
-- Phase 1 (deploy now): Add new column alongside old
ALTER TABLE users ADD COLUMN display_name VARCHAR(200);
-- Application writes to BOTH columns during transition
-- Phase 2 (next deploy): Backfill
UPDATE users SET display_name = name WHERE display_name IS NULL;
-- Phase 3 (after confirming): Drop old column
ALTER TABLE users DROP COLUMN name;
Rule 2: Migrations run automatically on deploy
async onDatabaseSetup(migrator: Migrator) {
await migrator.runModuleMigrations('auth');
// Idempotent — safe to run multiple times
}
Rule 3: Separate migration deploys from code deploys
- Day 1 — Deploy migration (add new column, keep old)
- Day 2 — Deploy code that uses new column
- Day 3 — Deploy migration to drop old column after confirming
Scenario 4 — API Contract Changes
Backwards-compatible (safe anytime): adding endpoints, adding optional fields, adding optional params. Frontends that don't know about new fields simply ignore them.
Breaking changes — use API versioning:
/api/v1/tasks ← old behavior, still works
/api/v2/tasks ← new behavior
Both run simultaneously. Products on v1 keep working. v1 is deprecated with a sunset date announced in advance.
Overall Governance Model
PLATFORM REPO (shared modules)
Core changes go through:
1. PR with changeset label (patch / minor / major)
2. Required review from platform team
3. Automated tests across ALL products in CI
4. Staged rollout: Dev → Staging → Prod
Breaking changes (major):
5. Migration guide published
6. Each product team opts in on their own schedule
7. Old version supported for defined deprecation window
Decision Matrix
| Change Type | Strategy |
|---|---|
| Bug fix in core module | Patch version, auto-deploy everywhere |
| New optional feature in core | Minor version, auto-deploy, products adopt when ready |
| Breaking change to core | Major version, products opt in independently |
| New module added to a product | Additive — deploy freely |
| DB schema change (additive) | Run migration, deploy code |
| DB schema change (breaking) | 3-phase migration (add → backfill → drop) |
| API contract change | Version the endpoint (/v1 → /v2), both run simultaneously |
XIII. Baseline Infrastructure Checklist
Everything below lives in packages/core/. It ships with the platform. Product teams never build these — they just use them.
Tier 1 — Every Project, No Exceptions
Non-negotiable. If you don't build them into the platform, every product team will reinvent them poorly.
1. Settings & Configuration Engine
User preferences, org-level config, and runtime feature flags — all in one system.
CREATE TABLE settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
scope VARCHAR(20) NOT NULL, -- 'org', 'user', 'module'
scope_id UUID NOT NULL, -- org_id, user_id, or module instance id
namespace VARCHAR(100) NOT NULL, -- 'notifications', 'appearance', 'billing'
key VARCHAR(200) NOT NULL,
value JSONB NOT NULL,
updated_by UUID,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(org_id, scope, scope_id, namespace, key)
);
CREATE INDEX idx_settings_lookup ON settings(org_id, scope, scope_id, namespace);
class SettingsService {
async get<T>(orgId: string, scope: Scope, key: string, defaultValue: T): Promise<T>;
async set(orgId: string, scope: Scope, key: string, value: unknown): Promise<void>;
async getBulk(orgId: string, scope: Scope, namespace: string): Promise<Record<string, unknown>>;
}
// Usage:
const timezone = await settings.get(orgId, { type: 'user', id: userId }, 'timezone', 'UTC');
const orgBranding = await settings.getBulk(orgId, { type: 'org', id: orgId }, 'branding');
2. Comments & Activity Feed
Universal — attaches to any entity across any module. Every task, document, contact gets comments and an activity timeline.
CREATE TABLE comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
entity_type VARCHAR(50) NOT NULL, -- 'task', 'document', 'contact'
entity_id UUID NOT NULL,
author_id UUID NOT NULL,
body TEXT NOT NULL,
parent_id UUID REFERENCES comments(id), -- threaded replies
edited_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_comments_entity ON comments(org_id, entity_type, entity_id, created_at);
CREATE TABLE activity_feed (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id UUID NOT NULL,
actor_id UUID NOT NULL,
action VARCHAR(100) NOT NULL, -- 'status_changed', 'assigned', 'commented'
changes JSONB DEFAULT '{}', -- { "status": { "from": "draft", "to": "review" } }
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_activity_entity ON activity_feed(org_id, entity_type, entity_id, created_at DESC);
CREATE INDEX idx_activity_actor ON activity_feed(org_id, actor_id, created_at DESC);
GET /api/v1/tasks/:id/comments — comments on a task
POST /api/v1/tasks/:id/comments — add a comment
GET /api/v1/documents/:id/activity — activity timeline for a doc
GET /api/v1/activity?actor=user_123 — everything a user did (cross-entity)
3. Tags & Labels
Universal tagging system — any entity, any module. Color-coded, filterable.
CREATE TABLE tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
name VARCHAR(100) NOT NULL,
color VARCHAR(7), -- hex: #FF5733
category VARCHAR(50), -- 'priority', 'department', 'custom'
UNIQUE(org_id, name)
);
CREATE TABLE entity_tags (
entity_type VARCHAR(50) NOT NULL,
entity_id UUID NOT NULL,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
org_id UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY(entity_type, entity_id, tag_id)
);
CREATE INDEX idx_entity_tags_lookup ON entity_tags(org_id, entity_type, entity_id);
CREATE INDEX idx_entity_tags_by_tag ON entity_tags(org_id, tag_id);
GET /api/v1/tags — list org tags
POST /api/v1/tags — create tag
POST /api/v1/tasks/:id/tags — tag a task
DELETE /api/v1/tasks/:id/tags/:tagId — untag
GET /api/v1/tasks?tags=urgent,frontend — filter by tags
4. Soft Deletes, Trash & Archive
Nothing truly deletes. Users get a trash can. Compliance gets retention. Every user-facing table includes deleted_at and archived_at.
class SoftDeleteMiddleware {
// Automatically appends WHERE deleted_at IS NULL to all queries
// Unless explicitly requested: findMany({ withDeleted: true })
}
class TrashService {
async softDelete(entityType: string, entityId: string, orgId: string): Promise<void>;
async restore(entityType: string, entityId: string, orgId: string): Promise<void>;
async listTrash(orgId: string, entityType?: string): Promise<TrashedItem[]>;
async permanentDelete(entityType: string, entityId: string, orgId: string): Promise<void>;
// Scheduled: auto-purge items in trash > 30 days
}
DELETE /api/v1/tasks/:id — soft delete (moves to trash)
POST /api/v1/trash/:id/restore — restore from trash
GET /api/v1/trash?type=task — list trashed items
DELETE /api/v1/trash/:id/permanent — hard delete (admin only)
5. Standardized Pagination, Filtering & Sorting
Every list endpoint works identically. Frontends build one data-table component that works with every module.
interface ListQuery {
page?: number; // default 1
limit?: number; // default 25, max 100
sort?: string; // 'created_at:desc' or 'title:asc'
search?: string; // full-text search across searchable fields
filter?: Record<string, string | string[]>;
// filter[status]=active
// filter[assignee]=user_123,user_456
// filter[created_at.gte]=2025-01-01
}
interface PaginatedResponse<T> {
data: T[];
meta: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
};
}
// Base repository — every module gets this for free
class BaseRepository<T> {
async findMany(orgId: string, query: ListQuery): Promise<PaginatedResponse<T>> {
// Builds Prisma query from standardized params
// Applies RLS automatically via orgId
// Excludes soft-deleted by default
}
}
// Every endpoint works the same:
GET /api/v1/tasks?page=2&limit=10&sort=due_date:asc&filter[status]=in_progress
GET /api/v1/contacts?search=acme&filter[type]=lead&sort=created_at:desc
6. Standardized Error Responses & Exception Handling
Every error — validation, auth, business logic, server — returns the same shape. Frontends build one error handler.
interface ErrorResponse {
error: {
code: string; // 'VALIDATION_ERROR', 'NOT_FOUND', 'FORBIDDEN'
message: string; // 'Task not found'
details?: unknown[]; // field-level errors for validation
correlationId: string; // ties to logs and traces
timestamp: string;
};
}
// 400 — Validation
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{ "field": "email", "message": "Must be a valid email address" },
{ "field": "name", "message": "Required" }
],
"correlationId": "req_abc123",
"timestamp": "2025-03-30T12:00:00Z"
}
}
// NestJS global exception filter
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const correlationId = host.switchToHttp().getRequest().correlationId;
// Maps any exception to the standard envelope
// Logs full stack trace internally
// Returns sanitized error to client (no stack traces)
// Sets correct HTTP status code
// Includes rate-limit headers: X-RateLimit-Remaining, Retry-After
}
}
7. Email & Transactional Messaging
Every product sends email. Template engine + queue + provider abstraction (SES today, SendGrid tomorrow).
CREATE TABLE email_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID, -- NULL = system-level template
slug VARCHAR(100) NOT NULL, -- 'welcome', 'password_reset', 'invoice_sent'
subject VARCHAR(500) NOT NULL, -- 'Welcome to {{org_name}}'
body_html TEXT NOT NULL, -- Handlebars / Mjml template
body_text TEXT,
variables JSONB DEFAULT '[]',
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(org_id, slug)
);
CREATE TABLE email_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
to_address VARCHAR(255) NOT NULL,
template_slug VARCHAR(100),
subject VARCHAR(500),
status VARCHAR(20) NOT NULL, -- 'queued', 'sent', 'delivered', 'bounced', 'failed'
provider VARCHAR(50), -- 'ses', 'sendgrid'
provider_message_id VARCHAR(200),
error TEXT,
sent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
class EmailService {
async send(params: {
to: string | string[];
template: string;
variables: Record<string, unknown>;
orgId: string;
}): Promise<void> {
// 1. Load template (org-specific override → system default)
// 2. Render with Handlebars
// 3. Queue via BullMQ (never block the API request)
// 4. Worker picks up, sends via SES/SendGrid
// 5. Logs result to email_log
}
}
// Usage from any module:
await email.send({
to: user.email,
template: 'task_assigned',
variables: { taskTitle: task.title, assignerName: actor.name },
orgId,
});
8. Invitation & Onboarding System
Invite users to orgs/teams. Magic links. First-time setup hooks.
CREATE TABLE invitations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
email VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'member',
team_ids UUID[] DEFAULT '{}', -- auto-join these teams on accept
token VARCHAR(200) NOT NULL UNIQUE,
invited_by UUID NOT NULL,
status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'accepted', 'expired', 'revoked'
expires_at TIMESTAMPTZ NOT NULL,
accepted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
POST /api/v1/invitations — invite user (sends email with magic link)
GET /api/v1/invitations/accept?token=X — accept invite (creates user if needed, joins org)
GET /api/v1/invitations — list pending invitations for org
DELETE /api/v1/invitations/:id — revoke invitation
// Onboarding hooks — modules register first-time setup steps:
interface OnboardingStep {
moduleId: string;
step: string; // 'create_first_team', 'import_contacts', 'set_timezone'
required: boolean;
order: number;
}
// Core tracks completion per user
// Frontend renders a setup wizard from the registered steps
9. Feature Flags (Runtime)
Toggle features per org, per user, or by percentage — without redeploying.
CREATE TABLE feature_flags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key VARCHAR(100) NOT NULL UNIQUE, -- 'kanban_view', 'ai_summary', 'new_billing'
description TEXT,
enabled BOOLEAN DEFAULT false, -- global default
rules JSONB DEFAULT '[]',
-- [
-- { "type": "org", "ids": ["org_123"], "enabled": true },
-- { "type": "user", "ids": ["user_456"], "enabled": true },
-- { "type": "percentage", "value": 25, "enabled": true }
-- ]
updated_at TIMESTAMPTZ DEFAULT NOW()
);
class FeatureFlagService {
async isEnabled(key: string, context: { orgId: string; userId: string }): Promise<boolean> {
// 1. Check cache (Redis, 30s TTL)
// 2. Evaluate rules: user match → org match → percentage → global default
}
}
// Usage:
if (await features.isEnabled('kanban_view', { orgId, userId })) {
// return kanban-specific data
}
GET /api/v1/admin/feature-flags — list all flags (platform admin)
PATCH /api/v1/admin/feature-flags/:key — update flag rules
GET /api/v1/features — resolved flags for current user
10. Health Checks & Readiness Probes
ECS, ALB, and deploy pipelines all need these. Liveness = "is the process alive?" Readiness = "can it serve traffic?"
@Controller('health')
export class HealthController {
@Get('/live')
async liveness() {
return { status: 'ok', uptime: process.uptime() };
}
@Get('/ready')
async readiness() {
const checks = await Promise.allSettled([
this.db.query('SELECT 1'), // Aurora writer reachable
this.redis.ping(), // Redis reachable
]);
const healthy = checks.every(c => c.status === 'fulfilled');
return {
status: healthy ? 'ready' : 'degraded',
checks: {
database: checks[0].status,
redis: checks[1].status,
},
};
}
}
11. Request Context Middleware
Every request gets a context object that propagates everywhere — logs, audit, permissions, downstream calls. No prop-drilling.
interface RequestContext {
correlationId: string; // generated or forwarded from X-Correlation-Id header
userId: string;
orgId: string;
roles: string[];
ip: string;
userAgent: string;
timestamp: Date;
}
@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const ctx: RequestContext = {
correlationId: req.headers['x-correlation-id'] || randomUUID(),
userId: req.user?.id,
orgId: req.user?.orgId,
roles: req.user?.roles || [],
ip: req.ip,
userAgent: req.headers['user-agent'],
timestamp: new Date(),
};
// Available everywhere via AsyncLocalStorage
requestContextStorage.run(ctx, () => next());
}
}
// Any service, anywhere:
const ctx = RequestContext.current();
logger.info('Task created', { taskId, correlationId: ctx.correlationId });
Tier 2 — Most Projects Would Need
Not universal, but you'll reach for these in 80%+ of products. Build the abstractions now; flesh out when needed.
12. Webhooks (Outbound)
Let external systems subscribe to platform events. Retry with exponential backoff, HMAC signing, delivery logs.
CREATE TABLE webhook_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
url VARCHAR(1000) NOT NULL,
events VARCHAR(100)[] NOT NULL, -- {'task.created', 'contact.updated'}
secret VARCHAR(200) NOT NULL, -- HMAC signing key
active BOOLEAN DEFAULT true,
failure_count INT DEFAULT 0, -- auto-disable after N consecutive failures
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE webhook_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
subscription_id UUID REFERENCES webhook_subscriptions(id),
event VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
response_status INT,
response_body TEXT,
attempts INT DEFAULT 0,
next_retry_at TIMESTAMPTZ,
status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'delivered', 'failed'
created_at TIMESTAMPTZ DEFAULT NOW()
);
Delivery: event emitted → BullMQ job → POST to URL with HMAC signature → retry with exponential backoff (1min, 5min, 30min, 2hr) → disable subscription after 10 consecutive failures.
13. Import / Export
Standardized pipeline for bulk data. CSV upload, Excel/JSON export.
class ImportExportService {
async importCSV(params: {
file: Buffer;
entityType: string; // 'contacts', 'tasks'
mapping: FieldMapping[]; // maps CSV columns to entity fields
orgId: string;
actorId: string;
}): Promise<ImportJob> {
// 1. Parse CSV
// 2. Validate each row against entity schema
// 3. Return preview (first 10 rows + validation errors)
// 4. On confirm: queue BullMQ job for bulk insert
// 5. Emit 'import.completed' event with stats
}
async export(params: {
entityType: string;
query: ListQuery; // reuse the standard filtering
format: 'csv' | 'xlsx' | 'json';
orgId: string;
}): Promise<string> {
// 1. Query data (uses same BaseRepository.findMany)
// 2. Generate file
// 3. Upload to S3
// 4. Return presigned download URL (expires in 1 hour)
}
}
POST /api/v1/import/preview — upload file, get preview + validation
POST /api/v1/import/confirm — start import job
GET /api/v1/import/jobs/:id — check import status
POST /api/v1/export — start export job, returns download URL
14. Scheduled Jobs / Cron
Centralized cron registry with execution tracking and failure alerting.
CREATE TABLE scheduled_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL UNIQUE,
cron_expression VARCHAR(50) NOT NULL, -- '0 8 * * *' (daily at 8am)
handler VARCHAR(200) NOT NULL, -- 'notifications:sendDailyDigest'
enabled BOOLEAN DEFAULT true,
last_run_at TIMESTAMPTZ,
last_status VARCHAR(20),
last_duration_ms INT,
next_run_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE job_executions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_id UUID REFERENCES scheduled_jobs(id),
started_at TIMESTAMPTZ NOT NULL,
finished_at TIMESTAMPTZ,
status VARCHAR(20), -- 'running', 'success', 'failed'
result JSONB,
error TEXT,
duration_ms INT
);
// Modules register cron jobs during setup:
onReady() {
this.scheduler.register({
name: 'notifications:sendDailyDigest',
cron: '0 8 * * *',
handler: () => this.notificationService.sendDailyDigests(),
});
this.scheduler.register({
name: 'trash:autoPurge',
cron: '0 3 * * *',
handler: () => this.trashService.purgeExpired(30), // 30-day retention
});
}
15. Dashboard & Analytics Base
Standardized aggregation endpoints every dashboard will call. Not a full BI tool — just the API patterns.
class AnalyticsService {
async count(params: {
entityType: string;
orgId: string;
filter?: Record<string, unknown>;
}): Promise<number>;
async groupBy(params: {
entityType: string;
groupField: string; // 'status', 'assignee_id', 'type'
orgId: string;
filter?: Record<string, unknown>;
}): Promise<{ group: string; count: number }[]>;
async timeSeries(params: {
entityType: string;
dateField: string; // 'created_at', 'closed_at'
interval: 'day' | 'week' | 'month';
orgId: string;
filter?: Record<string, unknown>;
}): Promise<{ date: string; count: number }[]>;
}
GET /api/v1/analytics/count?entity=tasks&filter[status]=completed
GET /api/v1/analytics/group?entity=contacts&group=type
GET /api/v1/analytics/timeseries?entity=tasks&date=created_at&interval=week
16. API Keys & External Access
Service accounts and scoped API keys for integrations.
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
name VARCHAR(200) NOT NULL, -- 'Zapier Integration', 'Mobile App'
key_hash VARCHAR(200) NOT NULL, -- bcrypt hash (plain key shown once at creation)
key_prefix VARCHAR(10) NOT NULL, -- 'pk_live_' — for identification
scopes VARCHAR(100)[] NOT NULL, -- {'tasks:read', 'contacts:write'}
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
created_by UUID NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
POST /api/v1/api-keys — create key (returns plain key ONCE)
GET /api/v1/api-keys — list keys (prefix, scopes, last_used — never full key)
DELETE /api/v1/api-keys/:id — revoke key
Auth middleware detects Authorization: Bearer pk_live_... and resolves to the API key's scopes instead of a user JWT.
17. i18n / Localization Framework
Middleware + string extraction pattern — costs almost nothing to add now, painful to retrofit later.
@Injectable()
export class I18nMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
req.locale = resolveLocale(req);
// Accept-Language header → user setting → org default → 'en'
next();
}
}
class I18nService {
t(key: string, locale: string, variables?: Record<string, unknown>): string;
// 'task.assigned' → 'You were assigned to "{taskTitle}"'
// 'task.assigned' → 'Te asignaron a "{taskTitle}"' (es)
}
class FormatService {
date(value: Date, locale: string, style?: 'short' | 'long'): string;
number(value: number, locale: string): string;
currency(value: number, locale: string, currency: string): string;
}
String tables stored as JSON files in the repo (locales/en.json, locales/es.json). Modules register their own keys.
Updated Module Dependency Map
Core (always loaded):
├── Auth
├── Users
├── Organizations
├── Teams
├── Permissions (Zanzibar)
├── Entity Links
├── Notifications (event-driven)
├── Files
├── Audit Log
├── Search
├── Settings & Config ← NEW
├── Comments & Activity Feed ← NEW
├── Tags & Labels ← NEW
├── Soft Deletes / Trash ← NEW
├── Pagination / Filtering ← NEW (middleware)
├── Error Handling ← NEW (global filter)
├── Email / Messaging ← NEW
├── Invitations / Onboarding ← NEW
├── Feature Flags ← NEW
├── Health Checks ← NEW
└── Request Context ← NEW (middleware)
Tier 2 (most projects):
├── Webhooks (outbound) ← NEW
├── Import / Export ← NEW
├── Scheduled Jobs / Cron ← NEW
├── Dashboard Analytics Base ← NEW
├── API Keys / External Auth ← NEW
└── i18n / Localization ← NEW
Optional (plug in per product):
├── Tasks (depends on: Core)
├── Contacts (depends on: Core)
├── Documents (depends on: Core, Files)
├── Notes (depends on: Core)
├── Finance (depends on: Core, Contacts)
└── [Custom] (depends on: Core)
XV. Core System User Flows
Step-by-step walkthroughs for every core system. Each step maps to the exact API call, database write, and event emitted.
Auth
Flow 1: Sign Up
Step 1: User submits registration form
API: POST /api/v1/auth/register
Body: { email, password, first_name, last_name }
System: Validate email + password strength (12 chars, 1 upper, 1 number, 1 special)
DB: INSERT INTO users (password_hash = bcrypt 12 rounds)
INSERT INTO organizations (auto-create personal org)
INSERT INTO org_members (role = 'owner')
Event: 'user.registered' → welcome email
Returns: { user, tokens: { accessToken (15min JWT), refreshToken (30d httpOnly cookie) } }
Flow 2: Login
Step 1: User submits credentials
API: POST /api/v1/auth/login { email, password }
System: bcrypt.compare → if MFA enabled → { requiresMfa: true, mfaToken }
DB: INSERT refresh_token. UPDATE users SET last_login_at, login_count++
Event: 'user.logged_in' → audit log (IP, UA, geo)
Notes: 5 failed attempts → 15min lockout. Rate: 10/email/15min.
Flow 3: Token Refresh
API: POST /api/v1/auth/refresh { refreshToken }
System: Validate → issue new access token → rotate refresh token (old invalidated)
Notes: If revoked token reused → revoke ALL tokens (breach detected)
Flow 4: Logout
API: POST /api/v1/auth/logout
System: Revoke refresh token. Blacklist access token in Redis (TTL = remaining life).
Notes: /api/v1/auth/logout-all → revoke ALL sessions
Flow 5: Password Reset
Step 1: POST /api/v1/auth/forgot-password { email }
System: Generate reset token (1hr TTL). Always return 200 (prevent enumeration).
Event: 'user.password_reset_requested' → email with link
Step 2: POST /api/v1/auth/reset-password { token, newPassword }
DB: UPDATE password_hash. DELETE all refresh_tokens (revoke sessions).
Event: 'user.password_changed' → confirmation email
Flow 6: MFA Enrollment (TOTP)
Step 1: POST /api/v1/auth/mfa/enroll → { qrCodeUrl, secret }
DB: Store encrypted TOTP secret (KMS). mfa_enabled = false until verified.
Step 2: POST /api/v1/auth/mfa/verify { code }
DB: mfa_enabled = true. Generate 10 hashed backup codes.
Returns: { backupCodes } (shown once)
Step 3: POST /api/v1/auth/mfa/challenge { mfaToken, code }
Returns: { accessToken, refreshToken }
Flow 7: OAuth / SSO
Step 1: GET /api/v1/auth/oauth/google → 302 to Cognito/Keycloak (with PKCE)
Step 2: GET /api/v1/auth/oauth/callback?code=X&state=Y
System: Verify state → exchange code → extract user info → match or create user
DB: UPSERT users (link oauth_provider). INSERT refresh_token.
Returns: Redirect with tokens
Users
Profile & Account Management
View profile: GET /api/v1/users/me → { id, email, name, avatar, timezone, locale }
Update: PATCH /api/v1/users/me { first_name, timezone, locale }
Event: 'user.updated' → audit log
Upload avatar: POST /api/v1/users/me/avatar → { uploadUrl, key }
Client PUTs to S3 directly → PATCH /api/v1/users/me { avatar_url }
Change password: POST /api/v1/users/me/change-password { currentPassword, newPassword }
Revokes all other sessions. Event: 'user.password_changed'
Deactivate: POST /api/v1/users/me/deactivate { confirmation: 'DEACTIVATE', password }
Soft-delete. Revoke tokens. GDPR anonymize after 30 days (cron).
Organizations
Org Lifecycle
Create: POST /api/v1/organizations { name, slug, plan }
DB: INSERT org + org_members (creator = owner)
Settings: PATCH /api/v1/organizations/:orgId { name, logo_url, default_timezone }
PUT /api/v1/settings { scope:'org', namespace:'branding', key, value }
Members: GET /api/v1/organizations/:orgId/members (paginated)
Remove: DELETE /api/v1/organizations/:orgId/members/:userId
Reassign owned tasks/docs. Remove permission tuples.
Event: 'organization.member_removed' → notify user
Transfer: POST /api/v1/organizations/:orgId/transfer { newOwnerId, password }
Old owner → admin. New owner → owner. Event: 'ownership_transferred'
Teams
Team Management
Create: POST /api/v1/teams { name, description, visibility }
Add: POST /api/v1/teams/:id/members { user_ids, role }
DB: INSERT team_members + permission_tuples
Event: 'team.member_added' → notify each user
Remove: DELETE /api/v1/teams/:id/members/:userId
Event: 'team.member_removed'
Assign to entity: POST /api/v1/links
{ source: { type:'team', id }, target: { type:'document', id }, relationship:'assigned' }
DB: INSERT entity_link + permission_tuples (all members get view)
Event: 'entity.linked' → notify team
List work: GET /api/v1/teams/:id/linked/tasks
GET /api/v1/teams/:id/linked/documents
Permissions (Zanzibar)
Permission Flows
Grant: POST /api/v1/permissions/grant
{ namespace:'document', object_id:'doc_123', relation:'editor', subject:{ type:'user', id:'user_456' } }
DB: INSERT INTO permission_tuples
Check (runtime — every request):
Middleware: PermissionEngine.check(userId, 'view', { type:'document', id:'doc_123' })
Graph walk: direct → via team → via org role → cached in Redis (30s TTL)
Denied → 403
My access: GET /api/v1/permissions/my-access?namespace=document
Returns: [{ object_id, relations: ['viewer','editor'] }]
Custom role: POST /api/v1/roles { name:'project_manager', permissions:['task:*','document:view'] }
Audit: GET /api/v1/permissions/audit?namespace=document&object_id=doc_123
Shows: direct grants + inherited (via team/org)
Entity Links
Universal Linking
Link: POST /api/v1/links
{ source: { type:'contact', id }, target: { type:'document', id }, relationship:'associated' }
System: verify both exist → permission check → create
Event: 'entity.linked' → activity on both entities
View: GET /api/v1/contacts/:id/linked/documents (bidirectional lookup)
GET /api/v1/contacts/:id/linked/tasks
Resolves via ModuleLoader → EntitySummary[]
Remove: DELETE /api/v1/links/:linkId
Event: 'entity.unlinked' → audit log
Search: GET /api/v1/links?source_type=deal&source_id=deal_1
Powers "Related Items" panel on every detail page
Files
Upload → Download → Organize
Upload (presigned):
Step 1: POST /api/v1/files/upload-url { filename, contentType, size, entity_type?, entity_id? }
Validate size < 100MB. Generate S3 presigned PUT (15min). Insert file (status='pending').
Step 2: Client PUTs binary to S3 directly (no server memory pressure)
Step 3: POST /api/v1/files/:id/confirm → verify S3 HEAD → status='active' → link to entity
Event: 'file.uploaded' → activity feed
Download: GET /api/v1/files/:id/download → presigned GET URL (1hr expiry)
List: GET /api/v1/documents/:id/files
Delete: DELETE /api/v1/files/:id → soft delete. S3 object retained 30 days.
Folders: POST /api/v1/folders { name, parent_id? }
PATCH /api/v1/files/:id { folder_id }
GET /api/v1/folders/:id/contents → { folders[], files[] }
Audit Log
Automatic Logging & Compliance
Every mutating API call auto-logged (no module code needed):
DB: INSERT INTO audit_log (actor_id, action, entity_type, entity_id, changes, ip, correlation_id)
Append-only: no UPDATE or DELETE allowed on audit_log table.
View: GET /api/v1/audit?entity_type=document&entity_id=doc_123 (entity trail)
GET /api/v1/audit?actor_id=user_456&from=2025-01-01 (user trail)
GET /api/v1/audit?page=1&sort=timestamp:desc (global, admin)
Export: POST /api/v1/audit/export { from, to, format:'csv' } → queued → S3 download
GET /api/v1/audit/export/:jobId → { status, downloadUrl }
Retention: Configurable per org. Default 2 years → archive to S3 Glacier.
Monthly cron: 'audit:archiveOld'
Search
Global & Contextual Search
Global (typeahead):
GET /api/v1/search?q=acme&limit=10
PostgreSQL full-text: ts_rank + permission filter (only viewable entities)
Returns: EntitySummary[] grouped by type. Debounced 300ms. Cached 60s.
Later: migrate to OpenSearch for fuzzy + better ranking.
Filtered: GET /api/v1/search?q=acme&type=contact&filter[status]=active
In-entity: GET /api/v1/tasks/:id/comments?search=budget
Recent: Tracked automatically. GET /api/v1/search/recent → last 10
Saved: POST /api/v1/search/saved { name, query, type, filters }
GET /api/v1/search/saved/:id/run → re-execute with current data
XVI. Baseline Infrastructure Flows
User walkthroughs for key baseline infrastructure systems that ship with every product.
Comments & Activity Feed
Add comment: POST /api/v1/{entityType}/{entityId}/comments { body }
Parse @mentions → notify mentioned users
Event: 'comment.created' → activity feed + watchers notified
Reply: POST /api/v1/{entityType}/{entityId}/comments { body, parent_id }
Event: 'comment.replied' → notify thread participants
Edit: PATCH /api/v1/comments/:id { body } → sets edited_at
Delete: DELETE /api/v1/comments/:id → soft delete, shows "[deleted]"
Activity feed: GET /api/v1/{entityType}/{entityId}/activity
Merged: system events (status changes, assignments) + comments
Sorted chronologically. Powers the "Activity" tab on every entity.
My activity: GET /api/v1/activity?actor=me → everything you did, cross-entity
@Mentions: User types @ → GET /api/v1/search?type=user&q=ali (typeahead)
Stored as: "@[Alice](user:user_789)"
On save: emit 'user.mentioned' → in-app + email notification
Tags & Labels
Create tag: POST /api/v1/tags { name:"urgent", color:"#EF4444", category:"priority" }
Unique per org.
Apply: POST /api/v1/tasks/:id/tags { tag_id }
Event: 'entity.tagged' → activity feed
Bulk apply: POST /api/v1/tags/:tagId/apply { entities: [{ type, id }, ...] }
Filter: GET /api/v1/tasks?tags=urgent,frontend
Works on every entity list (standard ListQuery).
Remove: DELETE /api/v1/tasks/:id/tags/:tagId
Admin: GET /api/v1/tags (list all)
PATCH /api/v1/tags/:id { color, name } (update)
DELETE /api/v1/tags/:id (cascade removes from entities)
Invitation & Onboarding
Invite: POST /api/v1/invitations { email, role, team_ids }
Generate token (7 day expiry). Send email via EmailService.
Event: 'invitation.sent' → audit log
Accept: GET /api/v1/invitations/accept?token=X
If no account → redirect to register (pre-filled email)
POST /api/v1/auth/register { ..., invitation_token }
DB: Create user + join org + join teams + permission tuples
If has account → just join org
Event: 'invitation.accepted' → notify inviter
Resend: POST /api/v1/invitations/:id/resend → new token, reset expiry
Revoke: DELETE /api/v1/invitations/:id → status = 'revoked'
Onboarding: GET /api/v1/onboarding/status
Returns: { steps: [{ id, module, required, completed }] }
Modules register steps. Frontend renders wizard.
POST /api/v1/onboarding/complete { step:'create_first_team' }
Wizard dismissed when all required steps done.
Import / Export
Import CSV:
Step 1: POST /api/v1/import/preview (FormData: file + entityType)
Parse headers → auto-suggest column mapping → validate 100 rows
Returns: { headers, suggestedMapping, preview (10 rows), validationErrors, totalRows }
Step 2: POST /api/v1/import/confirm { previewId, mapping, skipErrors }
Queue BullMQ job. Returns: { jobId }
Event: 'import.started'
Step 3: Worker batch-inserts (100 rows/batch). Tracks progress.
Event: 'import.completed' → notify: "2,430 of 2,450 imported (20 skipped)"
Failed rows → downloadable error CSV
Step 4: GET /api/v1/import/jobs/:id → { status, successCount, errorCount, errorFile }
Export:
POST /api/v1/export { entityType, format:'csv', query }
Worker: query → generate file → S3 → presigned URL (1hr)
GET /api/v1/export/jobs/:id → { status, downloadUrl }
Webhooks (Outbound)
Register: POST /api/v1/webhooks { url, events:['task.created','contact.updated'] }
Generate HMAC secret. Returns secret ONCE (admin must save).
Test: POST /api/v1/webhooks/:id/test
Send test payload with HMAC signature. Log response.
Delivery: Event fires → match subscriptions → BullMQ job → POST to URL
Headers: X-Webhook-Signature (HMAC-SHA256), X-Webhook-Event, X-Webhook-Delivery
Retry: Exponential backoff: 1min → 5min → 30min → 2hr
After 10 consecutive failures → auto-disable subscription
Event: 'webhook.disabled' → notify admin
Log: GET /api/v1/webhooks/:id/deliveries (status, attempts, response codes)
Manual retry: POST /api/v1/webhooks/deliveries/:id/retry
API Keys & External Access
Generate: POST /api/v1/api-keys { name:'Zapier', scopes:['tasks:read','contacts:write'], expires_at? }
Key format: pk_live_ + 32 random bytes. Stored as bcrypt hash.
Returns: { id, key:'pk_live_a1b2...' } ← shown ONCE, never stored plain
List: GET /api/v1/api-keys → [{ name, prefix:'pk_live_a1b2', scopes, last_used_at }]
Full key NEVER returned.
Revoke: DELETE /api/v1/api-keys/:id → revoked_at = NOW(). Immediate effect.
Auth flow: Authorization: Bearer pk_live_a1b2...
1. Detect prefix → API key auth (not JWT)
2. Lookup by prefix → bcrypt.compare
3. Check: not revoked, not expired
4. Validate scope: GET /tasks needs 'tasks:read'
5. Set context: orgId from key, actorId = 'api_key:{id}'
6. Apply RLS
Rate limits: Redis counter per key per minute.
Free: 100/min. Pro: 1000/min. Enterprise: 10000/min.
Headers: X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After
XVII. Frontend Architecture
The frontend mirrors the backend's modular philosophy. A product picks which modules to load — the frontend only renders what the backend serves.
Shared UI Kit / Design System
All UI components live in @platform/ui. Every product uses the same components.
| Component | Purpose | Used By |
|---|---|---|
| DataTable | Sortable, filterable, paginated (wraps ListQuery) | Every list view |
| KanbanBoard | Drag-and-drop column layout | Tasks, Deals/Pipeline |
| Timeline | Chronological activity feed | Task detail, Contact profile |
| RichTextEditor | TipTap with track changes | Documents, Notes, Comments |
| EntityCard | Renders any EntitySummary as linkable card | Linked items, search results |
| FileUpload | Drag-and-drop with presigned URL flow | Any entity with files |
| CommandPalette | Cmd+K global search + quick actions | Global (always available) |
| SlideOver | Side panel for entity detail | Quick view on any entity |
| TagInput | Multi-select tag picker with color pills | All entity tag fields |
Module Registration (Frontend)
Each module declares routes, nav items, widgets, and settings pages. The app loads only what's enabled.
// packages/modules/tasks/module.ts
export const tasksModule: FrontendModule = {
id: 'tasks',
name: 'Tasks',
routes: [
{ path: '/tasks', component: () => import('./pages/TasksPage') },
{ path: '/tasks/:id', component: () => import('./pages/TaskDetailPage') },
],
navigation: { label: 'Tasks', icon: 'CheckSquare', path: '/tasks', order: 20 },
widgets: {
'task-list': {
component: () => import('./widgets/TaskListWidget'),
title: 'Tasks',
acceptsContext: ['contact', 'document', 'deal', 'team'],
},
},
searchProvider: { type: 'task', icon: 'CheckSquare' },
};
// Module loading — only active modules get routes/nav/widgets:
const enabledModules = await fetch('/api/v1/features').then(r => r.json());
const activeModules = allModules.filter(m => enabledModules[m.id]);
Cross-Module Widgets
Any module can embed UI into any other module's pages without direct imports.
// How it works:
// 1. Contact profile renders <WidgetSlot entityType="contact" entityId="contact_123" />
// 2. WidgetSlot queries registry: "Which widgets accept context='contact'?"
// 3. Finds: TaskListWidget, DocumentListWidget, DealPipelineWidget
// 4. Renders each as a collapsible section (lazy loaded via React.Suspense)
// 5. Each widget fetches its own data via the SDK independently
// Widget examples:
// Contact Profile → TaskListWidget, DocumentListWidget, DealPipelineWidget
// Deal Detail → TaskListWidget, DocumentListWidget
// Document Detail → ContactCard
// Dashboard → TaskCountWidget, DealForecastWidget, RevenueWidget
Routing
Next.js App Router with dynamic module route groups. Route protection via edge middleware.
// Route structure:
// app/(auth)/login, register ← minimal layout, no sidebar
// app/(dashboard)/tasks/* ← loaded if tasks module enabled
// app/(dashboard)/contacts/* ← loaded if contacts module enabled
// app/(dashboard)/settings/* ← always available
// Edge middleware checks:
// 1. Has access token? No → redirect /login
// 2. Module enabled in JWT claims? No → redirect /dashboard
// 3. Navigation auto-generated from activeModules.map(m => m.navigation)
State Management
Server state: TanStack Query (cache, optimistic updates, invalidation). Client state: Zustand (sidebar, modals, command palette). No Redux.
// Server state — TanStack Query
const { data } = useQuery({ queryKey: ['tasks', query], queryFn: () => api.tasks.list(query) });
// Mutations with optimistic updates:
const createTask = useMutation({
mutationFn: (data) => api.tasks.create(data),
onMutate: async (newTask) => {
await queryClient.cancelQueries({ queryKey: ['tasks'] });
queryClient.setQueryData(['tasks'], old => ({ ...old, data: [newTask, ...old.data] }));
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
});
// Client state — Zustand (sidebar, command palette, slide-over)
const { sidebarCollapsed, toggleSidebar, openSlideOver } = useUIStore();
| Concern | TanStack Query + Zustand | Redux Toolkit |
|---|---|---|
| Server state caching | Built-in (stale-while-revalidate) | Manual (RTK Query helps) |
| Optimistic updates | First-class support | Manual |
| Boilerplate | Near zero | Significant |
| Bundle size | ~13KB combined | ~40KB+ |
| Module isolation | Each module has own hooks | Global store = coupling |
Real-Time (WebSocket)
// WebSocket connects on auth → joins user room
// Server pushes events → client invalidates relevant TanStack Query caches
// What updates in real-time:
// notification → toast + badge count
// task.status_changed → kanban card moves column
// deal.stage_changed → pipeline board updates
// document.revised → revision indicator
// comment.created → new comment in timeline
// entity.updated → detail page refreshes
Layout System
┌──────────────────────────────────────────────────────────┐
│ TopBar │
│ [≡] [Search... Cmd+K] [🔔 3] [Avatar] │
├──────┬───────────────────────────────────────────────────┤
│ │ │
│ Side │ Main Content Area │
│ bar │ ┌──────────────────────────────────────────────┐ │
│ │ │ Page Header (title + actions) │ │
│ Home │ ├──────────────────────────────────────────────┤ │
│ Tasks│ │ │ │
│ CRM │ │ Page Content │ │
│ Docs │ │ (DataTable / KanbanBoard / Form / Detail) │ │
│ $$$ │ │ │ │
│ │ └──────────────────────────────────────────────┘ │
│ ---- │ │
│ Sett │ SlideOver ─── Quick view panel (no page nav) ──┐│
│ │ ││
└──────┴──────────────────────────────────────────────────┘│
│
Entity detail: 2/3 main content + 1/3 WidgetSlot sidebar │
Every module page follows the same PageLayout pattern (header + actions + content). Entity detail pages use EntityDetailLayout (main tabs + widget sidebar).
XIV. Standard vs. This Architecture
| Concern | Standard Approach | This Architecture |
|---|---|---|
| Multi-tenancy | Shared DB, app-level filtering | Row-Level Security at DB + Zanzibar permission model |
| Account isolation | One AWS account, IAM policies | Multi-account with SCPs, blast radius isolation |
| Security | JWT + middleware | Zero Trust, 5 layers, Zanzibar RBAC/ABAC |
| Database | Standard PostgreSQL | Aurora Serverless v2 (3-5x faster, auto-scaling, multi-AZ) |
| Migration | Manual scripts | DMS with CDC (zero-downtime from any legacy DB) |
| Modularity | Feature flags | True module system with deps, migrations, permissions, events |
| Cross-module | Hardcoded foreign keys | Universal entity linking (any-to-any) |
| Deployment | Manual setup per project | One CDK command provisions a full product cell |
| Reliability | Alerts on errors | SLO-driven error budgets (Google SRE model) |
| Observability | Logs only | Correlated logs + metrics + traces |
Key Architecture Decisions & Rationale
| Decision | Why | Alternatives Considered |
|---|---|---|
| Prisma as ORM | Type-safe, auto-migrations, built-in connection pooling. 3M+ weekly npm downloads. Used by Netflix, Vercel, Priceline. | Drizzle (thinner, closer to SQL, less mature ecosystem) |
| NestJS backend | TypeScript full stack, built-in module system matches architecture, strong DI, monorepo-friendly | FastAPI (great perf, but adds Python as second language) |
| ECS Fargate for backend | Persistent connections (Aurora, Redis, WebSockets), no cold starts, connection pooling, predictable cost | Lambda (kills DB with per-invocation connections, 15min timeout, no WS) |
| Field-based change tracking | Generic diff breaks on HTML tags, misidentifies changes. Field-level tracking is reliable and maps to document structure. | diff npm package (explicitly abandoned for contract revisions) |
| TipTap for rich text | ProseMirror-based, extensible via custom marks (track changes), HTML content, React integration | Slate (less mature), Quill (less extensible), CKEditor (heavy) |
| Cheerio for HTML parsing | Lightweight, jQuery-like API for finding data-field elements and injecting highlights. No browser needed. | jsdom (heavier), node-html-parser (less API) |
| CDK for IaC | Same TypeScript as app code, first-class AWS support, composable constructs, type-safe | Terraform (better for multi-cloud, but we are AWS-only) |
| Aurora Serverless v2 | 3-5x faster, auto-scales 0.5-128 ACUs, 6-way replicated, read replicas with under 20ms lag, 100% PG compatible | Standard RDS PostgreSQL (cheaper at small scale, no auto-scaling) |
| Vercel for frontend | Best Next.js DX, instant preview deploys per PR, edge SSR, fast builds | Amplify (slower builds), CloudFront+S3 (no SSR) |
| Media Processing excluded | Too niche. Only needed for products with image/video requirements. Not universal. | Was considered for Tier 2 baseline but cut |