[ '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); } }