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

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.


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 a returnTo query param.
  • The shell side calls navigateBack(fallback) in its default handlers — it pushes returnTo when present and safe, and falls back to the shell's original hardcoded path (or router.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:

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.tsxActivity editor (admin)
src/app/[locale]/(app)/v/[id]/page.tsxActivity reviewer (member + admin)
src/app/[locale]/(legal)/Footer legal pages — SSG, no auth, no MongoDB (see legal-pages.md)
src/app/[locale]/[id]/route.tsPublic activity export (HTML / RTF) — no auth
src/app/[locale]/[id]/pdf/route.tsPublic activity PDF export — no auth
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