Skip to main content

Architecture

ropa2.0 is a multi-tenant SaaS application for GDPR Record of Processing Activity (ROPA) management. Each tenant is an organization that independently configures and publishes its ROPA. The application is a Next.js App Router monolith backed by MongoDB.


High-Level Overview

Browser

├─ <shortName>.<domain> Subdomain identifies the tenant


Next.js App (Vercel)

├─ src/proxy.js (middleware) Locale validation, redirect guard

├─ src/app/[locale]/(app)/ Main app — locale + tenant scoped
│ ├─ layout.jsx Tenant resolution, auth, org data fetch
│ ├─ page.jsx ROPA dashboard
│ ├─ e/[id]/ Activity editor
│ ├─ ropa/ ROPA-level actions (server actions)
│ └─ event/ Error event pages

├─ src/app/[locale]/[id]/ Public ROPA export (PDF / HTML / RTF)

└─ src/app/api/
├─ webhooks/clerk/ Clerk org lifecycle events
├─ locale-default/ Subdomain → default locale resolution
└─ admin/ Protected admin operations


Services layer (src/services/)

├─ organizationService Tenant CRUD, Clerk linking, logo sync
├─ ropaService ROPA document management
├─ activityService Data processing activity CRUD
├─ ouService Organisational unit hierarchy
├─ contractService Data processing agreements
├─ templateService Handlebars template rendering + cache
├─ exportService PDF / HTML / RTF generation
├─ importService JSON / CSV data import
└─ mongodb.js Singleton connection + transaction helper


MongoDB Atlas
└─ Collections: Organization, Ropa, Activity, Contract, Partner,
OrganizationalUnit, Template, TemplatePreview, Account

Multi-Tenancy

Tenant isolation is enforced through subdomains and shortName.

demo.rat.gd → shortName = "demo"
acme.rat.gd → shortName = "acme"

shortName is the stable, immutable tenant identifier. It is used:

  • As the MongoDB query key on every collection
  • As the Clerk organization slug (kept in sync via webhook)
  • As the subdomain

Every database read in the services layer accepts shortName as the first argument. There is no shared data between tenants.

Server-side tenant resolution: getHostnameServer() reads the host request header and strips the domain suffix. Fallback is demo.

Client-side tenant resolution: getSubdomainClient() reads window.location.hostname with the same logic.


Request Lifecycle

A typical page request flows through four layers:

1. Middleware (src/proxy.js)
└─ Validates locale segment in the URL path.
Redirects unsupported locales before any page handler runs.

2. App Layout (src/app/[locale]/(app)/layout.jsx)
├─ Resolves shortName from the Host header
├─ Fetches Organization from MongoDB (5 s timeout)
├─ Checks locale is configured for this tenant
├─ Checks membership (Clerk) for private orgs
├─ Fetches the Ropa document for the current locale
└─ Renders shell: MiniDrawer + OrganizationProvider + children

3. Page / Server Action
└─ Receives org and ropa context via OrganizationProvider.
Calls service functions for mutations.

4. Services layer
└─ All reads/writes go through here.
Services call Mongoose models directly.
Mutations that span multiple collections use withTransaction().

On failure at layer 2, the layout short-circuits:

FailureResponse
MongoDB unreachable<NoMongoDB> full-screen error (no redirect)
Org not foundRedirect to /{locale}/event/org-not-available
Locale not configuredRedirect to /{defaultLocale}/event/language
Private org, no membershipRedirect to /{locale}/event/org-not-available

See errors.md for the full error routing map.


Authentication & Access Control

Authentication is provided by Clerk. The app uses Clerk Organizations to model tenants.

Clerk session
└─ userId + orgId (set by TenantSync.jsx to match the subdomain)

getOrgMembership(shortName) src/lib/auth/requireOrgAccess.js
└─ Returns { role: "admin"|"basic_member"|null }
null = authenticated but not a member of this tenant

requireOrgAccess(shortName, role?) src/lib/auth/requireOrgAccess.js
└─ Throws if user lacks the required role
Used in all write server actions

TenantSync.jsx (client component in the app layout) ensures Clerk's active organization always matches the subdomain, regardless of which tenant the user last visited.

Public vs. private orgs: organization.isPublic controls whether unauthenticated users can view a tenant's ROPA. Even on public orgs, write operations always require authentication and org membership.


Data Model

Key entities and their relationships:

Organization
├─ ropas[] → Ropa (one per locale)
├─ partners[] → embedded Partner sub-documents (own org = id 0)
├─ contracts[] → embedded Contract sub-documents
└─ clerkOrganizationId

Ropa
├─ shortName tenant key
├─ locale
├─ ous[] → OrganizationalUnit (hierarchical tree)
│ └─ ous[] → nested sub-OUs (recursive)
└─ activities[] → Activity IDs

Activity
├─ shortName tenant key
├─ legalBasis enum (GDPR Art. 6 legal bases)
├─ dataCategories[] enum (personal data categories)
├─ recipients[] → Partner references
├─ contracts[] → Contract references
└─ securityLevel enum

Organizational Units form a tree. Each OU holds a list of Activity IDs. The ROPA document is the root container; activities are fetched by ID and joined at read time.

Templates are Handlebars documents stored in MongoDB. templateService renders them against the flattened ROPA data and caches the result in TemplatePreview.


Services Layer

All business logic lives in src/services/. Each service file exports pure functions — no class instances, no global state.

Function naming conventions:

SuffixMeaning
RORead-only — safe to call in layout / server components
RWRead-write — requires auth, called from server actions only
(none)Internal utility, not exported from barrel

Service functions return a consistent result shape:

{ isError: false, data: <result> } // success
{ isError: true, message: <string> } // failure

Callers check result.isError before using result.data. Errors are logged inside the service with logError() before returning the failure object.

Transactions: withTransaction(callback) in mongodb.js wraps multi-collection writes in a MongoDB session. The callback receives the session; if it throws, the transaction is automatically rolled back.


Internationalisation

Locale handling uses next-intl with the App Router.

URL locale segment
/en/... /de/... /fr/...

src/lib/i18n/request.js resolves messages for the current locale
messages/ JSON message files per locale (precompiled at build time)
src/proxy.js intercepts unsupported locale segments

Each Organization configures one or more ROPAs, one per locale. The locale field on a Ropa document maps to the next-intl locale. The organization's default locale is the ROPA marked isDefault: true.

When a visitor arrives at /<unsupportedLocale>/, the middleware redirects to the org's default locale language event page.


Export Pipeline

ROPA documents can be exported as PDF, HTML, or RTF.

User requests export (locale URL or admin panel)

├─ exportService.exportRopa(shortName, ropaId, format)

├─ Fetches full org + ropa + activities from MongoDB

├─ templateService.renderTemplate(templateId, data)
│ ├─ Loads Handlebars template (MongoDB or filesystem fallback)
│ ├─ sanitizeHandlebars() — strips unsafe template constructs
│ └─ Handlebars.compile(template)(data)

└─ Format-specific renderer
├─ PDF — Chromium headless via @sparticuz/chromium + pdf-lib
├─ HTML — sanitize-html to strip remaining unsafe markup
└─ RTF — custom RTF serialiser

Export routes live at src/app/[locale]/[id]/ (public, no auth required for public orgs).


Telemetry

LayerToolWhat it captures
Structured logsPinoAll server-side operations (254 log statements)
Log storageAxiomShips Pino logs to queryable storage in production
Error trackingSentryUnhandled errors in routes, server actions, and the browser
Session ReplaySentryBrowser errors with masked text (GDPR safe)

See telemetry.md for setup.

Log flow:

Server component / service
└─ logger.info / logError()
├─ stdout (always) → Vercel log drain → Axiom
└─ @axiomhq/pino transport → Axiom directly (production only)

Client component
└─ toastError / toastSuccess
├─ toast notification → user sees it
├─ logger.error (console) → browser devtools
└─ logToServer() server action → Pino server logger

Error boundary (error.jsx)
└─ Sentry.captureException() → Sentry dashboard

Key Files

FileRole
src/proxy.jsNext.js middleware: locale validation, redirect guard
src/app/[locale]/(app)/layout.jsxTenant resolution, MongoDB fetch, auth check, app shell
src/app/[locale]/(app)/page.jsxROPA dashboard
src/app/[locale]/(app)/e/[id]/page.jsxActivity editor
src/app/[locale]/[id]/page.jsxPublic ROPA view / export
src/app/api/webhooks/clerk/route.jsClerk webhook: org update + delete
src/services/mongodb.jsMongoose connection singleton, withTransaction
src/services/organizationService.jsTenant CRUD, Clerk sync
src/services/ropaService.jsROPA document management
src/services/activityService.jsActivity CRUD
src/services/templateService.jsHandlebars rendering + cache
src/services/exportService.jsPDF / HTML / RTF export
src/lib/auth/requireOrgAccess.jsClerk membership check, role guard
src/lib/clerk/TenantSync.jsxSyncs Clerk active org to current subdomain
src/lib/url/getHostnameServer.jsExtracts shortName from request headers
src/lib/utils/logger.jsPino logger (server) + browser console fallback
src/instrumentation.jsNext.js instrumentation hook — Sentry server init