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>
This commit is contained in:
andreandClaude Opus 5.5 committed 2026-09-25 11:33:24 +01:00
1 parent 0e8b53f620
commit 04c8c83e66
29 files changed
+480 -29

No files matched your search

+2
View File
@@ -37,6 +37,8 @@ require_once __DIR__ . '/functions_events.php'; // time-limited events +
require_once __DIR__ . '/functions_info.php'; // contactos (fitme3070_info) editor
require_once __DIR__ . '/functions_memberships.php'; // mensalidades + pedidos de inscrição
require_once __DIR__ . '/functions_reviews.php'; // reviews Google (Places API) + cache
require_once __DIR__ . '/functions_trials.php'; // pedidos de aula experimental
require_once __DIR__ . '/functions_forms.php'; // anti-spam + bloqueio de IP dos formulários públicos
// ─── DB-backed settings → legacy constants ───────────────────────────────────
// Resolved once from the settings cache so call sites read plain constants.
+34
View File
@@ -23,3 +23,37 @@ function audit_mask_email(string $email): string {
[$u, $d] = explode('@', $email, 2);
return mb_substr($u, 0, 1) . '…@' . $d;
}
// ─── Escritas de conteúdo ────────────────────────────────────────────────────
// Os modelos (config/functions_*.php) chamam audit_row() antes de alterar e
// audit_change() depois: o registo leva o antes/depois campo a campo
// (platform_audit_diff) ou, numa criação, os campos criados. Tabela e chave vêm
// do código, nunca do pedido, e ainda assim são validadas.
function audit_row(PDO $pdo, string $table, string $pk, int $id): ?array {
if (!preg_match('/^[A-Za-z0-9_]+$/', $table) || !preg_match('/^[A-Za-z0-9_]+$/', $pk)) return null;
try {
$st = $pdo->prepare("SELECT * FROM `{$table}` WHERE `{$pk}` = ? LIMIT 1");
$st->execute([$id]);
$row = $st->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
} catch (Throwable $e) {
return null;
}
}
function audit_change(PDO $pdo, string $entity, string $table, string $pk, int $id, string $verb,
?array $before = null, string $labelCol = 'name'): void {
if (!function_exists('platform_audit') || $id < 1) return;
$after = audit_row($pdo, $table, $pk, $id) ?? [];
$changes = $before !== null
? platform_audit_diff($before, $after, ['updated_at', 'created_at', 'updated_by'])
: platform_audit_created(array_diff_key($after, array_flip(['created_at', 'updated_at'])));
if ($verb !== 'create' && $before !== null && !$changes) return; // gravou sem mudar nada
platform_audit($pdo, $entity . '.' . $verb, [
'target_type' => $entity,
'target_id' => $id,
'target_label' => (string) ($after[$labelCol] ?? $before[$labelCol] ?? ''),
'changes' => $changes,
]);
}
+7 -2
View File
@@ -128,10 +128,13 @@ function db_events_create(PDO $pdo, array $data): int {
str_replace('T', ' ', (string) $d['publish_until']),
(int) $d['is_active'], $d['cta_label'], $d['cta_url'], (int) $d['show_lead_form'],
]);
return (int) $pdo->lastInsertId();
$__id = (int) $pdo->lastInsertId();
audit_change($pdo, 'event', 'events', 'e_id', (int) $__id, 'create', null, 'title');
return $__id;
}
function db_events_update(PDO $pdo, int $id, array $data): bool {
$__before = audit_row($pdo, 'events', 'e_id', (int) $id);
$d = array_merge(events_defaults(), $data);
$stmt = $pdo->prepare("
UPDATE events SET
@@ -150,7 +153,9 @@ function db_events_update(PDO $pdo, int $id, array $data): bool {
(int) $d['is_active'], $d['cta_label'], $d['cta_url'], (int) $d['show_lead_form'],
$id,
]);
return $stmt->rowCount() > 0;
$__r = $stmt->rowCount() > 0;
audit_change($pdo, 'event', 'events', 'e_id', (int) $id, 'update', $__before, 'title');
return $__r;
}
// Soft delete para a lixeira (config/functions_trash.php).
+47
View File
@@ -0,0 +1,47 @@
<?php
// ─── Formulários públicos: anti-spam e bloqueio de IP (platform-tools) ──────
// Aula experimental, inscrição e eventos usam as defesas partilhadas da frota
// em vez de um honeypot local por formulário:
// - platform_ip_guard(): um IP banido não chega ao formulário;
// - platform_antispam_check(): honeypot, carimbo assinado com tempo mínimo
// (time-trap), prova de JavaScript, limite por IP e pontuação de conteúdo.
// Uma submissão recusada recebe a mesma resposta de sucesso (o bot não aprende
// nada) e fica em `blocked_submissions`, para um falso positivo se poder ler.
//
// Falha aberto: se o módulo rebentar, o formulário continua a funcionar —
// segurança que derruba o formulário é uma avaria, não uma proteção.
require_once __DIR__ . '/../shared/platform-tools/functions_ip_ban.php';
require_once __DIR__ . '/../shared/platform-tools/functions_antispam_forms.php';
platform_antispam_config([
// NÃO mudar num site a correr: invalida os formulários abertos nesse momento.
'key_domain' => 'fitme-antispam|',
'log_table' => 'blocked_submissions',
'keywords' => ['seo', 'backlink', 'backlinks', 'crypto', 'bitcoin', 'casino', 'viagra', 'loan',
'guest post', 'web design', 'website traffic', 'ranking', 'forex', 'investment opportunity'],
// Marcas do português: quem as usa não é pontuado como «texto estrangeiro».
'native' => '/\b(ol[aá]|bom dia|boa (tarde|noite)|gostaria|queria|aulas?|gin[aá]sio|treino|obrigad[oa]|marcar|experimental|hor[aá]rio)\b/u',
]);
// Devolve o motivo da recusa (já registado) ou null para seguir.
function public_form_screen(PDO $pdo, string $form, string $table, string $name, string $text): ?string {
try {
platform_ip_guard($pdo); // sai com 403 se o IP estiver banido
return platform_antispam_check($pdo, $form, $table, ['name' => $name, 'text' => $text]);
} catch (Throwable $e) {
error_log('[antispam] ' . $form . ': ' . $e->getMessage());
return null;
}
}
// Os campos escondidos que o formulário tem de levar (honeypot + carimbo +
// prova de JS). Sem eles o carimbo falha e a submissão é recusada.
function public_form_trap(string $form): string {
try {
return platform_antispam_trap_field($form, $GLOBALS['pdo'] ?? null);
} catch (Throwable $e) {
error_log('[antispam] trap ' . $form . ': ' . $e->getMessage());
return '';
}
}
+7 -2
View File
@@ -17,13 +17,18 @@ function db_gallery_get(PDO $pdo, int $id): ?array {
function db_gallery_create(PDO $pdo, string $name): int {
$stmt = $pdo->prepare("INSERT INTO galleries (name) VALUES (?)");
$stmt->execute([$name]);
return (int) $pdo->lastInsertId();
$__id = (int) $pdo->lastInsertId();
audit_change($pdo, 'gallery', 'galleries', 'id', (int) $__id, 'create', null, 'name');
return $__id;
}
function db_gallery_update(PDO $pdo, int $id, string $name): bool {
$__before = audit_row($pdo, 'galleries', 'id', (int) $id);
$stmt = $pdo->prepare("UPDATE galleries SET name = ? WHERE id = ?");
$stmt->execute([$name, $id]);
return $stmt->rowCount() > 0;
$__r = $stmt->rowCount() > 0;
audit_change($pdo, 'gallery', 'galleries', 'id', (int) $id, 'update', $__before, 'name');
return $__r;
}
// Soft delete para a lixeira (config/functions_trash.php). As imagens ficam
+7 -2
View File
@@ -199,10 +199,13 @@ function db_gyms_create(PDO $pdo, array $d): int {
facebook, messenger, email, website, google_maps, google_maps_link)
VALUES (?, ?, '', '', '', '', '', '', '', '', '', '', '')")->execute([$id, $d['name']]);
$pdo->commit();
return $id;
$__id = $id;
audit_change($pdo, 'gym', 'gyms', 'id', (int) $__id, 'create', null, 'name');
return $__id;
}
function db_gyms_update(PDO $pdo, int $id, array $d): bool {
$__before = audit_row($pdo, 'gyms', 'id', (int) $id);
$pdo->beginTransaction();
$params = _gyms_params($d); $params[] = $id;
$st = $pdo->prepare("UPDATE gyms SET slug = ?, name = ?, locality = ?, postal_code = ?, is_published = ?,
@@ -214,7 +217,9 @@ function db_gyms_update(PDO $pdo, int $id, array $d): bool {
$changed = $st->rowCount() > 0;
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
$pdo->commit();
return $changed;
$__r = $changed;
audit_change($pdo, 'gym', 'gyms', 'id', (int) $id, 'update', $__before, 'name');
return $__r;
}
// Soft delete. The default gym can't go (the root URL needs somewhere to land).
+11 -3
View File
@@ -42,10 +42,13 @@ function db_horario_create(PDO $pdo, array $data): int {
$data['hora_fim'],
$data['local'] ?? '',
]);
return (int) $pdo->lastInsertId();
$__id = (int) $pdo->lastInsertId();
audit_change($pdo, 'horario', 'horario', 'id', (int) $__id, 'create', null, 'dia_semana');
return $__id;
}
function db_horario_update(PDO $pdo, int $id, array $data): bool {
$__before = audit_row($pdo, 'horario', 'id', (int) $id);
$stmt = $pdo->prepare("
UPDATE horario
SET id_modalidade = ?, dia_semana = ?, hora_inicio = ?, hora_fim = ?, local = ?
@@ -59,7 +62,9 @@ function db_horario_update(PDO $pdo, int $id, array $data): bool {
$data['local'] ?? '',
$id,
]);
return $stmt->rowCount() > 0;
$__r = $stmt->rowCount() > 0;
audit_change($pdo, 'horario', 'horario', 'id', (int) $id, 'update', $__before, 'dia_semana');
return $__r;
}
// Soft delete para a lixeira (config/functions_trash.php).
@@ -71,11 +76,14 @@ function db_horario_delete(PDO $pdo, int $id): bool {
// Modalidade and local are left untouched. Times must be HH:MM[:SS]; the caller
// is responsible for validating the day against the allowed set.
function db_horario_move(PDO $pdo, int $id, string $dia_semana, string $hora_inicio, string $hora_fim): bool {
$__before = audit_row($pdo, 'horario', 'id', (int) $id);
$stmt = $pdo->prepare("
UPDATE horario SET dia_semana = ?, hora_inicio = ?, hora_fim = ? WHERE id = ?
");
$stmt->execute([$dia_semana, $hora_inicio, $hora_fim, $id]);
return $stmt->rowCount() >= 0;
$__r = $stmt->rowCount() >= 0;
audit_change($pdo, 'horario', 'horario', 'id', (int) $id, 'update', $__before, 'dia_semana');
return $__r;
}
// Modalidades dropdown (id => nome + cor) for horario create/edit forms.
+4 -1
View File
@@ -45,6 +45,7 @@ function db_info_save(PDO $pdo, array $data): bool {
$cols = array_keys(info_fields());
// One row per gym (id = gyms.id): the gym being edited in the backoffice.
$gid = admin_gym_id();
$__before = audit_row($pdo, 'fitme3070_info', 'id', (int) $gid);
$exists = (bool) $pdo->query("SELECT COUNT(*) FROM fitme3070_info WHERE id = " . $gid)->fetchColumn();
$vals = array_map(fn($c) => $data[$c] ?? '', $cols);
if ($exists) {
@@ -56,5 +57,7 @@ function db_info_save(PDO $pdo, array $data): bool {
$stmt = $pdo->prepare("INSERT INTO fitme3070_info (id, $names, updated_at) VALUES (" . $gid . ", $marks, NOW())");
}
$stmt->execute($vals);
return $stmt->rowCount() > 0;
$__r = $stmt->rowCount() > 0;
audit_change($pdo, 'info', 'fitme3070_info', 'id', (int) $gid, $__before ? 'update' : 'create', $__before, 'name');
return $__r;
}
+11 -3
View File
@@ -93,10 +93,13 @@ function db_memberships_create(PDO $pdo, array $d): int {
$id = (int) $pdo->lastInsertId();
if ($d['is_featured']) _memberships_clear_featured($pdo, $id);
$pdo->commit();
return $id;
$__id = $id;
audit_change($pdo, 'membership', 'memberships', 'id', (int) $__id, 'create', null, 'name');
return $__id;
}
function db_memberships_update(PDO $pdo, int $id, array $d): bool {
$__before = audit_row($pdo, 'memberships', 'id', (int) $id);
$pdo->beginTransaction();
$stmt = $pdo->prepare("
UPDATE memberships SET name = ?, subtitle = ?, price = ?, is_addon = ?, price_suffix = ?,
@@ -110,7 +113,9 @@ function db_memberships_update(PDO $pdo, int $id, array $d): bool {
$changed = $stmt->rowCount() > 0;
if ($d['is_featured']) _memberships_clear_featured($pdo, $id);
$pdo->commit();
return $changed;
$__r = $changed;
audit_change($pdo, 'membership', 'memberships', 'id', (int) $id, 'update', $__before, 'name');
return $__r;
}
// Soft delete para a lixeira (config/functions_trash.php). Deixa de ser o
@@ -187,10 +192,13 @@ function db_membership_lead_create(PDO $pdo, array $d): int {
}
function db_membership_lead_update(PDO $pdo, int $id, string $status, string $notes): bool {
$__before = audit_row($pdo, 'membership_leads', 'id', (int) $id);
if (!isset(membership_lead_statuses()[$status])) return false;
$stmt = $pdo->prepare("UPDATE membership_leads SET status = ?, admin_notes = ?, updated_at = NOW() WHERE id = ?");
$stmt->execute([$status, $notes !== '' ? $notes : null, $id]);
return $stmt->rowCount() > 0;
$__r = $stmt->rowCount() > 0;
audit_change($pdo, 'membership_lead', 'membership_leads', 'id', (int) $id, 'update', $__before, 'name');
return $__r;
}
// Vai para a lixeira (config/functions_trash.php).
+7 -2
View File
@@ -30,10 +30,13 @@ function db_modalidades_create(PDO $pdo, array $data): int {
$data['descricao'] ?? '',
(int) ($data['item_order'] ?? 0),
]);
return (int) $pdo->lastInsertId();
$__id = (int) $pdo->lastInsertId();
audit_change($pdo, 'modalidade', 'modalidades', 'm_id', (int) $__id, 'create', null, 'nome');
return $__id;
}
function db_modalidades_update(PDO $pdo, int $id, array $data): bool {
$__before = audit_row($pdo, 'modalidades', 'm_id', (int) $id);
$stmt = $pdo->prepare("
UPDATE modalidades
SET nome = ?, cor = ?, icon = ?, descricao = ?, item_order = ?
@@ -47,7 +50,9 @@ function db_modalidades_update(PDO $pdo, int $id, array $data): bool {
(int) ($data['item_order'] ?? 0),
$id,
]);
return $stmt->rowCount() > 0;
$__r = $stmt->rowCount() > 0;
audit_change($pdo, 'modalidade', 'modalidades', 'm_id', (int) $id, 'update', $__before, 'nome');
return $__r;
}
// Soft delete para a lixeira (config/functions_trash.php).
+1 -1
View File
@@ -101,7 +101,7 @@ function db_update_setting(PDO $pdo, string $key, string $value, ?int $userId =
if ($old !== $value && function_exists('platform_audit')) {
platform_audit($pdo, 'settings.update', [
'target_type' => 'setting', 'target_id' => $key, 'target_label' => $key,
'changes' => [$key => ['antes' => $old, 'depois' => $value]],
'changes' => [$key => ['from' => $old, 'to' => $value]],
]);
}
}
+7 -2
View File
@@ -34,10 +34,13 @@ function db_team_create(PDO $pdo, array $data): int {
($data['certifications'] ?? '') !== '' ? $data['certifications'] : null,
$data['photo'] ?? null,
]);
return (int) $pdo->lastInsertId();
$__id = (int) $pdo->lastInsertId();
audit_change($pdo, 'team', 'team', 'id', (int) $__id, 'create', null, 'name');
return $__id;
}
function db_team_update(PDO $pdo, int $id, array $data, ?string $photo = null): bool {
$__before = audit_row($pdo, 'team', 'id', (int) $id);
$sets = ["name = ?", "title = ?", "birthday = ?", "`at` = ?", "status = ?", "description = ?",
"skills = ?", "certifications = ?", "updated_at = NOW()"];
$params = [
@@ -57,7 +60,9 @@ function db_team_update(PDO $pdo, int $id, array $data, ?string $photo = null):
$params[] = $id;
$stmt = $pdo->prepare("UPDATE team SET " . implode(', ', $sets) . " WHERE id = ?");
$stmt->execute($params);
return $stmt->rowCount() > 0;
$__r = $stmt->rowCount() > 0;
audit_change($pdo, 'team', 'team', 'id', (int) $id, 'update', $__before, 'name');
return $__r;
}
// Soft delete para a lixeira (config/functions_trash.php).
+5
View File
@@ -59,6 +59,11 @@ function trash_types(): array {
'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), ''))",
+80
View File
@@ -0,0 +1,80 @@
<?php
// ─── Pedidos de aula experimental ────────────────────────────────────────────
// Guardados por site/agendar.php ANTES de se tentar o email, para nenhum
// pedido depender do SMTP. Geridos em Gestão → Aulas experimentais
// (views/admin/trials/index.php), por ginásio. Apagar vai para a lixeira.
// Schema: database/migration_20260926_trial_leads.sql.
function trial_lead_statuses(): array {
return ['novo' => 'Novo', 'contactado' => 'Contactado', 'marcado' => 'Aula marcada', 'descartado' => 'Descartado'];
}
function db_trial_lead_create(PDO $pdo, array $d): int {
$stmt = $pdo->prepare("
INSERT INTO trial_leads (gym_id, name, email, phone, preferred_date, period, message,
consent, consent_text, ip, user_agent)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
");
$stmt->execute([
(int) ($d['gym_id'] ?? 1),
mb_substr(trim((string) ($d['name'] ?? '')), 0, 150),
mb_substr(trim((string) ($d['email'] ?? '')), 0, 190),
mb_substr(trim((string) ($d['phone'] ?? '')), 0, 40),
($d['preferred_date'] ?? '') !== '' ? $d['preferred_date'] : null,
mb_substr(trim((string) ($d['period'] ?? '')), 0, 40),
mb_substr(trim((string) ($d['message'] ?? '')), 0, 2000),
!empty($d['consent']) ? 1 : 0,
mb_substr((string) ($d['consent_text'] ?? ''), 0, 255),
mb_substr((string) ($d['ip'] ?? ''), 0, 45),
mb_substr((string) ($d['user_agent'] ?? ''), 0, 255),
]);
return (int) $pdo->lastInsertId();
}
function db_trial_lead_mark_emailed(PDO $pdo, int $id): void {
$pdo->prepare("UPDATE trial_leads SET email_sent = 1 WHERE id = ?")->execute([$id]);
}
function db_trial_leads_list(PDO $pdo, int $gymId, string $status = ''): array {
$sql = "SELECT * FROM trial_leads WHERE deleted_at IS NULL AND gym_id = ?";
$args = [$gymId];
if ($status !== '' && isset(trial_lead_statuses()[$status])) { $sql .= " AND status = ?"; $args[] = $status; }
$sql .= " ORDER BY created_at DESC, id DESC LIMIT 1000";
$stmt = $pdo->prepare($sql);
$stmt->execute($args);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
function db_trial_leads_count_new(PDO $pdo, int $gymId): int {
try {
$st = $pdo->prepare("SELECT COUNT(*) FROM trial_leads WHERE status = 'novo' AND deleted_at IS NULL AND gym_id = ?");
$st->execute([$gymId]);
return (int) $st->fetchColumn();
} catch (Throwable $e) { return 0; }
}
function db_trial_lead_update(PDO $pdo, int $id, int $gymId, string $status, string $notes): bool {
if (!isset(trial_lead_statuses()[$status])) return false;
$before = $pdo->prepare("SELECT status, admin_notes, name FROM trial_leads WHERE id = ? AND gym_id = ? AND deleted_at IS NULL");
$before->execute([$id, $gymId]);
$old = $before->fetch(PDO::FETCH_ASSOC);
if (!$old) return false;
$stmt = $pdo->prepare("UPDATE trial_leads SET status = ?, admin_notes = ?, updated_by = ? WHERE id = ? AND gym_id = ?");
$stmt->execute([$status, $notes !== '' ? $notes : null, (int) ($_SESSION['user_id'] ?? 0) ?: null, $id, $gymId]);
audit('trial_lead.update', [
'target_type' => 'trial_lead', 'target_id' => $id, 'target_label' => (string) $old['name'],
'changes' => platform_audit_diff(
['status' => $old['status'], 'admin_notes' => (string) $old['admin_notes']],
['status' => $status, 'admin_notes' => $notes]
),
]);
return $stmt->rowCount() > 0;
}
// Vai para a lixeira — só se pertencer ao ginásio que se está a editar.
function db_trial_lead_delete(PDO $pdo, int $id, int $gymId): bool {
$own = $pdo->prepare("SELECT 1 FROM trial_leads WHERE id = ? AND gym_id = ?");
$own->execute([$id, $gymId]);
if (!$own->fetchColumn()) return false;
return trash_delete($pdo, 'trial_lead', $id);
}
+7 -2
View File
@@ -19,13 +19,18 @@ function db_whours_get(PDO $pdo, int $id): ?array {
function db_whours_create(PDO $pdo, array $data): int {
$stmt = $pdo->prepare("INSERT INTO work_hours (gym_id, week_day, open, close) VALUES (?, ?, ?, ?)");
$stmt->execute([admin_gym_id(), $data['week_day'], $data['open'], $data['close']]);
return (int) $pdo->lastInsertId();
$__id = (int) $pdo->lastInsertId();
audit_change($pdo, 'work_hours', 'work_hours', 'id', (int) $__id, 'create', null, 'week_day');
return $__id;
}
function db_whours_update(PDO $pdo, int $id, array $data): bool {
$__before = audit_row($pdo, 'work_hours', 'id', (int) $id);
$stmt = $pdo->prepare("UPDATE work_hours SET week_day = ?, open = ?, close = ? WHERE id = ?");
$stmt->execute([$data['week_day'], $data['open'], $data['close'], $id]);
return $stmt->rowCount() > 0;
$__r = $stmt->rowCount() > 0;
audit_change($pdo, 'work_hours', 'work_hours', 'id', (int) $id, 'update', $__before, 'week_day');
return $__r;
}
// Soft delete para a lixeira (config/functions_trash.php).