Marijn 0ee15e3cbb feat: add GDPR data export (download data) button (#198)
* feat: add GDPR data export (download data) button

Wires up the previously disabled "Download data" button on the settings
page. Downloads a zip with a profile.xlsx (account + owned seasons), and
per owned season a folder with each quiz's xlsx (questions, results,
eliminations tabs) and a candidates.xlsx (candidates + season info tabs).
Soft-deleted rows are included and flagged so the export reflects
everything the app still holds about the user.

* feat: include question bank in GDPR data export

Adds a question-bank.xlsx per season folder with Questions (bank
questions, answers, reusable/complete flags, labels, and which quizzes
they've been used in) and Labels tabs, since BankQuestion/BankAnswer
content was previously missing from the export.

* fix: hard-delete quiz/audit-log data when deleting an account

QuizCandidate, GivenAnswer, and Elimination are Gedmo\SoftDeleteable, so
cascading their removal through Season -> Quiz/Candidate only set
deletedAt instead of physically deleting the row. Since Candidate and
Answer are hard-deleted via orphanRemoval, this broke their foreign keys
and rolled back the entire account deletion whenever a candidate had
actually participated in a quiz. Bulk DQL deletes now purge these rows
before the cascade runs.

Also purge BankQuestion audit-log rows (ext_log_entries), which store
the editor's username/email but aren't foreign-keyed to the entity they
log, so they were never cleaned up and would otherwise keep a deleted
user's email around indefinitely.

* style: make the download data button primary (blue)

* feat: add raw answers crosstab to quiz export

Adds a "Raw answers" tab to each quiz xlsx: one row per candidate, one
column per question, with the given answer text in each cell — the raw
data behind the aggregated Results tab.

* i18n: translate new settings page string to Dutch

* refactor: sanitize filenames with Symfony's AsciiSlugger instead of a hand-rolled regex

Extracts a shared Tvdt\Helpers\FilenameSanitizer (backed by
symfony/string's AsciiSlugger) and uses it everywhere user-controlled
text (season/quiz names, account email) ends up in a zip entry path or
a downloaded filename. AsciiSlugger is allowlist-based (only A-Z/0-9
survive; everything else, including unicode and path-traversal
sequences, is folded or stripped) rather than a denylist of "unsafe"
characters, and it's an officially maintained Symfony component already
present as a transitive dependency.

Also fixes BackofficeController::exportQuiz(), a pre-existing endpoint
that built its Content-Disposition filename directly from an
unsanitized quiz name — the same class of risk the data export already
guarded against.

Naming note: sanitized names are now slugs (spaces become dashes,
unicode is ASCII-transliterated), e.g. "Krtek Weekend" -> "Krtek-Weekend".

* fix: check ZipArchive open/close results and clean up temp files on failure

Addresses CodeRabbit findings on the GDPR export:
- ZipArchive::open() and close() can both return false without
  throwing; neither was checked, so a failure silently produced an
  empty or corrupt zip, and the temp zip path was never cleaned up on
  a mid-build exception.
- writeToTempFile() leaked its tempnam()'d file if Writer\Xlsx::save()
  threw before the caller could track it for cleanup.

* fix: drop public link identifier from the candidates export sheet

The nameHash is a public quiz-access token, not something a data
export should hand out — remove it from candidates.xlsx.

* feat: add Quiz info tab covering dropouts, finalization, and disabled questions

An entity-by-entity audit of the export vs. delete flows found the
delete flow fully covered, but three Quiz/Question fields missing from
the export: dropouts, finalizedAt, and Question.enabled. Adds a new
"Quiz info" tab (first sheet) to each quiz's xlsx with this data,
without touching the shared fillQuestionsSheet() used by the existing
single-quiz template export/import feature.

Deliberately left out per user decision: the BankQuestion audit log
(would leak other owners' emails, consistent with hiding co-owner
identities elsewhere in this export) and a few low-value timestamp
fields already covered by existing Started/time-taken columns.

* feat: require a confirmed email before exporting data

Antispam measure: both the full data export (SettingsController::downloadData)
and the single-quiz export (BackofficeController::exportQuiz) now redirect
with a flash warning instead of exporting when the account's email isn't
verified yet. Adds a matching hint on the settings page next to the
download button.
2026-07-09 20:28:10 +00:00
2026-07-08 14:38:32 +02:00
2026-07-07 21:43:24 +00:00
2026-07-08 14:38:32 +02:00
2025-09-28 18:14:58 +02:00
2025-09-28 18:14:58 +02:00
2026-03-22 22:40:25 +01:00
2024-12-29 14:58:03 +01:00
2026-01-25 19:58:39 +01:00
2026-02-18 16:20:14 +01:00
WIP
2025-06-01 15:40:47 +02:00
2026-07-07 21:43:24 +00:00
2026-02-18 16:20:14 +01:00
2026-07-08 14:38:32 +02:00
2026-03-22 22:40:25 +01:00
2026-03-22 22:40:25 +01:00
2026-07-08 14:38:32 +02:00
2026-05-25 13:56:46 +00:00
2026-03-22 22:40:25 +01:00

Tijd voor de test

CI

PHP/Symfony application for WIDM-style quiz management. Built with FrankenPHP, PostgreSQL, and Docker.

Disclaimer: This is an unofficial, non-commercial, open-source fan project. It is not affiliated with, endorsed by, or associated with Wie is de Mol? (produced by IDTV, broadcast by AVROTROS/NPO) or De Mol (produced by Woestijnvis, broadcast by Play/De Vijver Media). Wie is de Mol? and De Mol are trademarks of their respective rights holders. No copyright infringement is intended.

Requirements

  • Docker
  • Just (brew install just)

Local development

just up        # Start PHP + PostgreSQL containers
just migrate   # Run pending database migrations
just fixtures  # Load dev fixtures (truncates first)

The app is available at https://localhost (self-signed cert — run just trust-cert on macOS to trust it).

Useful commands

just shell              # Shell inside the running PHP container
just shell-run          # Shell in a fresh one-off container
just stop               # Stop containers (keep volumes)
just down               # Stop and remove containers
just clean              # Nuclear: remove containers + volumes + generated files
just exec <cmd>         # Run any command inside the PHP container

Environment

Copy .env and override locally via .env.local (not committed):

Variable Description
APP_SECRET Symfony app secret
DATABASE_URL PostgreSQL DSN (auto-set in Docker)
SENTRY_DSN Sentry error tracking
DEFAULT_URI Base URL for CLI-generated links

Testing

just test                                   # Full PHPUnit suite
just test tests/Path/To/TestFile.php        # Single file
just test --coverage-html var/coverage      # HTML coverage report
just reload-tests             # Drop/recreate test DB + migrate + test fixtures

Tests use a separate database configured via .env.test. The DAMA Doctrine bundle wraps each test in a transaction that is rolled back after. just reload-tests loads the --group=test fixtures; just fixtures loads the dev group and is unrelated to the test database.

Code quality

All checks run in CI and must pass before merging.

just fix-cs              # Auto-fix PHP-CS-Fixer + Twig-CS-Fixer
just phpstan             # PHPStan static analysis (level 8)
just rector              # Apply Rector modernizations
just rector --dry-run    # Preview Rector changes without applying

Database

just migrate                      # Run pending migrations
just fixtures                     # Load dev fixtures
bin/console make:migration        # Generate a new migration (inside container)

Migrations live in migrations/ (namespace DoctrineMigrations). Test fixtures are in src/DataFixtures/ loaded with --group=test.

Translations

just translations    # Extract/update nl translation strings into translations/

Contributing

  1. Create a branch from main — use a prefix like feat/, fix/, or docs/.
  2. Open a pull request; CI must pass before merging.
  3. Install the pre-commit hook (see below) to catch issues before pushing.

Pre-commit hook

A pre-commit hook lives in .githooks/pre-commit. Install it once after cloning:

just install-hooks

On every commit it runs automatically, only on staged files:

Staged file type Tools run
.php Rector → PHP-CS-Fixer (auto-fix + re-stage), then PHPStan (blocks on errors)
.twig Twig-CS-Fixer (auto-fix + re-stage)
Other (docs, config, …) Nothing — commit proceeds immediately

If the PHP container is not running, the hook falls back to docker compose run --rm so checks still execute. PHPUnit is not run in the hook; CI covers that.

Deployment

Docker images are published to ghcr.io/marijndoeve/tijdvoordetest for each tagged release.

First-time setup

  1. Copy compose.yaml and compose.prod.yaml to your server.
  2. Create a .env.prod.local file with the required variables (see below).
  3. Start the stack — migrations run automatically on container start:
IMAGE_TAG=latest docker compose -f compose.yaml -f compose.prod.yaml up -d

Updating to a new version

IMAGE_TAG=<tag> docker compose -f compose.yaml -f compose.prod.yaml pull
IMAGE_TAG=<tag> docker compose -f compose.yaml -f compose.prod.yaml up -d

Required environment variables

Variable Description
IMAGE_TAG Image tag to run (e.g. 1.2.3 or latest)
APP_SECRET Random secret string for Symfony
CADDY_MERCURE_JWT_SECRET JWT secret for the Mercure hub
POSTGRES_PASSWORD PostgreSQL password
MAILER_DSN Mailer transport DSN
MAILER_SENDER From address for emails
SENTRY_DSN Sentry project DSN (optional)

The compose.prod.yaml configures Traefik labels for TLS termination at tijdvoordetest.nl. Adjust the traefik labels in that file if you're hosting on a different domain or using a different reverse proxy.

License

MIT

S
Description
No description provided
Readme AGPL-3.0 21 MiB
Languages
PHP 76.9%
Twig 17.1%
JavaScript 3.1%
Shell 1.5%
Dockerfile 0.6%
Other 0.8%