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:
| Failure | Response |
|---|---|
| MongoDB unreachable | <NoMongoDB> full-screen error (no redirect) |
| Org not found | Redirect to /{locale}/event/org-not-available |
| Locale not configured | Redirect to /{defaultLocale}/event/language |
| Private org, no membership | Redirect 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:
| Suffix | Meaning |
|---|---|
RO | Read-only — safe to call in layout / server components |
RW | Read-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
| Layer | Tool | What it captures |
|---|---|---|
| Structured logs | Pino | All server-side operations (254 log statements) |
| Log storage | Axiom | Ships Pino logs to queryable storage in production |
| Error tracking | Sentry | Unhandled errors in routes, server actions, and the browser |
| Session Replay | Sentry | Browser 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
| File | Role |
|---|---|
src/proxy.js | Next.js middleware: locale validation, redirect guard |
src/app/[locale]/(app)/layout.jsx | Tenant resolution, MongoDB fetch, auth check, app shell |
src/app/[locale]/(app)/page.jsx | ROPA dashboard |
src/app/[locale]/(app)/e/[id]/page.jsx | Activity editor |
src/app/[locale]/[id]/page.jsx | Public ROPA view / export |
src/app/api/webhooks/clerk/route.js | Clerk webhook: org update + delete |
src/services/mongodb.js | Mongoose connection singleton, withTransaction |
src/services/organizationService.js | Tenant CRUD, Clerk sync |
src/services/ropaService.js | ROPA document management |
src/services/activityService.js | Activity CRUD |
src/services/templateService.js | Handlebars rendering + cache |
src/services/exportService.js | PDF / HTML / RTF export |
src/lib/auth/requireOrgAccess.js | Clerk membership check, role guard |
src/lib/clerk/TenantSync.jsx | Syncs Clerk active org to current subdomain |
src/lib/url/getHostnameServer.js | Extracts shortName from request headers |
src/lib/utils/logger.js | Pino logger (server) + browser console fallback |
src/instrumentation.js | Next.js instrumentation hook — Sentry server init |