Files
fitme3070.pt/config/functions_trash.php
T
andreandClaude Opus 5.5 04c8c83e66 feat: aulas experimentais guardadas, anti-spam partilhado nos formulários, audit das escritas
- Pedido de aula experimental passa a ficar em trial_leads (por ginásio)
  ANTES do email: sem SMTP o visitante já não vê «erro» e o pedido não se
  perde. Gestão → Aulas experimentais: estados, notas, CSV (auditado),
  apagar para a lixeira; aviso «sem email» quando o aviso não saiu.
- Aula experimental, inscrição e eventos usam o anti-spam do platform-tools
  (honeypot, carimbo assinado com tempo mínimo, prova de JS, limite por IP,
  pontuação de conteúdo) e o bloqueio de IP, em vez de honeypots locais.
  Recusas ficam em blocked_submissions; resposta igual à de sucesso.
  Falha aberto se o módulo rebentar.
- Checkbox de consentimento da aula experimental passa a ser guardada.
- Audit das escritas de conteúdo: criar/editar equipa, modalidades,
  eventos, mensalidades, pedidos de inscrição, galerias, horário (incl.
  arrastar no calendário), horas, contactos e ginásios, com antes/depois
  campo a campo (audit_row/audit_change). Exportação CSV de inscrições
  auditada.
- .table-responsive em todas as tabelas do backoffice.
- Migração: database/migration_20260926_trial_leads.sql (só cria tabelas).

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

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

249 lines
11 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,
]);
}
// ─── 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);
}
}