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> shortName 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, organization (tenant) data fetch
│ ├─ page.jsx ROPA dashboard
│ ├─ e/[id]/ Activity editor (admin in edit mode)
│ ├─ v/[id]/ Activity reviewer (member + admin in view mode)
│ ├─ [id]/ [Activity information | Activity declaration] renderer
│ ├─ ropa/ ROPA-level actions (server actions)
│ └─ event/ Error event pages
│
├─ src/app/[locale]/(legal)/ Footer legal pages — no auth, no DB, SSG
│ ├─ about/ About ROPA
│ ├─ terms/ Terms of Service
│ ├─ privacy/ Privacy Policy
│ ├─ licenses/ Third-party open-source licenses (always English)
│ └─ about/third-party-licenses/ Legacy URL alias → same licenses content
│
├─ src/app/[locale]/[id]/ Public activity export (HTML / RTF / PDF) — no auth
│
└─ 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. Route group dispatch
└─ (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.
Navigation
Every "modal" in the app (PartnerDetails, ContractDetails, TemplateEditor, …) is a plain page route rendering a MUI <Dialog open> — there are no Next.js intercepting or parallel routes. Because these modal shells are ordinary pages, the same shell can be reached from several different parent contexts (the flat list, an activity's controller chip, a contract's partner chip, a picker shortcut, …).
Convention: any shell reachable from more than one parent context must use the useReturnTo hook (src/lib/hooks/useReturnTo.ts) for its default Save/Close/Delete/Cancel handlers instead of hardcoding a parent path:
- The link-building side wraps its target in
withReturnTo(path), which attaches the current location as areturnToquery param. - The shell side calls
navigateBack(fallback)in its default handlers — it pushesreturnTowhen present and safe, and falls back to the shell's original hardcoded path (orrouter.back()when no fallback is given) otherwise.
returnTo only ever holds a same-origin relative path, enforced by isSafeReturnPath (src/lib/url/returnTo.ts) — unsafe values (absolute URLs, //, scheme:) are ignored, so the param cannot be abused as an open redirect. Routes reached without a returnTo behave exactly as if the mechanism didn't exist.
See the useReturnTo hook docs for the API.
Responsive UI
The UI is responsive down to phone widths (July 2026). MUI's default breakpoints are kept, with two carrying project-wide meaning: md (900px) switches the layout (temporary vs. permanent drawer, footer, shell margins) and sm (600px) forces dialogs fullscreen. Styling-only changes use CSS breakpoints in sx (server-correct first paint); the useIsMobile hook is reserved for the two spots where structure must change (drawer variant, dialog fullScreen).
All conventions — the SSR rule, the shell patterns, the table-to-stack form recipe, ButtonStack wrapping, and the Playwright mobile projects — live in infrastructure/responsive-design.md. Read that page before writing new UI components.
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.tsx | Activity editor (admin) |
src/app/[locale]/(app)/v/[id]/page.tsx | Activity reviewer (member + admin) |
src/app/[locale]/(legal)/ | Footer legal pages — SSG, no auth, no MongoDB (see legal-pages.md) |
src/app/[locale]/[id]/route.ts | Public activity export (HTML / RTF) — no auth |
src/app/[locale]/[id]/pdf/route.ts | Public activity PDF export — no auth |
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 |