Docs / Developer
System architecture overview
One deployable Next.js 16 application, one PostgreSQL database, multi-tenant by subdomain. 1PVN is a modular monolith organized into 23 business domain modules and 18 shared platform services, deployed to Fly.io in the EU. This article orients engineers to the codebase shape, the stack, how tenants are isolated, and how the AI provider chain works.
Audience: Engineers joining the project or onboarding to a new area of the codebase.
Prerequisites: Familiarity with Next.js App Router and TypeScript. For protected-path and release rules, read docs/onboarding/PROTECTED_PATHS.md and docs/onboarding/RELEASE_CHECKLIST.md before touching shared infrastructure.
Overview
What is a modular monolith?
A modular monolith is a single deployable application whose internal code is divided into strictly bounded business modules. Compared to microservices:
- One Docker image to build, one process to run, one database to operate.
- Each module owns its data access, business logic, and public API surface.
- Cross-module calls go through the module's
index.tsexports only — never into internal files. - A bug in one module does not crash others; shared infrastructure is centralized, not duplicated.
This is the right tradeoff for a team running an EU-resident compliance platform for 50+ chambers.
Core principles
- One codebase, one deploy. The entire application ships as a single image to Fly.io. No per-domain services or separate infrastructure.
- Strict module boundaries. Every feature lives under
src/lib/modules/. Cross-module imports are allowed only from the target module'sindex.ts. - Tenant isolation. A single database serves all chambers. Row-level security (RLS) policies and
tenantQuery()helpers enforce that one tenant can never read another's rows. - EU-first data handling. System-of-record data and the app runtime stay in the EU. Non-EU AI providers receive prompts only after PII redaction.
- Single source of truth. One PostgreSQL instance, one auth service, one audit trail.
Modular monolith at a glance
Folder layout
src/
├── app/ # Next.js App Router (routes, layouts)
│ ├── (public)/ # Unauthenticated routes (submit, register, track, checkin, agenda, respond, feedback)
│ ├── (authenticated)/ # Staff workbench (requires staff role or higher)
│ ├── (master)/console/ # Platform admin console (requires platform-admin)
│ ├── console/ # Tenant picker (authenticated users with multi-tenant access)
│ ├── api/ # REST API endpoints
│ └── auth/ # OAuth callbacks, session setup, handoff, pending
│
├── lib/
│ ├── modules/ # 23 domain modules
│ │ └── <module>/
│ │ ├── types.ts # TypeScript types and enums
│ │ ├── validators.ts # Zod input schemas
│ │ ├── repository.ts # Database queries
│ │ ├── services.ts # Business logic
│ │ ├── actions.ts # Next.js Server Actions (role-gated entry points)
│ │ └── index.ts # Public API — the only file other modules may import
│ │
│ ├── platform/ # 18 shared infrastructure services
│ │ └── <service>/ # ai, audit, auth, cache, console, db, email,
│ │ # ocr, qr, realtime, sentry, signatures,
│ │ # storage, stt, telegram, tenant, tokens, workflows
│ │
│ └── shared/ # Reusable utilities with no business logic
│ ├── ui/ # shadcn/ui components
│ ├── validators/ # Zod schemas shared across modules
│ ├── types/ # Global TypeScript types
│ └── utils/ # Helpers
│
└── components/ # React UI components (page-level, forms)
supabase/
├── migrations/ # SQL migrations (append-only, ~54 files)
└── seed/ # Test data (orgs, contacts)
How a module is structured
Every module exposes exactly the same internal file pattern:
src/lib/modules/pipeline/
├── types.ts # Opportunity, PipelineStage, Priority, StageHistoryEntry
├── validators.ts # CreateOpportunityInput, UpdateOpportunityInput
├── repository.ts # SQL: createOpportunity, getOpportunity, listOpportunities …
├── services.ts # Business logic: advanceStage, scoreOpportunity …
├── actions.ts # Server Actions: createOpportunityAction (role-gated)
└── index.ts # Exports: getOpportunity, createOpportunity, advanceStage …
index.ts is the contract. It lists what other modules and routes may call.
Cross-module call rules
// CORRECT — import from the module's public index
import { getOpportunity } from "@/lib/modules/pipeline";
const opp = await getOpportunity(id);
// WRONG — reaches into internal file, violates module boundary
import { getOpportunityRow } from "@/lib/modules/pipeline/repository";
Domain modules and platform services
23 domain modules
| Group | Modules |
|---|---|
| Auth & access | auth |
| Core investment facilitation | intake, stakeholders, pipeline, consultations, matching, outputs, communications |
| Governance | approvals, forms, issues, workflow-states, feedback, ops-control, admin, analytics |
| Support & cross-cutting | automation, search, geo, tags, notifications, captures, signatures |
Auth handles Google OAuth, email/password login, role hierarchy (public → lgu → staff → reviewer → lead → admin), and session verification per tenant.
Intake manages the public 3-step submission wizard, investor/LGU organization registration, submission tracking codes, and QR-based event check-in.
Stakeholders is the CRM: organizations (10 types), contacts with lead funnel status (new → contacted → qualified → unqualified → converted), investor profiles, and engagement history.
Pipeline tracks investment opportunities through 8 stages: submitted → intake_review → screening → development → matchmaking → facilitation → closed / on_hold. Manages scoring, priority, stage gates, and SLA monitoring.
Consultations manages events (9 types including meeting, workshop, roundtable, webinar, field visit), attendee RSVP lifecycle, agendas, AI-assisted minutes extraction, QR check-in, and document linking.
Matching algorithmically scores investor profiles against opportunities using configurable dimensions, and orchestrates B2B meeting scheduling.
Outputs handles documents (concept notes, policy briefs, reports, minutes, agreements) with versioning, status lifecycle (draft → approved → locked), template variable resolution ({{opportunity.title}}), and Documenso e-signature integration.
Communications manages multi-channel campaigns, audience segments, email/social scheduling, campaign response collection, and the append-only contact activity ledger.
Approvals enforces multi-step approval chains (configurable steps, role gates, SLA hours) on documents, opportunities, and consultations.
Forms provides a dynamic form engine with 8+ field types, optional scoring, and public or staff-only visibility.
Issues is a configurable issue tracker with 4 seeded types (policy_issue, recommendation, training_need, closeout_item) and custom fields per type.
Workflow-states provides reusable state machines for issues, documents, and policies with optional gate checks (approval chain, form fill, event trigger).
Ops-control governs feature delivery (spec → classified → released), change requests, incidents (open → resolved), feature flags, and maintenance windows.
Admin handles user role management, audit log viewing, GDPR data subject rights (access/erasure), system counts, and email template builder.
Analytics computes daily KPI snapshots, anomaly detection vs. 30-day rolling average, and materialized views for dashboards.
Search provides staff-gated federated full-text search across 8 entity types (organizations, contacts, opportunities, consultations, submissions, documents, issues, forms), rate-limited to 60 requests per minute per user.
Geo provides the Philippine PSGC administrative hierarchy (regions, provinces, municipalities, barangays) for location tagging.
Automation provides the admin UI for configuring and manually triggering the 15 Inngest workflows.
Captures is a generic model for market signals and objections linked to contacts or opportunities.
Feedback handles friction signal intake, bug/friction reports, and triage routing to ops-control.
Notifications sends branded internal email alerts to staff users for workflow events (approvals cleared, assignments).
Tags adds arbitrary string labels to any entity type for categorization.
Signatures wraps the Documenso Cloud integration for multi-party e-signing with field placement, webhook status tracking, and document locking on completion.
18 platform services
| Service | Purpose |
|---|---|
db | Postgres.js client, typed helpers (dbQuery, dbQueryOne), tenant-scoped wrappers (tenantQuery, tenantExecute) |
auth | Supabase Auth client initialization, session management |
ai | Mistral → Gemini → Haiku provider chain with PII redaction for non-EU providers |
ocr | PDF (pdf-parse), DOCX (mammoth), image (Tesseract.js + Google Document AI) text extraction |
storage | Cloudflare R2 (S3-compatible), presigned URLs, multi-tenant file paths |
email | Resend transactional email, template rendering, delivery lifecycle logging |
cache | Upstash Redis sliding-window rate limiting and caching |
workflows | Inngest serverless functions — 15 registered, event-driven and cron-triggered |
realtime | Supabase Realtime PostgreSQL change subscriptions for live dashboard updates |
audit | Append-only audit_events table; all mutations must log here |
signatures | Documenso Cloud API: envelope create, distribute, webhook events, signed PDF download |
sentry | Exception capture, layer/severity classification, Telegram forwarding for critical errors |
qr | QR code PNG generation (320×320) for attendee check-in URLs |
tenant | Host classification, tenant resolution from subdomain, handoff token flow |
console | Platform admin routing, tenant registry CRUD, federation management |
tokens | JWT generation/verification for single-use links (RSVP, handoff, campaign responses) |
stt | Speech-to-text via Mistral Voxtral (EU-resident) for mobile voice capture |
telegram | Bot command routing (/status, /errors, /deals, /approve …) and operational alerts |
The stack and EU residency
| Layer | Technology | EU resident |
|---|---|---|
| Framework | Next.js 16 (App Router) + TypeScript + React 19 | ✅ EU (Fly.io) |
| Database | Supabase PostgreSQL (same project as Auth) | ✅ EU region |
| Auth | Supabase Auth — Google OAuth + email/password | ✅ EU region |
| Hosting | Fly.io — Paris (cdg), Frankfurt (fra), or Amsterdam (ams) | ✅ EU |
| Cache / Queues | Upstash Redis (EU region) | ✅ EU |
| File storage | Cloudflare R2 (EU jurisdiction) | ✅ EU |
| Async workflows | Inngest (serverless, TypeScript, in-repo) | ⚠️ Verify GDPR terms |
| Resend | ⚠️ Verify SLA | |
| Error tracking | Sentry (EU endpoint available) | ⚠️ Verify endpoint config |
| AI primary | Mistral — mistral-large-latest (EU, France) | ✅ EU (France) |
| AI fallback 1 | Google Gemini — gemini-2.5-flash (non-EU) | ❌ Non-EU; PII filtered |
| AI fallback 2 | Claude Haiku — claude-haiku-4-5-20251001 (non-EU) | ❌ Non-EU; PII filtered |
| OCR | Tesseract.js (client-side) + Google Document AI (EU multi-region) | ✅ Local + EU |
| E-signature | Documenso Cloud | ⚠️ Verify residency |
| Styling | Tailwind CSS 4 + shadcn/ui | ✅ No data |
EU compliance model
Primary system-of-record (EU-only): Supabase PostgreSQL, Fly.io app runtime, Upstash Redis, Cloudflare R2.
Non-EU with partial mitigation: Gemini and Claude Haiku are fallbacks only and receive prompts only after PII redaction (see AI provider chain and governance). Groq is deliberately excluded — US servers are not an acceptable processor for this data.
The full service-by-service policy lives in docs/architecture/EU-DATA-RESIDENCY-SPEC.md.
Multi-tenancy model
Tenant isolation by subdomain
Each chamber is a separate tenant identified by its subdomain:
https://eccp.1pvn.com → ECCP Secretariat (tenant)
https://gepp.1pvn.com → GeoPP Chamber (tenant)
https://app.1pvn.com/console → Platform admin console (NOT a tenant)
classifyHost() in src/lib/platform/tenant/resolve.ts drives this:
- Single-label subdomain of
1pvn.com→ tenant (resolved from DB by subdomain) app.1pvn.com,www.1pvn.com, apex, or nested label → platform (no tenant context)*.fly.dev,localhost→ foreign (may fall back toTENANT_FALLBACK_SUBDOMAINin non-production environments only)
Request flow
- Request hits Fly.io →
classifyHost()readsx-forwarded-host. - If tenant:
getRequestTenant()looks uptenant_idfrom thetenantstable by subdomain. tenantQuery()injects the resolvedtenant_idinto every subsequent SQL query.- If no tenant context resolves,
tenantQuery()throws"Refusing tenant-scoped query (fail-closed)"— never returns an unscoped result set.
Database scoping via RLS
All tenant-scoped tables have a tenant_id column. RLS policies enforce row-level isolation. Even if a query omits the tenant_id filter, the policy returns 0 rows rather than leaking data.
-- Example: opportunities table RLS
CREATE POLICY "Staff sees own tenant data" ON opportunities
FOR ALL USING (tenant_id = current_tenant_id());
Use tenantQuery() (from src/lib/platform/db/tenant-query.ts) for all tenant-scoped queries. The helper resolves tenant_id from the request context (or an explicit withTenant() wrapper for background jobs) and injects it automatically.
Staff allowlists (per-tenant access control)
Each tenant has two fields that control who can sign in:
staff_email_domains— wildcard domain matching (e.g.,@eccp.com)staff_email_allowlist— exact email matching for special guests
Both checks are case-insensitive. If neither matches, the login route returns HTTP 403 "Access restricted to authorized accounts only". Platform admins manage these from /console/tenants.
Platform admin console (cross-tenant navigation)
Super-users (platform admins) can access all chambers:
- Log in to
app.1pvn.comwith Google OAuth. /consolelists accessible tenants viaresolveTenantsForEmail(email).- Click a tenant → server mints a handoff token (single-use JWT, 60-second TTL, stored in Redis).
- Browser redirects to
https://<subdomain>.1pvn.com/auth/handoff?token=…. - The handoff route validates the token (atomic
getdelin Redis — prevents replay), sets the session, and redirects to/dashboard.
Audit events are logged at both mint (console.handoff_minted) and redeem (console.handoff_redeemed).
AI provider chain and governance
Three-provider strategy
The chain is defined in src/lib/platform/ai/config.ts:
| Priority | Provider | Model | EU resident | Prompt handling |
|---|---|---|---|---|
| 1 (primary) | Mistral | mistral-large-latest | ✅ France | Full-fidelity |
| 2 (fallback) | Google Gemini | gemini-2.5-flash | ❌ Non-EU | PII-redacted |
| 3 (last resort) | Claude Haiku | claude-haiku-4-5-20251001 | ❌ Non-EU | PII-redacted |
The same three-provider chain applies to every task type:
| Task type | Chain |
|---|---|
minutes_summary | mistral → gemini → claude_haiku |
policy_draft | mistral → gemini → claude_haiku |
form_assist | mistral → gemini → claude_haiku |
email_draft | mistral → gemini → claude_haiku |
classification | mistral → gemini → claude_haiku |
ocr_normalize | mistral → gemini → claude_haiku |
report_narrative | mistral → gemini → claude_haiku |
How the chain executes
generateText() in src/lib/platform/ai/generate.ts iterates the chain. On each iteration it checks whether the provider has an API key configured (hasApiKey()), then calls the provider. If the provider throws, it logs a warning and moves to the next. If all providers fail, it throws AiAllProvidersFailedError.
For non-EU providers, requestForProvider() applies redactPII() to the prompt and system prompt before the call, unless skipRedaction: true is set by the caller.
PII redaction (what it covers)
redactPII() in src/lib/platform/ai/redact.ts applies three regex patterns:
| Pattern | Replacement |
|---|---|
| Email addresses | [EMAIL] |
| Phone numbers (international + local shapes, 7–15 digits) | [PHONE] |
| Long digit runs (9+ consecutive digits — TINs, account numbers) | [ID] |
The redaction deliberately does not attempt to strip names or organization names — doing so would require named-entity recognition and risks corrupting the prompt. Callers that route to a verified EU-resident provider (Mistral) may pass skipRedaction: true to send the full-fidelity prompt.
Handling provider failures
Wrap calls to generateText() with a try/catch for AiAllProvidersFailedError. Show a user-friendly message and log the full error chain to Sentry for ops review.
Where to go next
After this article, read these in order:
docs/onboarding/NEW_DEVELOPER_ONBOARDING.md— system shape, lanes, workflowdocs/onboarding/TEAM_OPERATING_MODEL.md— ownership, boundaries, sandbox rulesdocs/onboarding/PROTECTED_PATHS.md— what not to touch without explicit intentdocs/architecture/EU-DATA-RESIDENCY-SPEC.md— full service-by-service residency policy
Examples
Example 1: Cross-module call
The matching module needs an opportunity. It calls pipeline's public interface.
// src/lib/modules/matching/services.ts
import { getOpportunity } from "@/lib/modules/pipeline"; // index.ts export
export async function scoreMatch(opportunityId: string) {
const opp = await getOpportunity(opportunityId);
if (!opp) throw new Error("Opportunity not found");
return computeScore(opp);
}
Never import from @/lib/modules/pipeline/repository directly.
Example 2: Tenant-scoped query
// Correct — tenantQuery injects tenant_id automatically
const opps = await tenantQuery(
`SELECT * FROM opportunities WHERE stage = $1 AND is_archived = false`,
["development"]
);
// Wrong — manual tenant_id filtering is error-prone and bypasses the helper
const opps = await getSql()(
`SELECT * FROM opportunities WHERE stage = $1 AND tenant_id = $2`,
["development", manualTenantId]
);
Example 3: AI provider fallback in practice
When Mistral is unavailable, the chain automatically falls back to Gemini with a redacted prompt:
generateText({ taskType: "minutes_summary", prompt: "Summarize: [meeting transcript]" })
1. Try Mistral (full prompt) → HTTP 500 from Mistral API
2. Log warning, continue
3. Try Gemini (redacted prompt: emails/phones/long IDs replaced) → success
4. Return { text: "…", provider: "gemini", model: "gemini-2.5-flash" }
Example 4: Event email workflow
When an attendee registers for a consultation:
1. Staff or public user calls addAttendeeAction()
2. Action fires Inngest event: "consultation/attendee.registered"
3. eventEmailConfirmation workflow runs:
- Fetch consultation, attendee, email settings
- Render confirmation email (iCal attachment, QR code, venue/logistics block, agenda link)
- sendAndLogEmail() via Resend
- If sendConfirmation enabled and scheduledAt is set:
- Schedule reminder events at 7d, 1d, 1h before (if each flag is enabled)
- Schedule thank-you (T+2h) and follow-up (T+3d)
4. Attendee receives confirmation with calendar invite and QR check-in code
Best practices
Module development
-
Expose only what must be shared. Add to
index.tsonly when another module or route genuinely needs the function. Keep business logic inservices.ts, not in actions. -
Validate in
actions.ts, logic inservices.ts.// actions.ts — validate and delegate export async function createOpportunityAction(formData: FormData) { const user = await requireRole("staff", { module: "pipeline" }); const input = CreateOpportunitySchema.parse(Object.fromEntries(formData)); return createOpportunity(input, user.id); } -
Log every mutation to audit_events.
await logAudit({ action: "opportunity.created", entity: "opportunity", entityId: opp.id, userId: user.id, metadata: { title: opp.title, stage: opp.stage }, }); -
Use
tenantQuery()for all tenant-scoped tables. Never callgetSql()directly for queries that must be tenant-filtered.
Multi-tenancy safety
- Resolve the tenant from
x-forwarded-hostheaders only — never from URL params or request body. - Test that one tenant cannot read another's data by running queries with different tenant contexts.
- New tables need both a
tenant_idcolumn and an RLS policy in the same migration file.
AI governance
- Keep prompts short and task-specific to reduce token cost and latency.
- Never pass
skipRedaction: truefor prompts that include user-submitted content (e.g., form responses, meeting transcripts). - Handle
AiAllProvidersFailedErrorin calling code — do not let it bubble to the user as an unhandled error.
Warnings
- Do not import across module internal boundaries. Only
index.tsexports are part of the contract. Importing fromrepository.tsorservices.tsof another module creates hidden coupling that breaks refactors. - Do not run tenant-scoped queries without
tenantQuery(). The helper throws if no tenant context exists, which is the correct behavior — a missing context means the request was not properly routed. - Do not edit migration files. Migrations are append-only SQL history. Fix schema issues with a new migration file.
- Do not send unredacted prompts to non-EU providers. The
generateText()function handles this automatically whenskipRedactionis not set. If you bypassgenerateText()and call a provider directly, PII redaction is your responsibility. - Do not add Groq as a provider. It is excluded because its US servers are not an acceptable data processor for this project's residency requirements.
- Inngest workflows are asynchronous. Do not call
inngest.send()and then immediately query for the result — the workflow runs out of band.
Troubleshooting
"Access restricted to authorized accounts only"
Cause: The user's email is not in staff_email_domains or staff_email_allowlist for the tenant they are signing into.
Fix:
- Platform admin opens
/console/tenantsonapp.1pvn.com. - Expands the tenant's allowlist panel.
- Adds the user's email domain or exact email.
- User signs out and signs in again (session cache clears within about 1 minute).
Module A cannot import from module B
Cause: The function is not exported in module B's index.ts.
Fix:
- Open
src/lib/modules/<module-b>/index.ts. - Add the export:
export { myFunction } from "./services"; - Import from the index:
import { myFunction } from "@/lib/modules/module-b".
Query returns 0 rows unexpectedly
Cause 1: tenantQuery() resolved the wrong or missing tenant.
Fix: Check that the request arrives with a valid x-forwarded-host subdomain. Add logging around getRequestTenant() to confirm the resolved tenantId.
Cause 2: RLS policy is filtering the rows.
Fix: Verify the row's tenant_id matches the resolved tenant. Check that the current user's role satisfies the policy. Always write an RLS policy alongside each new table migration.
AI provider call throws AiAllProvidersFailedError
Cause: All three providers failed (missing API key, network error, or rate limit).
Fix:
- Check that
MISTRAL_API_KEY,GEMINI_API_KEY, andANTHROPIC_API_KEYare set in the environment. - Check Sentry for the per-provider error messages in the error chain.
- Check each provider's status page.
- As a temporary measure, disable non-essential AI tasks until the primary provider recovers.
Handoff token fails with a login redirect
Cause: The 60-second TTL expired, the token was already used (atomic getdel consumed it), or Redis was unavailable when the token was minted.
Fix: Platform admin clicks the tenant in /console again — this mints a fresh token. Ensure the browser completes the redirect quickly after mint.
FAQ
Q: How do I add a new domain module?
Create src/lib/modules/<module-name>/ with the standard files: types.ts, validators.ts, repository.ts, services.ts, actions.ts, index.ts. Export only the public interface from index.ts. Add a migration for any new tables, including the tenant_id column and an RLS policy.
Q: Can modules call each other synchronously?
Yes, if the called function is compute-only. If it hits the database, it is async. Calls that must survive a process crash (multi-step orchestration) belong in an Inngest workflow, not a direct service call.
Q: How do I query across tenants as a platform admin?
Platform admins have cross-tenant visibility via the console platform service. For direct cross-tenant queries, use the raw getSql() client and filter manually by tenant_id. Always log these queries to audit_events.
Q: What happens when a non-staff user (role='public') tries to access the workbench?
Routes under (authenticated)/ call requireRole("staff"). The function checks ROLE_HIERARCHY[user.role] >= ROLE_HIERARCHY["staff"]. For public (value 0) against staff (value 2), the check fails and an AuthError("Access denied", "UNAUTHORIZED") is thrown. The OAuth callback redirects public-role users to /auth/pending rather than /dashboard.
Q: How are daily KPI snapshots computed?
The computeDailySnapshot Inngest workflow runs every midnight Manila time (TZ=Asia/Manila 0 0 * * *). It calls buildSnapshot() from the analytics module, compares to the 30-day rolling average with detectAnomalies(), and upserts the result into daily_snapshots. Snapshots are retained for 365 days per the GDPR retention policy.
Q: Where do I put shared utility functions?
src/lib/shared/utils/ for helpers with no business logic. If a utility is specific to a domain (e.g., a stage label formatter), it belongs in that module's types.ts or a dedicated helper file within the module — not in shared/.
Q: How does Inngest guarantee workflow delivery?
Inngest retries failed workflow steps with exponential backoff. Idempotency keys (per event data, e.g., fileKey in the stakeholder sync) prevent duplicate runs for the same input. Check src/lib/platform/workflows/functions/ for per-workflow retry counts.
Related articles
- API and webhooks overview — the HTTP surface: public routes, staff-gated routes, and webhook handlers
- Roles and access — the role hierarchy this architecture enforces
- Master console overview — the platform-admin console and tenant provisioning
- Deeper developer references (multi-tenancy model, AI provider chain, storage, Inngest workflows, observability) are planned for Wave 3 — see the roadmap. Until then, the in-repo docs under
docs/architecture/anddocs/operations/are authoritative.