Files
fitme3070.pt/config/functions_trash.php
T
andreandClaude Opus 5.5 b202262da0 feat(rgpd): política de privacidade e termos, mapa a pedido, fotos locais, retenção, tema claro/escuro
- Política de privacidade reescrita: tabela de dados/finalidades/fundamento
  (inclui os pedidos de aula, inscrição e eventos com IP), prazos concretos,
  subcontratantes, Google Maps e reviews, cookies. Termos e condições novos
  (/home/termos), no rodapé e no sitemap. Campos [ENTRE PARÊNTESES RETOS]
  e revisão por jurista pendentes antes de publicar no domínio real.
- Google Maps só carrega ao carregar em «Mostrar mapa» (map_click_to_load);
  as fotos dos autores das reviews são guardadas pelo servidor em
  uploads/reviews/ e servidas localmente (review_avatar_url).
- Retenção que a política declara, aplicada pelo cron (04:00): leads com
  mais de 12 meses → lixeira; blocked_submissions e outbox de segurança
  apagados aos 90 dias (trash_retention_sweep, auditado).
- Backoffice e login com tema claro/escuro/automático: data-bs-theme
  aplicado no <head> antes do CSS (sem flash), escolha em localStorage,
  segue o sistema em tempo real, color-scheme e theme-color para os dois.
  Cores próprias passaram a variáveis do Bootstrap. lang="pt-PT".

Testado numa cópia da BD de produção: 72/72 verificações.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:39:05 +01:00

278 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// ─── Lixeira ─────────────────────────────────────────────────────────────────
// Tudo o que é importante apaga-se por soft delete: deleted_at + deleted_by.
// O registo sai das listagens e do site, aparece em Sistema → Lixeira
// (views/admin/trash/index.php), restaura-se a qualquer momento e só pode ser
// eliminado de vez passados TRASH_RETENTION_DAYS dias.
//
// A regra dos 30 dias vive no WHERE do DELETE (trash_purge), não na vista:
// esconder o botão não chega, um POST forjado tem de falhar também.
//
// Cada tipo declara-se uma vez em trash_types(); apagar, restaurar, listar e
// eliminar leem todos do mesmo registo. Os nomes de tabela e colunas vêm só
// deste array (nunca do pedido) e ainda assim são validados por regex antes de
// entrarem no SQL.
//
// Schema: database/migration_20260925_trash.sql.
const TRASH_RETENTION_DAYS = 30;
function trash_types(): array {
return [
'team' => [
'label' => 'Membro da equipa', 'table' => 'team', 'pk' => 'id',
'title' => "t.name", 'edit' => 'views/admin/team/edit.php?id=',
],
'modalidade' => [
'label' => 'Modalidade', 'table' => 'modalidades', 'pk' => 'm_id',
'title' => "t.nome", 'edit' => 'views/admin/modalidades/edit.php?id=',
],
'event' => [
'label' => 'Evento', 'table' => 'events', 'pk' => 'e_id',
'title' => "t.title", 'edit' => 'views/admin/events/edit.php?id=',
],
'membership' => [
'label' => 'Mensalidade', 'table' => 'memberships', 'pk' => 'id',
'title' => "t.name", 'edit' => 'views/admin/memberships/edit.php?id=',
],
'gallery' => [
'label' => 'Galeria', 'table' => 'galleries', 'pk' => 'id',
'title' => "t.name", 'edit' => null,
],
'gallery_image' => [
'label' => 'Imagem de galeria', 'table' => 'gallery', 'pk' => 'i_id',
'title' => "CONCAT(COALESCE((SELECT g.name FROM galleries g WHERE g.id = t.i_project_id), 'Galeria'), ' — ', t.i_file_name)",
'edit' => null,
],
'horario' => [
'label' => 'Aula do horário', 'table' => 'horario', 'pk' => 'id',
'title' => "CONCAT(COALESCE((SELECT m.nome FROM modalidades m WHERE m.m_id = t.id_modalidade), 'Aula'), ' — ', t.dia_semana, ' ', TIME_FORMAT(t.hora_inicio, '%H:%i'))",
'edit' => null,
],
'work_hours' => [
'label' => 'Horário de funcionamento', 'table' => 'work_hours', 'pk' => 'id',
'title' => "CONCAT(t.week_day, ' ', TIME_FORMAT(t.open, '%H:%i'), '–', TIME_FORMAT(t.close, '%H:%i'))",
'edit' => null,
],
'event_lead' => [
'label' => 'Inscrição em evento', 'table' => 'event_leads', 'pk' => 'lead_id',
'title' => "CONCAT(t.name, COALESCE(CONCAT(' — ', (SELECT e.title FROM events e WHERE e.e_id = t.e_id)), ''))",
'edit' => null, 'personal' => true,
],
'trial_lead' => [
'label' => 'Pedido de aula experimental', 'table' => 'trial_leads', 'pk' => 'id',
'title' => "CONCAT(t.name, IF(t.phone <> '', CONCAT(' — ', t.phone), ''))",
'edit' => null, 'personal' => true,
],
'membership_lead' => [
'label' => 'Pedido de inscrição', 'table' => 'membership_leads', 'pk' => 'id',
'title' => "CONCAT(t.name, IF(t.membership_name <> '', CONCAT(' — ', t.membership_name), ''))",
'edit' => null, 'personal' => true,
],
];
}
function trash_type(string $type): array {
$types = trash_types();
if (!isset($types[$type])) throw new InvalidArgumentException("Tipo de lixeira desconhecido: {$type}");
$t = $types[$type];
foreach (['table', 'pk'] as $k) {
if (!preg_match('/^[A-Za-z0-9_]+$/', $t[$k])) throw new InvalidArgumentException("Identificador inválido em {$type}.{$k}");
}
return $t;
}
function _trash_user_id(): ?int {
$id = (int) ($_SESSION['user_id'] ?? 0);
return $id > 0 ? $id : null;
}
// Marca como apagado. Devolve false se já estava apagado ou não existe.
function trash_delete(PDO $pdo, string $type, int $id): bool {
$t = trash_type($type);
$stmt = $pdo->prepare("UPDATE `{$t['table']}` SET deleted_at = NOW(), deleted_by = ? WHERE `{$t['pk']}` = ? AND deleted_at IS NULL");
$stmt->execute([_trash_user_id(), $id]);
$ok = $stmt->rowCount() > 0;
if ($ok) {
_trash_after($pdo, $type, $id, 'delete');
_trash_audit($pdo, 'trash.delete', $type, $id, 'Enviado para a lixeira');
}
return $ok;
}
function trash_restore(PDO $pdo, string $type, int $id): bool {
$t = trash_type($type);
$stmt = $pdo->prepare("UPDATE `{$t['table']}` SET deleted_at = NULL, deleted_by = NULL WHERE `{$t['pk']}` = ? AND deleted_at IS NOT NULL");
$stmt->execute([$id]);
$ok = $stmt->rowCount() > 0;
if ($ok) {
_trash_after($pdo, $type, $id, 'restore');
_trash_audit($pdo, 'trash.restore', $type, $id, 'Restaurado da lixeira');
}
return $ok;
}
// Elimina de vez — só se estiver na lixeira há pelo menos TRASH_RETENTION_DAYS
// dias. A condição está no próprio DELETE, por isso não há corrida entre a
// verificação e a remoção.
function trash_purge(PDO $pdo, string $type, int $id): bool {
$t = trash_type($type);
$row = trash_get($pdo, $type, $id);
if (!$row || !$row['can_purge']) return false;
$pdo->beginTransaction();
try {
if ($type === 'gallery') {
// As imagens da galeria vão com ela.
$imgs = $pdo->prepare("SELECT i_id FROM gallery WHERE i_project_id = ?");
$imgs->execute([$id]);
foreach ($imgs->fetchAll(PDO::FETCH_COLUMN) as $iid) _trash_image_file($pdo, (int) $iid, 'purge');
$pdo->prepare("DELETE FROM gallery WHERE i_project_id = ?")->execute([$id]);
}
if ($type === 'gallery_image') _trash_image_file($pdo, $id, 'purge');
$stmt = $pdo->prepare(
"DELETE FROM `{$t['table']}` WHERE `{$t['pk']}` = ?
AND deleted_at IS NOT NULL
AND deleted_at <= DATE_SUB(NOW(), INTERVAL " . (int) TRASH_RETENTION_DAYS . " DAY)"
);
$stmt->execute([$id]);
$ok = $stmt->rowCount() > 0;
$ok ? $pdo->commit() : $pdo->rollBack();
if ($ok) _trash_audit($pdo, 'trash.purge', $type, $id, 'Eliminado de vez', (string) $row['title']);
return $ok;
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
function trash_get(PDO $pdo, string $type, int $id): ?array {
foreach (trash_list($pdo, $type, $id) as $row) return $row;
return null;
}
// Tudo o que está na lixeira, mais recente primeiro. Com $type/$id devolve só
// esse registo (usado por trash_purge).
function trash_list(PDO $pdo, ?string $onlyType = null, ?int $onlyId = null): array {
$out = [];
foreach (trash_types() as $type => $_) {
if ($onlyType !== null && $type !== $onlyType) continue;
$t = trash_type($type);
$sql = "SELECT t.`{$t['pk']}` AS id, {$t['title']} AS title, t.deleted_at, t.deleted_by,
u.name AS deleted_by_name,
(t.deleted_at <= DATE_SUB(NOW(), INTERVAL " . (int) TRASH_RETENTION_DAYS . " DAY)) AS can_purge,
GREATEST(0, " . (int) TRASH_RETENTION_DAYS . " - DATEDIFF(NOW(), t.deleted_at)) AS days_left
FROM `{$t['table']}` t
LEFT JOIN users u ON u.id = t.deleted_by
WHERE t.deleted_at IS NOT NULL";
$args = [];
if ($onlyId !== null) { $sql .= " AND t.`{$t['pk']}` = ?"; $args[] = $onlyId; }
try {
$st = $pdo->prepare($sql);
$st->execute($args);
} catch (PDOException $e) {
// Migração ainda não aplicada nesta tabela: a lixeira mostra o resto.
error_log('[trash] ' . $type . ': ' . $e->getMessage());
continue;
}
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
$r['type'] = $type;
$r['label'] = $t['label'];
$r['personal'] = !empty($t['personal']);
$r['can_purge'] = (bool) $r['can_purge'];
$r['days_left'] = (int) $r['days_left'];
$out[] = $r;
}
}
usort($out, fn($a, $b) => strcmp((string) $b['deleted_at'], (string) $a['deleted_at']));
return $out;
}
function trash_count(PDO $pdo): int {
return count(trash_list($pdo));
}
// Cada passo da lixeira fica no audit_log, com o nome do registo (na
// eliminação definitiva é a única coisa que resta dele).
function _trash_audit(PDO $pdo, string $action, string $type, int $id, string $summary, ?string $label = null): void {
if (!function_exists('platform_audit')) return;
if ($label === null) {
$row = trash_get($pdo, $type, $id);
if ($row === null) {
// Restaurado: já não está na lixeira; o nome vem do próprio registo.
$t = trash_type($type);
try {
$st = $pdo->prepare("SELECT {$t['title']} FROM `{$t['table']}` t WHERE t.`{$t['pk']}` = ?");
$st->execute([$id]);
$label = (string) $st->fetchColumn();
} catch (Throwable $e) { $label = ''; }
} else {
$label = (string) $row['title'];
}
}
platform_audit($pdo, $action, [
'target_type' => $type, 'target_id' => $id, 'target_label' => $label, 'summary' => $summary,
]);
}
// ─── Retenção (política de privacidade, secção 3) ────────────────────────────
// Leads com mais de 12 meses vão para a lixeira (deleted_by NULL = sistema); a
// eliminação definitiva continua a exigir os 30 dias e um clique. Submissões
// recusadas pelo anti-spam e o outbox de segurança: apagados aos 90 dias.
// Chamado uma vez por dia pelo cron.php.
function trash_retention_sweep(PDO $pdo): array {
$done = [];
foreach (['trial_lead' => 'trial_leads', 'membership_lead' => 'membership_leads', 'event_lead' => 'event_leads'] as $type => $table) {
try {
$n = $pdo->exec("UPDATE `{$table}` SET deleted_at = NOW(), deleted_by = NULL
WHERE deleted_at IS NULL AND created_at < DATE_SUB(NOW(), INTERVAL 12 MONTH)");
if ($n) {
$done[$type] = (int) $n;
if (function_exists('platform_audit')) {
platform_audit($pdo, 'trash.retention', ['target_type' => $type, 'actor' => false,
'summary' => $n . ' registo(s) com mais de 12 meses enviados para a lixeira']);
}
}
} catch (Throwable $e) { error_log('[retention] ' . $table . ': ' . $e->getMessage()); }
}
foreach (['blocked_submissions' => 'created_at', 'hub_security_outbox' => 'occurred_at'] as $table => $col) {
try {
$n = $pdo->exec("DELETE FROM `{$table}` WHERE `{$col}` < DATE_SUB(NOW(), INTERVAL 90 DAY)");
if ($n) $done[$table] = (int) $n;
} catch (Throwable $e) { /* tabela ainda não existe */ }
}
return $done;
}
// ─── Efeitos em ficheiros ────────────────────────────────────────────────────
// O site público lista as fotos da galeria com glob() na pasta, não pela BD.
// Por isso uma imagem apagada tem de sair da pasta: vai para .trash/ (o
// .htaccess da raiz recusa qualquer caminho com um componente começado por
// ponto) e volta no restauro. Só a eliminação definitiva apaga o ficheiro.
function _trash_after(PDO $pdo, string $type, int $id, string $action): void {
if ($type === 'gallery_image') _trash_image_file($pdo, $id, $action);
}
function _trash_image_file(PDO $pdo, int $imageId, string $action): void {
$st = $pdo->prepare("SELECT i_file_name, i_project_id FROM gallery WHERE i_id = ? LIMIT 1");
$st->execute([$imageId]);
$img = $st->fetch(PDO::FETCH_ASSOC);
if (!$img) return;
$name = basename((string) $img['i_file_name']);
if ($name === '' || $name[0] === '.') return;
$dir = dirname(__DIR__) . '/uploads/gallery/' . (int) $img['i_project_id'] . '/';
$live = $dir . $name;
$trash = $dir . '.trash/' . $name;
if ($action === 'delete' && is_file($live)) {
if (!is_dir($dir . '.trash')) @mkdir($dir . '.trash', 0775, true);
@rename($live, $trash);
} elseif ($action === 'restore' && is_file($trash)) {
@rename($trash, $live);
} elseif ($action === 'purge') {
if (is_file($trash)) @unlink($trash);
}
}