- 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>
249 lines
11 KiB
PHP
249 lines
11 KiB
PHP
<?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);
|
||
}
|
||
}
|