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.
This commit is contained in:
2026-07-09 22:28:10 +02:00
committed by GitHub
parent 352e34a428
commit 0ee15e3cbb
15 changed files with 1132 additions and 14 deletions
+63
View File
@@ -5,9 +5,15 @@ declare(strict_types=1);
namespace Tvdt\Repository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\Elimination;
use Tvdt\Entity\GivenAnswer;
use Tvdt\Entity\QuizCandidate;
use Tvdt\Entity\Season;
use Tvdt\Entity\User;
/** @extends ServiceEntityRepository<User> */
@@ -44,8 +50,11 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
$em->wrapInTransaction(function () use ($em, $user): void {
$this->invalidateResetPasswordRequests($user);
$bankQuestionIds = [];
foreach ($user->seasons->toArray() as $season) {
if (1 === $season->owners->count()) {
$this->purgeSoftDeletableData($em, $season);
array_push($bankQuestionIds, ...$this->bankQuestionIds($season));
$em->remove($season);
continue;
@@ -56,9 +65,63 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
$em->remove($user);
$em->flush();
// Gedmo\Loggable writes its own "removed" log entry as part of the flush above, so the
// audit-log purge must happen after — purging first would just leave that final row behind.
$this->purgeBankQuestionAuditLog($em, $bankQuestionIds);
});
}
/**
* QuizCandidate, GivenAnswer, and Elimination are Gedmo\SoftDeleteable, so cascading their
* removal through the season/quiz/candidate relations only sets deletedAt — it never removes
* the row. That leaves personal data behind indefinitely and, since Candidate/Answer are hard
* deleted via orphanRemoval, it also breaks their foreign keys and rolls back the whole
* deletion. Bulk DQL deletes bypass the Gedmo listener and physically remove these rows first.
*/
private function purgeSoftDeletableData(EntityManagerInterface $em, Season $season): void
{
foreach ([QuizCandidate::class, GivenAnswer::class, Elimination::class] as $class) {
$em->createQuery(<<<DQL
delete from {$class} e
where e.quiz in (select q from Tvdt\Entity\Quiz q where q.season = :season)
DQL)
->setParameter('season', $season)
->execute();
}
}
/** @return list<string> */
private function bankQuestionIds(Season $season): array
{
return array_values(array_map(
static fn (BankQuestion $bankQuestion): string => $bankQuestion->id->toString(),
$season->bankQuestions->toArray(),
));
}
/**
* Gedmo\Loggable audit rows (ext_log_entries) aren't foreign-keyed to the entity they log —
* object_id is a plain string — so removing a BankQuestion never cleans up its history, and
* the editor's username/email would otherwise remain in those rows forever.
*
* @param list<string> $bankQuestionIds
*/
private function purgeBankQuestionAuditLog(EntityManagerInterface $em, array $bankQuestionIds): void
{
if ([] === $bankQuestionIds) {
return;
}
$em->createQuery(<<<'DQL'
delete from Tvdt\Entity\LogEntry l
where l.objectClass = :class and l.objectId in (:ids)
DQL)
->setParameter('class', BankQuestion::class)
->setParameter('ids', $bankQuestionIds)
->execute();
}
public function makeAdmin(string $email): void
{
$user = $this->findOneBy(['email' => $email]);