feat(rgpd): pedido de acesso/apagamento de dados + processo escrito (item 9.5)

- migration_20260925_rgpd_requests.sql: tabela rgpd_requests (email, tipo,
  estado, consentimento).
- config/functions_rgpd.php: rgpd_find() (acesso, art.º 15) e rgpd_erase()
  (apagamento por anonimização, art.º 17) sobre membership_leads/trial_leads/
  event_leads, + CRUD dos pedidos.
- Política de Privacidade: formulário público (POST + CSRF + anti-spam) para
  pedir acesso ou apagamento, e o processo escrito (resposta em 30 dias).
- Backoffice: página "Pedidos RGPD" (nav + index) para exportar os dados de um
  email (CSV, audit) ou anonimizá-los (POST + CSRF, audit).

O email está desligado, por isso os pedidos ficam guardados (como as leads),
não dependem de SMTP. Texto legal com placeholders — rever com jurista antes
do go-live (como já constava na Política).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
andreandClaude Fable 5 committed 2026-09-25 23:48:18 +01:00
1 parent 116817e2de
commit 3f2c505c17
6 files changed
+248 -1

No files matched your search

+1
View File
@@ -43,6 +43,7 @@ require_once __DIR__ . '/functions_reviews.php'; // reviews Google (Places
require_once __DIR__ . '/functions_trials.php'; // pedidos de aula experimental
require_once __DIR__ . '/functions_privacy.php'; // mapa e fotos da Google só a pedido / servidos localmente
require_once __DIR__ . '/functions_forms.php'; // anti-spam + bloqueio de IP dos formulários públicos
require_once __DIR__ . '/functions_rgpd.php'; // pedidos de acesso/apagamento (art.º 15/17)
// ─── DB-backed settings → legacy constants ───────────────────────────────────
// Resolved once from the settings cache so call sites read plain constants.
+74
View File
@@ -0,0 +1,74 @@
<?php
// RGPD — pedidos de acesso (art.º 15.º) e apagamento (art.º 17.º).
//
// Os formulários públicos guardam PII em membership_leads, trial_leads e
// event_leads, sempre identificável pelo email. O "apagamento" aqui é
// anonimização: a linha fica (estatística/audit), mas o nome/email/telefone
// passam a placeholders — o dado pessoal deixa de existir sem quebrar nada.
function rgpd_pii_tables(): array {
return [
'membership_leads' => 'Pedidos de inscrição',
'trial_leads' => 'Aulas experimentais',
'event_leads' => 'Inscrições em eventos',
];
}
// Devolve os dados pessoais associados a um email, organizados por tabela.
function rgpd_find(PDO $pdo, string $email): array {
$out = [];
foreach (rgpd_pii_tables() as $table => $label) {
$st = $pdo->prepare(
"SELECT name, email, phone, message, created_at FROM `$table` WHERE email = ? ORDER BY created_at DESC"
);
$st->execute([$email]);
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
if ($rows) $out[$table] = $rows;
}
return $out;
}
// Anonimiza os dados pessoais de um email em todas as tabelas públicas.
// Devolve o número de registos alterados.
function rgpd_erase(PDO $pdo, string $email): int {
$n = 0;
foreach (array_keys(rgpd_pii_tables()) as $table) {
$st = $pdo->prepare(
"UPDATE `$table` SET name = 'Apagado (RGPD)', email = '', phone = '', message = '' WHERE email = ?"
);
$st->execute([$email]);
$n += $st->rowCount();
}
return $n;
}
function db_rgpd_request_create(PDO $pdo, array $d): int {
$st = $pdo->prepare(
"INSERT INTO rgpd_requests (gym_id, email, request_type, message, consent) VALUES (?, ?, ?, ?, ?)"
);
$st->execute([
(int) ($d['gym_id'] ?? 0),
(string) $d['email'],
(string) $d['request_type'],
(string) ($d['message'] ?? ''),
(int) ($d['consent'] ?? 0),
]);
return (int) $pdo->lastInsertId();
}
function db_rgpd_request_get(PDO $pdo, int $id): ?array {
$st = $pdo->prepare("SELECT * FROM rgpd_requests WHERE id = ? LIMIT 1");
$st->execute([$id]);
return $st->fetch(PDO::FETCH_ASSOC) ?: null;
}
function db_rgpd_requests_list(PDO $pdo): array {
return $pdo->query(
"SELECT * FROM rgpd_requests WHERE deleted_at IS NULL ORDER BY created_at DESC"
)->fetchAll(PDO::FETCH_ASSOC);
}
function db_rgpd_request_mark(PDO $pdo, int $id, string $status, int $by): void {
$st = $pdo->prepare("UPDATE rgpd_requests SET status = ?, processed_at = NOW(), processed_by = ? WHERE id = ?");
$st->execute([$status, $by, $id]);
}
@@ -0,0 +1,20 @@
-- RGPD (art.º 15.º/17.º): pedidos de acesso e de apagamento de dados pessoais.
-- O visitante submete um pedido na Política de Privacidade; fica aqui (o email
-- está desligado, por isso o pedido é guardado, como as leads). O backoffice
-- processa: acesso = exportar os dados; apagamento = anonimizar.
CREATE TABLE IF NOT EXISTS `rgpd_requests` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`gym_id` INT NOT NULL DEFAULT 0,
`email` VARCHAR(190) NOT NULL,
`request_type` ENUM('acesso','apagamento') NOT NULL,
`message` TEXT NULL,
`status` ENUM('novo','concluido') NOT NULL DEFAULT 'novo',
`consent` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`processed_at` DATETIME NULL DEFAULT NULL,
`processed_by` INT UNSIGNED NULL DEFAULT NULL,
`deleted_at` DATETIME NULL DEFAULT NULL,
`deleted_by` INT UNSIGNED NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_rgpd_requests_status` (`status`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+4
View File
@@ -173,6 +173,10 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/trash/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/trash/index.php') ?>"><i class="bi bi-trash3-fill"></i>Lixeira</a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/rgpd/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/rgpd/index.php') ?>"><i class="bi bi-shield-lock-fill"></i>Pedidos RGPD</a>
</li>
</ul>
<?php endif; ?>
+62 -1
View File
@@ -11,6 +11,36 @@ require __DIR__ . '/_open.php';
$pp_name = htmlspecialchars($info['name'] ?? 'FitMe 3070');
$pp_email = htmlspecialchars($info['email'] ?? '');
$pp_addr = htmlspecialchars($info['adress'] ?? '');
// Pedido RGPD (acesso/apagamento). POST + CSRF + anti-spam; fica guardado
// (o email está desligado, não pode depender de SMTP) e é processado no
// backoffice (Sistema → Pedidos RGPD).
$rgpd_status = '';
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
csrf_check();
$rgpd_email = trim((string) ($_POST['rgpd_email'] ?? ''));
$rgpd_type = (string) ($_POST['rgpd_type'] ?? '') === 'apagamento' ? 'apagamento' : 'acesso';
$rgpd_msg = trim((string) ($_POST['rgpd_message'] ?? ''));
$rgpd_consent = !empty($_POST['rgpd_consent']);
if (!filter_var($rgpd_email, FILTER_VALIDATE_EMAIL) || !$rgpd_consent) {
$rgpd_status = 'erro';
} elseif (public_form_screen($pdo, 'rgpd', 'rgpd_requests', $rgpd_email, $rgpd_msg) !== null) {
$rgpd_status = 'ok'; // anti-spam: mesma resposta de sucesso
} else {
try {
db_rgpd_request_create($pdo, [
'gym_id' => (int) $gym['id'],
'email' => $rgpd_email,
'request_type' => $rgpd_type,
'message' => $rgpd_msg,
'consent' => 1,
]);
$rgpd_status = 'ok';
} catch (Throwable $e) {
$rgpd_status = 'erro';
}
}
}
?>
<section class="privacy-section" style="padding: 140px 0 60px;">
<div class="container">
@@ -59,10 +89,41 @@ $pp_addr = htmlspecialchars($info['adress'] ?? '');
<p>O mapa da secção de contactos é do <b>Google Maps</b> e só é carregado se carregares em «Mostrar mapa»; nesse momento a Google recebe o teu endereço IP e pode guardar cookies, segundo a <a href="https://policies.google.com/privacy" target="_blank" rel="noopener">política da Google</a>. As avaliações apresentadas vêm do perfil público do ginásio no Google; as fotos dos autores são guardadas neste site para o teu navegador não ter de as pedir à Google.</p>
<h2 class="h4 mt-4">5. Os teus direitos</h2>
<p>Tens direito de acesso, retificação, apagamento, limitação, portabilidade e oposição, e de retirar o consentimento a qualquer momento (sem afetar o tratamento feito até aí). Basta escreveres para <a href="mailto:<?= $pp_email ?>"><?= $pp_email !== '' ? $pp_email : '[EMAIL]' ?></a>; respondemos no prazo de um mês.
<p>Tens direito de acesso, retificação, apagamento, limitação, portabilidade e oposição, e de retirar o consentimento a qualquer momento (sem afetar o tratamento feito até aí). Podes pedir a exportação dos teus dados ou o seu apagamento com o formulário abaixo; respondemos no prazo de um mês.
Se tiveres conta na área de cliente, podes corrigir os teus dados e preferências no teu perfil.
Tens ainda o direito de apresentar reclamação à Comissão Nacional de Proteção de Dados (<a href="https://www.cnpd.pt" target="_blank" rel="noopener">www.cnpd.pt</a>).</p>
<h3 class="h6 mt-4 mb-2">Pedir acesso ou apagamento</h3>
<?php if ($rgpd_status === 'ok'): ?>
<div class="alert alert-success py-2">Pedido recebido. Vamos responder no prazo de um mês.</div>
<?php elseif ($rgpd_status === 'erro'): ?>
<div class="alert alert-danger py-2">Não foi possível enviar o pedido. Confirma o e-mail e tenta de novo.</div>
<?php endif; ?>
<form method="POST" action="" class="mt-2 mb-3" style="max-width:520px;">
<?= public_form_trap('rgpd') /* honeypot + carimbo + prova de JS */ ?>
<?= csrf_field() ?>
<div class="mb-2">
<label class="form-label" for="rgpd_email">O teu e-mail</label>
<input type="email" class="form-control" id="rgpd_email" name="rgpd_email" required>
</div>
<div class="mb-2">
<label class="form-label" for="rgpd_type">O que pretendes?</label>
<select class="form-select" id="rgpd_type" name="rgpd_type">
<option value="acesso">Aceder aos meus dados (exportação)</option>
<option value="apagamento">Apagar os meus dados</option>
</select>
</div>
<div class="mb-2">
<label class="form-label" for="rgpd_message">Mensagem (opcional)</label>
<textarea class="form-control" id="rgpd_message" name="rgpd_message" rows="2"></textarea>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="rgpd_consent" name="rgpd_consent" value="1" required>
<label class="form-check-label" for="rgpd_consent">Consinto no tratamento dos meus dados para responder a este pedido.</label>
</div>
<button type="submit" class="btn btn-primary">Enviar pedido</button>
</form>
<h2 class="h4 mt-4">6. Cookies</h2>
<p>Este site usa apenas um cookie técnico de sessão (<code>FIT_SESS</code>), necessário para os formulários e para a área reservada, que expira ao fechar o navegador. Não usamos cookies de publicidade nem de estatística. O Google Maps só coloca cookies se escolheres ver o mapa.</p>
+87
View File
@@ -0,0 +1,87 @@
<?php
require __DIR__ . '/../../../config/db.php';
require __DIR__ . '/../../../config/functions.php';
$user = require_admin();
// Apagar dados (POST + CSRF) — anonimiza e marca o pedido como concluído.
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
csrf_check();
$id = (int) ($_POST['id'] ?? 0);
if (isset($_POST['erase']) && $id) {
$req = db_rgpd_request_get($pdo, $id);
if ($req) {
$n = rgpd_erase($pdo, $req['email']);
db_rgpd_request_mark($pdo, $id, 'concluido', (int) $user['id']);
audit('rgpd.erase', [
'target_type' => 'rgpd_request', 'target_id' => $id,
'summary' => 'Apagamento RGPD de ' . audit_mask_email($req['email']) . ' — ' . $n . ' registos anonimizados',
]);
session()->setFlashdata('success', 'Dados de ' . htmlspecialchars($req['email']) . ' anonimizados (' . $n . ' registos).');
}
}
header('Location: ' . base_url('views/admin/rgpd/index.php'));
exit;
}
// Exportar dados (acesso, art.º 15.º) — CSV com tudo o que existe para o email.
if (isset($_GET['csv']) && is_numeric($_GET['csv'])) {
$req = db_rgpd_request_get($pdo, (int) $_GET['csv']);
if ($req) {
audit('rgpd.export', [
'target_type' => 'rgpd_request', 'target_id' => (int) $_GET['csv'],
'summary' => 'Exportação RGPD (acesso) de ' . audit_mask_email($req['email']),
]);
$rows = [];
foreach (rgpd_find($pdo, $req['email']) as $table => $items) {
foreach ($items as $it) {
$rows[] = [rgpd_pii_tables()[$table] ?? $table, $it['name'], $it['email'], $it['phone'], $it['message'] ?? '', $it['created_at']];
}
}
csv_export('rgpd', 'dados-' . substr(md5($req['email']), 0, 8), 'Exportação RGPD (acesso) de ' . audit_mask_email($req['email']), ['Fonte', 'Nome', 'E-mail', 'Telefone', 'Mensagem', 'Data'], $rows);
}
}
$requests = db_rgpd_requests_list($pdo);
$page_title = 'Pedidos RGPD';
include __DIR__ . '/../../../includes/header.php';
?>
<div class="container">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">Pedidos RGPD</h1>
</div>
<p class="text-muted small">Pedidos de acesso (exportação) e apagamento feitos na Política de Privacidade. O apagamento anonimiza o nome/e-mail/telefone nas leads — a linha fica para estatística e audit.</p>
<div class="card shadow-sm"><div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light"><tr>
<th>Data</th><th>E-mail</th><th>Pedido</th><th>Mensagem</th><th>Estado</th><th style="width:230px">Ações</th>
</tr></thead>
<tbody>
<?php if (!$requests): ?>
<tr><td colspan="6" class="text-center text-muted py-4">Sem pedidos RGPD.</td></tr>
<?php else: foreach ($requests as $r): ?>
<tr>
<td class="text-nowrap small"><?= htmlspecialchars(format_date_pt((string) $r['created_at'])) ?></td>
<td><?= htmlspecialchars($r['email']) ?></td>
<td><span class="badge <?= $r['request_type'] === 'apagamento' ? 'bg-danger' : 'bg-info text-dark' ?>"><?= $r['request_type'] === 'apagamento' ? 'Apagamento' : 'Acesso' ?></span></td>
<td class="small text-muted"><?= htmlspecialchars((string) $r['message']) ?></td>
<td><span class="badge <?= $r['status'] === 'novo' ? 'bg-warning text-dark' : 'bg-success' ?>"><?= $r['status'] === 'novo' ? 'Novo' : 'Concluído' ?></span></td>
<td class="text-nowrap">
<a class="btn btn-sm btn-outline-primary" href="?csv=<?= (int) $r['id'] ?>"><i class="bi bi-download me-1"></i>Exportar</a>
<?php if ($r['status'] === 'novo'): ?>
<form method="POST" class="d-inline" onsubmit="return confirm('Anonimizar todos os dados pessoais de <?= htmlspecialchars($r['email'], ENT_QUOTES) ?>? (irreversível)')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int) $r['id'] ?>">
<button class="btn btn-sm btn-outline-danger" name="erase" value="1"><i class="bi bi-eraser me-1"></i>Apagar</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; endif; ?>
</tbody>
</table>
</div>
</div></div>
</div>
<?php include __DIR__ . '/../../../includes/footer.php';