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.ts exports 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

  1. One codebase, one deploy. The entire application ships as a single image to Fly.io. No per-domain services or separate infrastructure.
  2. Strict module boundaries. Every feature lives under src/lib/modules/. Cross-module imports are allowed only from the target module's index.ts.
  3. 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.
  4. 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.
  5. 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

GroupModules
Auth & accessauth
Core investment facilitationintake, stakeholders, pipeline, consultations, matching, outputs, communications
Governanceapprovals, forms, issues, workflow-states, feedback, ops-control, admin, analytics
Support & cross-cuttingautomation, 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

ServicePurpose
dbPostgres.js client, typed helpers (dbQuery, dbQueryOne), tenant-scoped wrappers (tenantQuery, tenantExecute)
authSupabase Auth client initialization, session management
aiMistral → Gemini → Haiku provider chain with PII redaction for non-EU providers
ocrPDF (pdf-parse), DOCX (mammoth), image (Tesseract.js + Google Document AI) text extraction
storageCloudflare R2 (S3-compatible), presigned URLs, multi-tenant file paths
emailResend transactional email, template rendering, delivery lifecycle logging
cacheUpstash Redis sliding-window rate limiting and caching
workflowsInngest serverless functions — 15 registered, event-driven and cron-triggered
realtimeSupabase Realtime PostgreSQL change subscriptions for live dashboard updates
auditAppend-only audit_events table; all mutations must log here
signaturesDocumenso Cloud API: envelope create, distribute, webhook events, signed PDF download
sentryException capture, layer/severity classification, Telegram forwarding for critical errors
qrQR code PNG generation (320×320) for attendee check-in URLs
tenantHost classification, tenant resolution from subdomain, handoff token flow
consolePlatform admin routing, tenant registry CRUD, federation management
tokensJWT generation/verification for single-use links (RSVP, handoff, campaign responses)
sttSpeech-to-text via Mistral Voxtral (EU-resident) for mobile voice capture
telegramBot command routing (/status, /errors, /deals, /approve …) and operational alerts

The stack and EU residency

LayerTechnologyEU resident
FrameworkNext.js 16 (App Router) + TypeScript + React 19✅ EU (Fly.io)
DatabaseSupabase PostgreSQL (same project as Auth)✅ EU region
AuthSupabase Auth — Google OAuth + email/password✅ EU region
HostingFly.io — Paris (cdg), Frankfurt (fra), or Amsterdam (ams)✅ EU
Cache / QueuesUpstash Redis (EU region)✅ EU
File storageCloudflare R2 (EU jurisdiction)✅ EU
Async workflowsInngest (serverless, TypeScript, in-repo)⚠️ Verify GDPR terms
EmailResend⚠️ Verify SLA
Error trackingSentry (EU endpoint available)⚠️ Verify endpoint config
AI primaryMistral — mistral-large-latest (EU, France)✅ EU (France)
AI fallback 1Google Gemini — gemini-2.5-flash (non-EU)❌ Non-EU; PII filtered
AI fallback 2Claude Haiku — claude-haiku-4-5-20251001 (non-EU)❌ Non-EU; PII filtered
OCRTesseract.js (client-side) + Google Document AI (EU multi-region)✅ Local + EU
E-signatureDocumenso Cloud⚠️ Verify residency
StylingTailwind 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.comtenant (resolved from DB by subdomain)
  • app.1pvn.com, www.1pvn.com, apex, or nested label → platform (no tenant context)
  • *.fly.dev, localhostforeign (may fall back to TENANT_FALLBACK_SUBDOMAIN in non-production environments only)

Request flow

  1. Request hits Fly.io → classifyHost() reads x-forwarded-host.
  2. If tenant: getRequestTenant() looks up tenant_id from the tenants table by subdomain.
  3. tenantQuery() injects the resolved tenant_id into every subsequent SQL query.
  4. 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:

  1. Log in to app.1pvn.com with Google OAuth.
  2. /console lists accessible tenants via resolveTenantsForEmail(email).
  3. Click a tenant → server mints a handoff token (single-use JWT, 60-second TTL, stored in Redis).
  4. Browser redirects to https://<subdomain>.1pvn.com/auth/handoff?token=….
  5. The handoff route validates the token (atomic getdel in 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:

PriorityProviderModelEU residentPrompt handling
1 (primary)Mistralmistral-large-latest✅ FranceFull-fidelity
2 (fallback)Google Geminigemini-2.5-flash❌ Non-EUPII-redacted
3 (last resort)Claude Haikuclaude-haiku-4-5-20251001❌ Non-EUPII-redacted

The same three-provider chain applies to every task type:

Task typeChain
minutes_summarymistral → gemini → claude_haiku
policy_draftmistral → gemini → claude_haiku
form_assistmistral → gemini → claude_haiku
email_draftmistral → gemini → claude_haiku
classificationmistral → gemini → claude_haiku
ocr_normalizemistral → gemini → claude_haiku
report_narrativemistral → 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:

PatternReplacement
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:

  1. docs/onboarding/NEW_DEVELOPER_ONBOARDING.md — system shape, lanes, workflow
  2. docs/onboarding/TEAM_OPERATING_MODEL.md — ownership, boundaries, sandbox rules
  3. docs/onboarding/PROTECTED_PATHS.md — what not to touch without explicit intent
  4. docs/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

  1. Expose only what must be shared. Add to index.ts only when another module or route genuinely needs the function. Keep business logic in services.ts, not in actions.

  2. Validate in actions.ts, logic in services.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);
    }
    
  3. 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 },
    });
    
  4. Use tenantQuery() for all tenant-scoped tables. Never call getSql() directly for queries that must be tenant-filtered.

Multi-tenancy safety

  • Resolve the tenant from x-forwarded-host headers 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_id column 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: true for prompts that include user-submitted content (e.g., form responses, meeting transcripts).
  • Handle AiAllProvidersFailedError in calling code — do not let it bubble to the user as an unhandled error.

Warnings

  • Do not import across module internal boundaries. Only index.ts exports are part of the contract. Importing from repository.ts or services.ts of 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 when skipRedaction is not set. If you bypass generateText() 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:

  1. Platform admin opens /console/tenants on app.1pvn.com.
  2. Expands the tenant's allowlist panel.
  3. Adds the user's email domain or exact email.
  4. 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:

  1. Open src/lib/modules/<module-b>/index.ts.
  2. Add the export:
    export { myFunction } from "./services";
    
  3. 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:

  1. Check that MISTRAL_API_KEY, GEMINI_API_KEY, and ANTHROPIC_API_KEY are set in the environment.
  2. Check Sentry for the per-provider error messages in the error chain.
  3. Check each provider's status page.
  4. 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/ and docs/operations/ are authoritative.