From 2ae28089d5b667cc1eee9e3f2cebbefac2dece16 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Thu, 26 Mar 2026 11:59:34 +0100 Subject: [PATCH] Add RGPD data access/deletion forms and admin Analytics dashboard RGPD (/rgpd): - Access form: search by IP, generate PDF with all visitor data, email it - Deletion form: delete all visitor data by IP, generate attestation PDF - Both forms pre-fill client IP, require email for response - PDF templates with E-Cosplay branding, RGPD article references Admin Analytics (/admin/analytics): - KPIs: unique visitors, pageviews, pages/visitor - Top pages and referrers tables - Device type, browser, OS breakdowns - Period filter: today, 7d, 30d, all Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Controller/AdminController.php | 97 +++++++++++++++ src/Controller/LegalController.php | 53 +++++++- src/Service/RgpdService.php | 147 +++++++++++++++++++++++ templates/admin/analytics.html.twig | 124 +++++++++++++++++++ templates/admin/base.html.twig | 1 + templates/emails/rgpd_access.html.twig | 7 ++ templates/emails/rgpd_deletion.html.twig | 7 ++ templates/legal/rgpd.html.twig | 51 +++++++- templates/pdf/rgpd_access.html.twig | 52 ++++++++ templates/pdf/rgpd_deletion.html.twig | 40 ++++++ 10 files changed, 576 insertions(+), 3 deletions(-) create mode 100644 src/Service/RgpdService.php create mode 100644 templates/admin/analytics.html.twig create mode 100644 templates/emails/rgpd_access.html.twig create mode 100644 templates/emails/rgpd_deletion.html.twig create mode 100644 templates/pdf/rgpd_access.html.twig create mode 100644 templates/pdf/rgpd_deletion.html.twig diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php index f020323..f0182e2 100644 --- a/src/Controller/AdminController.php +++ b/src/Controller/AdminController.php @@ -2,6 +2,8 @@ namespace App\Controller; +use App\Entity\AnalyticsEvent; +use App\Entity\AnalyticsUniqId; use App\Entity\AuditLog; use App\Entity\BilletBuyer; use App\Entity\Event; @@ -666,6 +668,101 @@ class AdminController extends AbstractController return $this->render('admin/infra.html.twig', $data); } + #[Route('/analytics', name: 'app_admin_analytics', methods: ['GET'])] + public function analytics(Request $request, EntityManagerInterface $em): Response + { + $period = $request->query->getString('period', '7d'); + $since = match ($period) { + 'today' => new \DateTimeImmutable('today'), + '30d' => new \DateTimeImmutable('-30 days'), + 'all' => new \DateTimeImmutable('2020-01-01'), + default => new \DateTimeImmutable('-7 days'), + }; + + $visitors = (int) $em->createQueryBuilder() + ->select('COUNT(v.id)') + ->from(AnalyticsUniqId::class, 'v') + ->where('v.createdAt >= :since') + ->setParameter('since', $since) + ->getQuery() + ->getSingleScalarResult(); + + $pageviews = (int) $em->createQueryBuilder() + ->select('COUNT(e.id)') + ->from(AnalyticsEvent::class, 'e') + ->where('e.createdAt >= :since') + ->setParameter('since', $since) + ->getQuery() + ->getSingleScalarResult(); + + $topPages = $em->createQueryBuilder() + ->select('e.url, COUNT(e.id) AS hits') + ->from(AnalyticsEvent::class, 'e') + ->where('e.createdAt >= :since') + ->setParameter('since', $since) + ->groupBy('e.url') + ->orderBy('hits', 'DESC') + ->setMaxResults(20) + ->getQuery() + ->getArrayResult(); + + $topReferrers = $em->createQueryBuilder() + ->select('e.referrer, COUNT(e.id) AS hits') + ->from(AnalyticsEvent::class, 'e') + ->where('e.createdAt >= :since') + ->andWhere('e.referrer IS NOT NULL') + ->andWhere("e.referrer != ''") + ->setParameter('since', $since) + ->groupBy('e.referrer') + ->orderBy('hits', 'DESC') + ->setMaxResults(10) + ->getQuery() + ->getArrayResult(); + + $devices = $em->createQueryBuilder() + ->select('v.deviceType, COUNT(v.id) AS cnt') + ->from(AnalyticsUniqId::class, 'v') + ->where('v.createdAt >= :since') + ->setParameter('since', $since) + ->groupBy('v.deviceType') + ->getQuery() + ->getArrayResult(); + + $browsers = $em->createQueryBuilder() + ->select('v.browser, COUNT(v.id) AS cnt') + ->from(AnalyticsUniqId::class, 'v') + ->where('v.createdAt >= :since') + ->andWhere('v.browser IS NOT NULL') + ->setParameter('since', $since) + ->groupBy('v.browser') + ->orderBy('cnt', 'DESC') + ->setMaxResults(10) + ->getQuery() + ->getArrayResult(); + + $osList = $em->createQueryBuilder() + ->select('v.os, COUNT(v.id) AS cnt') + ->from(AnalyticsUniqId::class, 'v') + ->where('v.createdAt >= :since') + ->andWhere('v.os IS NOT NULL') + ->setParameter('since', $since) + ->groupBy('v.os') + ->orderBy('cnt', 'DESC') + ->getQuery() + ->getArrayResult(); + + return $this->render('admin/analytics.html.twig', [ + 'period' => $period, + 'visitors' => $visitors, + 'pageviews' => $pageviews, + 'top_pages' => $topPages, + 'top_referrers' => $topReferrers, + 'devices' => $devices, + 'browsers' => $browsers, + 'os_list' => $osList, + ]); + } + #[Route('/organisateurs/inviter', name: 'app_admin_invite_organizer', methods: ['GET', 'POST'])] public function inviteOrganizer(Request $request, EntityManagerInterface $em, MailerService $mailerService): Response { diff --git a/src/Controller/LegalController.php b/src/Controller/LegalController.php index 5093ebd..ef7a4fe 100644 --- a/src/Controller/LegalController.php +++ b/src/Controller/LegalController.php @@ -2,7 +2,9 @@ namespace App\Controller; +use App\Service\RgpdService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -63,10 +65,11 @@ class LegalController extends AbstractController ]); } - #[Route('/rgpd', name: 'app_rgpd')] - public function rgpd(): Response + #[Route('/rgpd', name: 'app_rgpd', methods: ['GET'])] + public function rgpd(Request $request): Response { return $this->render('legal/rgpd.html.twig', [ + 'client_ip' => $request->getClientIp(), 'breadcrumbs' => [ ['name' => 'Accueil', 'url' => '/'], ['name' => 'Politique RGPD', 'url' => '/rgpd'], @@ -74,6 +77,52 @@ class LegalController extends AbstractController ]); } + #[Route('/rgpd/acces', name: 'app_rgpd_access', methods: ['POST'])] + public function rgpdAccess(Request $request, RgpdService $rgpd): Response + { + $ip = trim($request->request->getString('ip')); + $email = trim($request->request->getString('email')); + + if ('' === $ip || '' === $email) { + $this->addFlash('error', 'Veuillez remplir tous les champs.'); + + return $this->redirectToRoute('app_rgpd'); + } + + $result = $rgpd->handleAccessRequest($ip, $email); + + if ($result['found']) { + $this->addFlash('success', 'Vos donnees ont ete envoyees par email.'); + } else { + $this->addFlash('success', 'Aucune donnee trouvee pour cette adresse IP. Un email de confirmation a ete envoye.'); + } + + return $this->redirectToRoute('app_rgpd'); + } + + #[Route('/rgpd/suppression', name: 'app_rgpd_deletion', methods: ['POST'])] + public function rgpdDeletion(Request $request, RgpdService $rgpd): Response + { + $ip = trim($request->request->getString('ip')); + $email = trim($request->request->getString('email')); + + if ('' === $ip || '' === $email) { + $this->addFlash('error', 'Veuillez remplir tous les champs.'); + + return $this->redirectToRoute('app_rgpd'); + } + + $result = $rgpd->handleDeletionRequest($ip, $email); + + if ($result['found']) { + $this->addFlash('success', 'Vos donnees ont ete supprimees. Une attestation a ete envoyee par email.'); + } else { + $this->addFlash('success', 'Aucune donnee trouvee pour cette adresse IP.'); + } + + return $this->redirectToRoute('app_rgpd'); + } + #[Route('/conformite', name: 'app_conformite')] public function conformite(): Response { diff --git a/src/Service/RgpdService.php b/src/Service/RgpdService.php new file mode 100644 index 0000000..1df5088 --- /dev/null +++ b/src/Service/RgpdService.php @@ -0,0 +1,147 @@ +em->getRepository(AnalyticsUniqId::class)->findBy(['ipHash' => $ipHash]); + + if (0 === \count($visitors)) { + return ['found' => false, 'count' => 0]; + } + + $data = []; + foreach ($visitors as $visitor) { + $events = $this->em->getRepository(AnalyticsEvent::class)->findBy( + ['visitor' => $visitor], + ['createdAt' => 'DESC'], + ); + + $data[] = [ + 'visitor' => $visitor, + 'events' => $events, + ]; + } + + $pdfPath = $this->generateAccessPdf($data, $ip); + + $this->mailer->sendEmail( + $email, + 'E-Ticket - Vos donnees personnelles (RGPD)', + $this->twig->render('emails/rgpd_access.html.twig'), + null, + null, + false, + [['path' => $pdfPath, 'name' => 'donnees-personnelles.pdf']], + ); + + @unlink($pdfPath); + + return ['found' => true, 'count' => \count($visitors)]; + } + + /** + * @return array{found: bool, deleted: int} + */ + public function handleDeletionRequest(string $ip, string $email): array + { + $ipHash = hash('sha256', $ip); + $visitors = $this->em->getRepository(AnalyticsUniqId::class)->findBy(['ipHash' => $ipHash]); + + if (0 === \count($visitors)) { + return ['found' => false, 'deleted' => 0]; + } + + $count = \count($visitors); + + foreach ($visitors as $visitor) { + $this->em->remove($visitor); + } + $this->em->flush(); + + $pdfPath = $this->generateDeletionPdf($ip); + + $this->mailer->sendEmail( + $email, + 'E-Ticket - Attestation de suppression (RGPD)', + $this->twig->render('emails/rgpd_deletion.html.twig'), + null, + null, + false, + [['path' => $pdfPath, 'name' => 'attestation-suppression.pdf']], + ); + + @unlink($pdfPath); + + return ['found' => true, 'deleted' => $count]; + } + + /** + * @param list}> $data + */ + private function generateAccessPdf(array $data, string $ip): string + { + $logoPath = $this->projectDir.'/public/logo.png'; + $logoBase64 = file_exists($logoPath) ? 'data:image/png;base64,'.base64_encode((string) file_get_contents($logoPath)) : ''; + + $html = $this->twig->render('pdf/rgpd_access.html.twig', [ + 'data' => $data, + 'ip' => $ip, + 'logo' => $logoBase64, + 'date' => new \DateTimeImmutable(), + ]); + + return $this->renderPdf($html); + } + + private function generateDeletionPdf(string $ip): string + { + $logoPath = $this->projectDir.'/public/logo.png'; + $logoBase64 = file_exists($logoPath) ? 'data:image/png;base64,'.base64_encode((string) file_get_contents($logoPath)) : ''; + + $html = $this->twig->render('pdf/rgpd_deletion.html.twig', [ + 'ip' => $ip, + 'logo' => $logoBase64, + 'date' => new \DateTimeImmutable(), + ]); + + return $this->renderPdf($html); + } + + private function renderPdf(string $html): string + { + $dompdf = new Dompdf(); + $dompdf->loadHtml($html); + $dompdf->setPaper('A4'); + $dompdf->render(); + + $path = tempnam(sys_get_temp_dir(), 'rgpd_').'.pdf'; + file_put_contents($path, $dompdf->output()); + + return $path; + } +} diff --git a/templates/admin/analytics.html.twig b/templates/admin/analytics.html.twig new file mode 100644 index 0000000..521329b --- /dev/null +++ b/templates/admin/analytics.html.twig @@ -0,0 +1,124 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}Analytics{% endblock %} + +{% block body %} +
+

Analytics

+
+ {% for key, label in {today: "Aujourd'hui", '7d': '7 jours', '30d': '30 jours', all: 'Tout'} %} + + {{ label }} + + {% endfor %} +
+
+ + {# KPIs #} +
+
+

Visiteurs uniques

+

{{ visitors|number_format(0, '.', ' ') }}

+
+
+

Pages vues

+

{{ pageviews|number_format(0, '.', ' ') }}

+
+
+

Pages / visiteur

+

{{ visitors > 0 ? (pageviews / visitors)|number_format(1) : '0' }}

+
+
+

Periode

+

{{ {today: "Aujourd'hui", '7d': '7 derniers jours', '30d': '30 derniers jours', all: 'Depuis le debut'}[period] }}

+
+
+ +
+ {# Top pages #} +
+

Pages les plus visitees

+ {% if top_pages|length > 0 %} +
+ + + + + + + + + {% for page in top_pages %} + + + + + {% endfor %} + +
URLVues
{{ page.url }}{{ page.hits }}
+
+ {% else %} +

Aucune donnee.

+ {% endif %} +
+ + {# Top referrers #} +
+

Referrers

+ {% if top_referrers|length > 0 %} +
+ + + + + + + + + {% for ref in top_referrers %} + + + + + {% endfor %} + +
SourceVues
{{ ref.referrer }}{{ ref.hits }}
+
+ {% else %} +

Aucun referrer.

+ {% endif %} +
+
+ + {# Devices, Browsers, OS #} +
+
+

Appareils

+ {% for d in devices %} +
+ {{ d.deviceType|capitalize }} + {{ d.cnt }} +
+ {% endfor %} +
+
+

Navigateurs

+ {% for b in browsers %} +
+ {{ b.browser }} + {{ b.cnt }} +
+ {% endfor %} +
+
+

Systemes

+ {% for o in os_list %} +
+ {{ o.os }} + {{ o.cnt }} +
+ {% endfor %} +
+
+{% endblock %} diff --git a/templates/admin/base.html.twig b/templates/admin/base.html.twig index 55ef5b7..ac21759 100644 --- a/templates/admin/base.html.twig +++ b/templates/admin/base.html.twig @@ -24,6 +24,7 @@ Evenements Commandes Logs + Analytics Infra diff --git a/templates/emails/rgpd_access.html.twig b/templates/emails/rgpd_access.html.twig new file mode 100644 index 0000000..8caa820 --- /dev/null +++ b/templates/emails/rgpd_access.html.twig @@ -0,0 +1,7 @@ +{% extends 'emails/base.html.twig' %} +{% block content %} +

Bonjour,

+

Suite a votre demande d'acces a vos donnees personnelles (article 15 du RGPD), vous trouverez en piece jointe un document PDF contenant l'ensemble des donnees que nous detenons vous concernant.

+

Si vous souhaitez exercer votre droit a l'effacement, vous pouvez le faire depuis notre page RGPD.

+

Cordialement,
L'equipe E-Ticket

+{% endblock %} diff --git a/templates/emails/rgpd_deletion.html.twig b/templates/emails/rgpd_deletion.html.twig new file mode 100644 index 0000000..9a0316b --- /dev/null +++ b/templates/emails/rgpd_deletion.html.twig @@ -0,0 +1,7 @@ +{% extends 'emails/base.html.twig' %} +{% block content %} +

Bonjour,

+

Suite a votre demande de suppression de vos donnees personnelles (article 17 du RGPD), nous vous confirmons que vos donnees ont ete supprimees de nos systemes.

+

Vous trouverez en piece jointe une attestation de suppression.

+

Cordialement,
L'equipe E-Ticket

+{% endblock %} diff --git a/templates/legal/rgpd.html.twig b/templates/legal/rgpd.html.twig index e92d3fc..7919530 100644 --- a/templates/legal/rgpd.html.twig +++ b/templates/legal/rgpd.html.twig @@ -151,7 +151,56 @@

Tout litige en relation avec le traitement des donnees personnelles est soumis au droit francais. Il est fait attribution exclusive de juridiction aux tribunaux competents de Laon.

-

Derniere mise a jour : {{ "now"|date("d/m/Y") }}

+

Derniere mise a jour : {{ "now"|date("d/m/Y") }}

+ + {% for message in app.flashes('success') %} +

{{ message }}

+ {% endfor %} + {% for message in app.flashes('error') %} +

{{ message }}

+ {% endfor %} + +
+

Exercer vos droits

+ +
+
+

Droit d'acces

+

Recevez par email un PDF contenant toutes les donnees de navigation que nous detenons vous concernant (article 15 du RGPD).

+
+
+ + +
+
+ + +
+ +
+
+ +
+

Droit a l'effacement

+

Supprimez toutes vos donnees de navigation de nos systemes et recevez une attestation par email (article 17 du RGPD).

+
+
+ + +
+
+ + +
+ +
+
+
+
{% endblock %} diff --git a/templates/pdf/rgpd_access.html.twig b/templates/pdf/rgpd_access.html.twig new file mode 100644 index 0000000..14bff67 --- /dev/null +++ b/templates/pdf/rgpd_access.html.twig @@ -0,0 +1,52 @@ + + + + +{% if logo %}{% endif %} +

Droit d'acces aux donnees personnelles

+

Article 15 du RGPD — Genere le {{ date|date('d/m/Y a H:i') }}

+

Adresse IP concernee : {{ ip }}

+ +{% for entry in data %} +

Session #{{ loop.index }} — {{ entry.visitor.createdAt|date('d/m/Y H:i') }}

+ + + + +
Appareil{{ entry.visitor.deviceType }}OS{{ entry.visitor.os ?? 'Inconnu' }}
Navigateur{{ entry.visitor.browser ?? 'Inconnu' }}Langue{{ entry.visitor.language ?? 'Inconnu' }}
Ecran{{ entry.visitor.screenWidth ?? '?' }}x{{ entry.visitor.screenHeight ?? '?' }}Compte lie{{ entry.visitor.user ? entry.visitor.user.email : 'Non' }}
+ +{% if entry.events|length > 0 %} + + + + {% for event in entry.events %} + + + + + + + {% endfor %} + +
DatePageTitreReferrer
{{ event.createdAt|date('d/m/Y H:i') }}{{ event.url }}{{ event.title ?? '-' }}{{ event.referrer ?? '-' }}
+{% else %} +

Aucune page visitee enregistree.

+{% endif %} +{% endfor %} + +

+Document genere automatiquement par E-Ticket (Association E-Cosplay) conformement au Reglement General sur la Protection des Donnees (RGPD). +Pour toute question : contact@e-cosplay.fr +

+ + diff --git a/templates/pdf/rgpd_deletion.html.twig b/templates/pdf/rgpd_deletion.html.twig new file mode 100644 index 0000000..f46ef75 --- /dev/null +++ b/templates/pdf/rgpd_deletion.html.twig @@ -0,0 +1,40 @@ + + + + +
+{% if logo %}{% endif %} + +

Attestation de suppression de donnees

+ +
+

Conformement a votre demande et en application de l'article 17 du Reglement General sur la Protection des Donnees (RGPD), nous attestons que :

+ +

Les donnees personnelles associees a l'adresse IP {{ ip }} ont ete supprimees de nos systemes le {{ date|date('d/m/Y a H:i') }}.

+ +

Cette suppression concerne l'ensemble des donnees de navigation collectees par notre systeme d'analyse d'audience, incluant :

+
    +
  • Identifiants de session
  • +
  • Pages visitees
  • +
  • Donnees techniques (appareil, navigateur, systeme d'exploitation)
  • +
  • Liens eventuels avec un compte utilisateur
  • +
+
+ +
+

Association E-Cosplay

+

SIREN : 943121517 / RNA : W022006988

+

42 rue de Saint-Quentin, 02800 Beautor, France

+

contact@e-cosplay.fr

+

Document genere automatiquement — Reference : RGPD-DEL-{{ date|date('YmdHis') }}

+
+
+ +