# Register van verwerkingsactiviteiten (ROPA) — OutreachMap

> **Status:** v0.2 — GDPR harden (todo 28). Eigenaar: OutreachMap (verwerkingsverantwoordelijke).
> Hosting uitsluitend EU (Vercel fra1/cdg1, Neon aws-eu-central-1, Stadia EU-tiles, Vercel Blob EU, Resend EU).
> Versie 0.2 — 2026-08-20 · DPA-links + DPIA + Art 19 withdraw + DSAR + breach register toegevoegd.

## 1. Overzicht

Dit register beschrijft verwerkingen op grond van **art. 30 AVG**. Voor elke verwerking: doel, rechtsgrond, categorieën betrokkenen & persoonsgegevens, ontvangers, bewaartermijn, doorgifte en beveiliging. Zie ook [docs/DPA.md](./DPA.md) (verwerkersovereenkomsten Art. 28) en [docs/SUBPROCESSORS.md](./SUBPROCESSORS.md) (EU-only check).

Principe: **dataminimalisatie** — geen BSN, geen telefoon in titel/beschrijving (geblokkeerd), alleen noodzakelijke velden. Leeftijdsgrens 16+ (AVG art. 8 NL).

## 2. Verwerkingen (art. 30 lid 1)

| # | Verwerking | Doel | Rechtsgrond (AVG art. 6) | Categorie betrokkenen | Categorie persoonsgegevens | Ontvangers / (sub)verwerkers | Bewaartermijn | Doorgifte buiten EER | Beveiliging | DPIA nodig? |
|---|------------|------|--------------------------|-----------------------|----------------------------|------------------------------|---------------|----------------------|-------------|-------------|
| 1 | Account & authenticatie (Better Auth + Neon) | Inloggen organisatoren/moderators/deelnemers; rolbeheer | **6(1)(a)** toestemming (affiliation vrij-tekst) + **6(1)(b)** uitvoering overeenkomst (account) + **6(1)(f)** gerechtvaardigd belang (beveiliging) | Gebruikers (participant/organizer/moderator/admin) | e-mail, naam/displayName, affiliation (optioneel vrij-tekst 0-80, max 80), password hash, sessie-token, IP (kortstondig), userAgent, DOB (optioneel 16+ gate, niet verplicht), role | **Neon** (aws-eu-central-1 Frankfurt) — DB `user/session/account/verification`; **Vercel** (fra1/cdg1) runtime | tot verwijdering account + 30 d grace; sessie 7 d (updateAge 1 d); affiliation + DOB tot verwijdering | **Nee** — alleen EU (Neon EU, Vercel EU) | hashing (better-auth/bcrypt), TLS, at-rest encryptie Neon, httpOnly secure cookie SameSite=Lax, EU-pinning, pseudonimisering (affiliation geen BSN) | nee |
| 2 | Event-publicatie & moderatie | Events aanmaken, modereren en tonen op kaart/lijst; status lifecycle draft→pending→approved/rejected/needs_edit | **6(1)(f)** gerechtvaardigd belang (platform-functioneren, moderatie) + **6(1)(a)** toestemming organisator (publicatie) | Organisatoren, moderators, admin | event-titel, beschrijving, adres/plaats, geocoördinaat (geography Point,4326), datum, contact-persoon (displayName), moderatie-besluit (policyTag, rationale), imageKey | Neon (events/audit_logs), Vercel (runtime), Blob EU (image), Stadia EU (geocode) | events tot **2 jaar na endAt** (730 d, zie `src/lib/retention.ts`), daarna purge/anonimiseren; audit_logs **5 jaar** (1825 d, append-only); moderatielog 1 jaar zichtbaar | Nee | RBAC (requireRole), audit append-only (geen UPDATE/DELETE), rate limit, CSRF origin-check, PostGIS geom validatie NL-bounds | nee — zie DPIA §5 |
| 3 | Aanmeldingen voor outreach (in_platform) | Deelnemer aanmelden; capaciteit & idempotentie | **6(1)(a)** toestemming / **6(1)(b)** uitvoering overeenkomst (deelname) | Deelnemers (ingelogd) | naam (via user), e-mail (via user, niet in sign_ups), event-id, status going/cancelled, createdAt | Neon (sign_ups), Resend EU (optioneel bevestiging) | tot event + **90 d**, daarna anonimiseren/deleten | Nee | minimal data (geen telefoon; via signUpMode instruction/link als organisator expliciet), EU-mail | te beoordelen (laag) |
| 4a | Kaartweergave — tiles | Tonen MapLibre-kaart (EU-tiles) | **6(1)(a)** toestemming (maps-cookie) / **6(1)(f)** gerechtvaardigd belang (EU-tiles, geen tracking) | Bezoekers | IP (kortstondig, niet opgeslagen door OutreachMap), tile-request (geen persoonsgegeven bij Stadia — geen cookie) | **Stadia Maps EU** (`tiles-eu.stadiamaps.com`, `api-eu.stadiamaps.com`) | sessie (tiles); Stadia verwerkt alleen IP tijdelijk per eigen DPA | Nee — Stadia EU endpoint Frankfurt, geen US-fallback | EU-tiles pin, geen Google/Mapbox US, tiles URL `NEXT_PUBLIC_TILES_URL=https://tiles-eu.stadiamaps.com` | nee |
| 4b | Geocoding (server-only) | Adres → lat/lng (server-side) | **6(1)(f)** gerechtvaardigd belang (locatievalidatie) | Organisatoren | adres/plaats (ingevuld), lat/lng (afgeleid, NL-bounds check 50.7–54.0/3.2–7.2) | Stadia EU (`api-eu.stadiamaps.com/geocoding`) primair, Nominatim EU (`nominatim.openstreetmap.org` countrycodes=nl) fallback; cache `geocode_cache` in Neon | cache immortal (adres→point), hergebruik; adres validatie bij event | Nee — beide EU endpoints, sleutel `STADIA_API_KEY` server-only nooit NEXT_PUBLIC | server-only (`import "server-only"`), geen client-exposure, NL-bounds reject Buiten Nederland | nee |
| 5 | Analytiek (optioneel, EU-only, uitgeschakeld default) | Anonieme gebruiksstatistieken | **6(1)(a)** toestemming (analytics-cookie, withdraw via Art 7(3)) | Bezoekers | geanonimiseerde pageviews, geen IP opslag (IP-anonimisering 0 laatste octet) | EU-analytiekprovider n.t.b. (Plausible EU / PostHog EU — alleen na DPA, nu geen provider actief; code alleen localStorage consent) | 14 maanden (indien actief) | Nee — EU-provider verplicht, geen US GA zonder TIA | IP-anonimisering, geen fingerprinting, consent withdraw wist analytics (Art 19 propagatie log) | nee — geen profiling |
| 6 | E-mail (transactioneel, NL templates) | Bevestigingen, moderatie-notificaties (submit/approved/rejected/needs_edit/sign-up/cancel) | **6(1)(f)** gerechtvaardigd belang / **6(1)(a)** toestemming (transactioneel, geen marketing) | Gebruikers (organisatoren, moderators) | e-mail, event-meta (titel, datum, reden), unsubscribe niet nodig (transactioneel) | **Resend EU** (EU-region store) | logs 1 jaar (Resend EU), daarna purge | Nee — Resend EU regio, geen US transfer | EU-only API key, NL-templates, geen BSN/telefoon in body | nee |
| 7 | Cookies & toestemming | Opslaan cookie-keuzes (essential/analytics/maps) | **6(1)(c)** wettelijke verplichting (ePrivacy, art. 5(3)) + **6(1)(a)** toestemming voor niet-noodzakelijk | Bezoekers | consent-voorkeuren (`outreachmap:consent` v1: `{essential, analytics, maps}`), SameSite=Lax cookie + localStorage | — (localStorage + EU-cookie, geen subverwerker) | 1 jaar (`Max-Age=31536000`) | Nee | SameSite=Lax, geen wall (site werkt met alleen essential), intrekken even makkelijk als geven (granulaire banner + /privacy#instellingen + /account) | nee |
| 8 | Afbeeldingen (Blob EU) | 1 image/event (webp/jpeg, 5 MB max, sharp resize 1200px → webp, blur tot approved) | **6(1)(a)** toestemming organisator (upload) + **6(1)(f)** gerechtvaardigd belang (moderatie) | Organisatoren | imageKey (`events/<id>-<name>.webp`), image bytes (geblurred tot approved, zie §6) | **Vercel Blob EU** (store regio EU, `BLOB_REGION=eu`, token EU-store, niet iad1) | tot event 2y retentie; of tot verwijdering account; blob delete bij event purge | Nee — EU store Frankfurt (`availability: eu`), geen iad1 | EU-pin (`assertBlobRegionEU()`), sharp blur default, geen gezichten ongeblurred public tot approved, 5 MB validatie | nee |
| 9 | Rechten van betrokkenen — DSAR (art. 15) | Inzage/verstrekking geaggregeerde persoonsgegevens binnen 1 maand | **6(1)(c)** wettelijke verplichting | Alle betrokkenen | geaggregeerd: profile (user), events (organisator), signUps, reports/appeals, auditLogs (eigen acties) — JSON export | — (interne handler `app/api/dsar/route.ts` + `src/lib/gdpr/dsar.ts`) | DSAR response geen retentie (eenmalig); log alleen datum verzoek | Nee | auth re-check (`auth.api.getSession`), mock DB fallback, 200 JSON shape `{userId, profile, events, signUps, requests}` | n.v.t. |
| 10 | Datalekkenregister (art. 33/34) | Registratie & beoordeling inbreuken; AP-melding ≤72u indien risico | **6(1)(c)** wettelijke verplichting | Betrokkenen bij lek | breach beschrijving, severity (low/medium/high/critical), timestamp, actor, affected categories | Intern register (`breach_register` tabel / `src/lib/gdpr/breach.ts` file fallback) + AP (Autoriteit Persoonsgegevens) indien risico | register **5 jaar** (analoog audit) | Nee | append-only log, geen DELETE, `logBreach()` writable test | n.v.t. |

## 3. Bewaartermijnen — detail

- **Sessies:** tot uitloggen / 7 d verloop; moderatielog 1 jaar zichtbaar, audit 5 jaar append-only.
- **Events:** tot 730 dagen na `endAt`, daarna purge ( `isEventRetentionExpired` in `src/lib/retention.ts` ).
- **Aanmeldingen:** tot 90 dagen na `endAt`, daarna anonimiseren.
- **Consent:** 1 jaar (`outreachmap:consent` v1).
- **Breach register:** 5 jaar.
- **E-maillogs (Resend EU):** 1 jaar.
- Zie `src/lib/retention.ts` voor `RETENTION_EVENTS_DAYS=730`, `RETENTION_AUDIT_DAYS=1825`, helpers `daysUntil*`, `softDeletePatch`.

## 4. Rechten van betrokkenen (art. 12–22)

Inzage (art. 15), rectificatie (16), verwijdering (17), beperking (18), bezwaar (20), dataportabiliteit (20) en **intrekken toestemming** (art. 7 lid 3) via:

- `privacy@outreachmap.nl` (1 maand SLA, verlengbaar +1 maand bij complexiteit)
- `/privacy#rechten` en `/privacy#instellingen` (granulaire banner)
- **/account** — `Toestemming intrekken` knop roept `withdrawConsent(userId)` aan: wist `analytics`+`maps`, propageert via **art. 19** naar ontvangers (log, zie `src/lib/gdpr/consent.ts`), en toont banner opnieuw.

Intrekken is even gemakkelijk als geven (banner `Alles accepteren` en `Alles weigeren` gelijkwaardig, geen dark pattern, geen wall).

**DSAR runbook (1-maand):**

1. Verzoek ontvangen (mail / `GET /api/dsar` geauthenticeerd) → log datum.
2. Identiteit verifiëren via `auth.api.getSession` (re-check, geen proxy-trust).
3. `src/lib/gdpr/dsar.ts` `collectDsarData(userId)` → aggregeert `user` + `events` (organizerId) + `signUps` + `reports/appeals` (reporterId/requestorId) + `auditLogs` (actorId) — mock fallback bij geen DB.
4. `app/api/dsar/route.ts` retourneert `200` JSON `DsarExport` (zie §4 schema); content-disposition optioneel.
5. Indien complex: informeer betrokkene binnen 1 maand over verlenging + redenen.
6. Geen kosten tenzij buitensporig/herhaald.

```ts
// Ds ar shape (src/lib/gdpr/dsar.ts)
type DsarExport = {
  userId: string;
  exportedAt: string; // ISO
  profile: { id, email, name, role, createdAt } | null;
  events: Array<{ id, title, moderationState, createdAt }>;
  signUps: Array<{ id, eventId, status, createdAt }>;
  reports: unknown[];
  appeals: unknown[];
  auditLogs: unknown[];
  meta: { retention: { events: "730d", audit: "1825d" }, note: string };
};
```

## 5. Verwerkers & doorgifte (art. 28 en EDPB doorgifte)

**Geen doorgifte buiten EER.** Zie [docs/SUBPROCESSORS.md](./SUBPROCESSORS.md) voor EU-only verificatie per subverwerker.

| Verwerker | Rol | Regio-pin | DPA / art. 28 | Doorgifte | Status |
|-----------|-----|-----------|---------------|-----------|--------|
| **Vercel** (hosting, Fluid, Blob) | Verwerker (hosting + Blob store) | `fra1` (Frankfurt) + `cdg1` (Parijs) via `vercel.json` — geen iad1 | [Vercel DPA](https://vercel.com/legal/dpa) — SCC's bij onderaannemers, EU-regio pin als TOM | Geen — EU-regio's only, `x-vercel-id` fra1/cdg1 | ✅ EU-pin verified (`vercel.json`, `BLOB_REGION=eu`) |
| **Neon** (Postgres, Better Auth) | Verwerker (DB) | `aws-eu-central-1` (Frankfurt) | [Neon DPA](https://neon.tech/dpa) + AWS DPA — encryptie at-rest, subverwerkers AWS EU | Geen — DB alleen EU, `-pooler.eu-central-1.aws.neon.tech` | ✅ EU-pin verified (`DATABASE_URL` pooler EU) |
| **Stadia Maps** (tiles + geocode) | Verwerker (kaart) | `tiles-eu.stadiamaps.com` / `api-eu.stadiamaps.com` (Frankfurt) | [Stadia DPA / Privacy](https://stadiamaps.com/privacy/) — EU endpoints, geen US fallback | Geen — EU-tiles only, geen `tiles.stadiamaps.com` (US) | ✅ EU-pin (`NEXT_PUBLIC_TILES_URL` tiles-eu, geen US) |
| **Vercel Blob** | Verwerker (images) | EU store (Frankfurt, `BLOB_REGION=eu`) | Onder Vercel DPA — store regio EU bij creatie (token EU-store) | Geen — store EU | ✅ `assertBlobRegionEU()` guard |
| **Resend** (e-mail EU) | Verwerker (mail) | EU-regio (Resend EU) | [Resend DPA](https://resend.com/legal/dpa) — EU data residency optie | Geen — Resend EU | ✅ EU-pin (`RESEND_API_KEY` EU) |

**Art. 28 checklist per verwerker (samenvatting):**

- [x] Onderwerp, duur, aard en doel verwerking gedocumenteerd (tabel §2).
- [x] Soorten persoonsgegevens & betrokkenen gedefinieerd.
- [x] Verwerker handelt alleen op instructie (ToS ≠ DPA — expliciete DPA-link vereist).
- [x] Geheimhouding, TOM's (TLS, encryptie, hashing, RBAC, EU-pinning).
- [x] Subverwerkers alleen met toestemming + zelfde verplichtingen.
- [x] Bijstand rechten betrokkenen + breach-melding ≤72u.
- [x] Na einde: teruggave/verwijdering data (retentie 2y/5y, purge).
- [x] Audit/controle recht.

> **Waarschuwing:** ToS / Terms of Service gelden **niet** als DPA. Elke verwerker vereist ondertekende DPA via bovenstaande links + Vercel/Neon dashboard. Dit project documenteert DPA-status; juridische ondertekening loopt via privacy@outreachmap.nl.

## 6. Beveiliging (art. 32) & beeldbeleid

- **TLS** (Vercel), **at-rest encryptie** (Neon/Vercel), **hashing** (Better Auth password hash), **RBAC** moderatie (`requireRole`), **rate limiting** (Upstash Redis), **CSRF** origin-check, **audit append-only** (geen UPDATE/DELETE).
- **Minimale dataverzameling**, EU-pinning (geen US-tiles, geen iad1).
- **Afbeeldingen:** default **geblurred tot approved** — uploads worden server-side via `sharp.blur(12)` geblurred en pas na `moderationState=approved` ongeblurred getoond. Doel: gezichten niet herkenbaar public vooraf. Zie `src/lib/storage/blob.ts` `IMAGE_BLUR_DEFAULT=true` + `src/lib/gdpr/imageBlur.ts`. Organisator ziet eigen preview ongeblurred na upload, public ziet blurred placeholder tot moderatie. Geen AI-gezichtsherkenning, alleen generieke blur.

## 7. DPIA — beoordeling (art. 35)

| Vraag | Beoordeling |
|-------|-------------|
| **Systematische profilering / scoring?** | **Nee.** Geen profiling, geen scoring, geen geautomatiseerde besluitvorming (art. 22). Sortering `Relevance|Date|Distance` is deterministisch op `startAt` + `ST_DWithin` afstand, geen persoonsprofiel. Kaart-clustering is geo-aggregatie, geen persoonsprofilering. |
| **Grootschalige bijzondere categorieën / strafrecht?** | **Nee.** Alleen event-meta + account; geen BSN/bijzondere gegevens (geblokkeerd). |
| **Grootschalig systematisch monitoren publieke ruimte?** | **Nee.** Geen tracking; analytics alleen geanonimiseerd na toestemming, IP-geanonimiseerd, geen camera/drone. |
| **Nieuwe technologie met hoog risico?** | **Nee.** MapLibre + PostGIS zijn bewezen; image blur is generieke TOM, geen biometrie. |
| **Advies FG/DPO?** | FG n.t.b. — bij introductie echte analytics-provider of AI-moderatie opnieuw DPIA. |
| **Conclusie WP29-criteria (≥2 = DPIA):** | **0/9 criteria** → geen DPIA vereist. Wel herbeoordelen bij: activatie analytics-provider, intro AI-moderatie, of verwerking bijzondere gegevens. |

- Drempels: geen grootschalig, geen kwetsbare groepen (<16 geblokkeerd), geen keten-besluitvorming.
- Mitigaties: dataminimalisatie, EU-only, blur-tot-approved, consent withdraw Art 19, DSAR 1 maand, breach ≤72u.

## 8. Meldplicht & contact

- **Datalekken:** intern register (`src/lib/gdpr/breach.ts` `logBreach()` → `breach_register` tabel / file fallback) + AP-melding binnen **72u** indien risico (art. 33), betrokkene informeren indien hoog risico (art. 34). Test: `logBreach({description, severity})` is writable.
- **Contact:** `privacy@outreachmap.nl`. FG/DPO: n.t.b. (extern adviseur bij DPIA).
- **Klacht:** AP — `https://autoriteitpersoonsgegevens.nl`.

---

*Laatst bijgewerkt 2026-08-20 — EU-only pin geverifieerd: `vercel.json` fra1/cdg1, `BLOB_REGION=eu`, `NEXT_PUBLIC_TILES_URL` tiles-eu, Neon `aws-eu-central-1`. DPA-links boven zijn canonieke bronnen; controleer versie via actual DPA-document.*
