Deploying the local-SMTP commit took fitme3070.pt down:
PHP Warning: require_once(.../shared/platform-tools/compat_secrets.php):
Failed to open stream: No such file or directory
in .../config/functions_secrets.php on line 20
config/functions_secrets.php is loaded on every request, so a hard require_once
on a submodule file is a site-wide fatal — public pages included — whenever
shared/platform-tools lags the code that needs it. It lagged: the server is
still on 603ff84, which predates compat_secrets.php, because "Atualizar
submódulo" was itself broken (fixed in 3626080, which the site had to be up to
deploy). A missing file should not be able to close that loop.
Three requires hardened, each to the failure the caller can actually report:
- config/functions_secrets.php: falls back to plaintext passthrough helpers
and logs loudly. secrets_available() then returns false, which is exactly
what the Comunicações page already renders a warning banner for — so
nothing silently pretends to be encrypted while the site keeps serving.
- config/functions_queue.php (_flush_email): throws instead of fataling. A
fatal killed the request outright, so flush_message()'s catch never ran and
the row sat in 'sending' forever with no explanation. It now lands in
last_error where the Monitor page shows it.
- views/sysadmin/comms.php: renders "Submódulo desactualizado" naming the
missing files, rather than a blank 500 on the page you would open to
diagnose mail in the first place.
Verified by loading config/functions_secrets.php with no shared/ directory
present: the bootstrap completes, the warning is logged once, secrets_available()
is false, and setting_secret()/setting_or_env() still return usable values.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
566 lines
23 KiB
PHP
566 lines
23 KiB
PHP
<?php
|
|
// Local outbound message queue (email + SMS + WhatsApp) backed by `message_queue`.
|
|
//
|
|
// Delivery happens in this app — there is no central relay. Each channel has a
|
|
// small driver:
|
|
// email → PHPMailer over SMTP (or PHP mail() when SMTP_HOST is empty)
|
|
// sms → Twilio Messages API, plain SMS sender
|
|
// whatsapp → Twilio Messages API, `whatsapp:` sender
|
|
//
|
|
// Standard pattern for transactional sends (immediate delivery expected):
|
|
// $id = enqueue_email([...]);
|
|
// return flush_message($id);
|
|
//
|
|
// Standard pattern for broadcasts (deferred is acceptable):
|
|
// foreach ($recipients as $u) {
|
|
// enqueue_email([...]);
|
|
// }
|
|
// // cron.php drains them within 5 minutes; no inline flush needed.
|
|
//
|
|
// Every row keeps its status, attempt count, last error and timestamps, so the
|
|
// Monitor page (views/sysadmin/messages.php) can show what was sent and retry
|
|
// what wasn't.
|
|
|
|
const MESSAGE_QUEUE_VALID_STATUSES = ['pending', 'sending', 'sent', 'failed', 'cancelled'];
|
|
const MESSAGE_QUEUE_VALID_CHANNELS = ['email', 'sms', 'whatsapp'];
|
|
|
|
// ─── Enqueue ─────────────────────────────────────────────────────────────────
|
|
|
|
function enqueue_email(array $data): int {
|
|
return _enqueue_message('email', $data);
|
|
}
|
|
|
|
function enqueue_sms(array $data): int {
|
|
return _enqueue_message('sms', $data);
|
|
}
|
|
|
|
function enqueue_whatsapp(array $data): int {
|
|
return _enqueue_message('whatsapp', $data);
|
|
}
|
|
|
|
function _enqueue_message(string $channel, array $data): int {
|
|
global $pdo;
|
|
$stmt = $pdo->prepare("
|
|
INSERT INTO message_queue
|
|
(channel, to_user_id, to_address, to_name, reply_to, subject, body, alt_body,
|
|
context_type, context_id, scheduled_for, max_attempts, created_by)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
");
|
|
$stmt->execute([
|
|
$channel,
|
|
$data['to_user_id'] ?? null,
|
|
$data['to_address'],
|
|
$data['to_name'] ?? null,
|
|
// Sem isto, responder ao aviso de contacto respondia ao remetente da
|
|
// casa e nao a quem escreveu -- o flush_message() ja o passava ao
|
|
// mailer, era o INSERT que nao o guardava.
|
|
$data['reply_to'] ?? null,
|
|
$data['subject'] ?? null,
|
|
$data['body'],
|
|
$data['alt_body'] ?? null,
|
|
$data['context_type'] ?? null,
|
|
$data['context_id'] ?? null,
|
|
$data['scheduled_for'] ?? null,
|
|
$data['max_attempts'] ?? 3,
|
|
$data['created_by'] ?? ($_SESSION['user']['id'] ?? null),
|
|
]);
|
|
return (int) $pdo->lastInsertId();
|
|
}
|
|
|
|
// ─── Flush a single message ──────────────────────────────────────────────────
|
|
|
|
// Atomically claims a pending/failed row, sends it, updates status.
|
|
// Returns true on send success, false otherwise.
|
|
function flush_message(int $id): bool {
|
|
global $pdo;
|
|
|
|
// Atomic claim — only one worker proceeds. Guards against the cron run and
|
|
// an inline flush racing for the same row.
|
|
$claim = $pdo->prepare("
|
|
UPDATE message_queue
|
|
SET status = 'sending', attempts = attempts + 1
|
|
WHERE id = ?
|
|
AND status IN ('pending', 'failed')
|
|
");
|
|
$claim->execute([$id]);
|
|
if ($claim->rowCount() === 0) return false;
|
|
|
|
$row = db_get_message($pdo, $id);
|
|
if (!$row) return false;
|
|
|
|
try {
|
|
match ($row['channel']) {
|
|
'email' => _flush_email($row),
|
|
'sms' => _flush_twilio($row, 'sms'),
|
|
'whatsapp' => _flush_twilio($row, 'whatsapp'),
|
|
default => throw new RuntimeException("Unknown channel: {$row['channel']}"),
|
|
};
|
|
|
|
$pdo->prepare("UPDATE message_queue SET status='sent', sent_at=NOW(), last_error=NULL, next_attempt_at=NULL WHERE id=?")->execute([$id]);
|
|
return true;
|
|
} catch (\Throwable $e) {
|
|
// `attempts` was already incremented by the claim, so it reflects this
|
|
// attempt. Once it reaches max_attempts the row parks in 'failed' and
|
|
// only a manual retry (Monitor page) revives it.
|
|
$maxed = ((int) $row['attempts']) >= ((int) $row['max_attempts']);
|
|
$newStatus = $maxed ? 'failed' : 'pending';
|
|
// Exponential backoff: 2^attempts minutes (2, 4, 8, ...)
|
|
$nextAt = $maxed ? null : date('Y-m-d H:i:s', time() + (60 * (2 ** (int) $row['attempts'])));
|
|
$pdo->prepare("UPDATE message_queue SET status=?, last_error=?, next_attempt_at=? WHERE id=?")
|
|
->execute([$newStatus, mb_substr($e->getMessage(), 0, 2000), $nextAt, $id]);
|
|
error_log("flush_message($id) failed: {$e->getMessage()}");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ─── Driver: email (PHPMailer / SMTP) ────────────────────────────────────────
|
|
|
|
function _flush_email(array $row): void {
|
|
// Dev-mode debug print: skip SMTP entirely, write the email to a log file
|
|
// and a short summary to PHP's error_log. Kept here rather than in the
|
|
// shared transport because it writes this app's own log format.
|
|
if (APP_ENV !== 'prod' && EMAIL_DEV_PRINT) {
|
|
_dev_print_message($row, 'EMAIL');
|
|
return;
|
|
}
|
|
|
|
// Transport is shared with the rest of the fleet (fotografia, hub). It applies
|
|
// the EMAIL_DEV_REDIRECT safety net itself, including the [DEV] subject
|
|
// prefix and the banner naming the original recipient, so that logic no
|
|
// longer lives here.
|
|
// A bare require_once would be a FATAL when shared/platform-tools lags the
|
|
// pinned commit (this install is deployed by file upload, so it does lag).
|
|
// A fatal here kills the whole request; flush_message()'s catch never runs
|
|
// and the row stays in 'sending' forever with no explanation. Throwing puts
|
|
// the reason in last_error, where the Monitor page shows it.
|
|
$_ptRoot = __DIR__ . '/../shared/platform-tools/';
|
|
foreach (['functions_mail.php', 'functions_settings.php'] as $_ptFile) {
|
|
if (!is_file($_ptRoot . $_ptFile)) {
|
|
throw new RuntimeException(
|
|
"shared/platform-tools/{$_ptFile} em falta — o submódulo está desactualizado. "
|
|
. 'Actualize-o em Sistema → Actualizações.'
|
|
);
|
|
}
|
|
require_once $_ptRoot . $_ptFile;
|
|
}
|
|
|
|
// Settings come from system_settings first, .env second -- the fleet-wide
|
|
// standard. This install stores them there via setting_or_env(); this
|
|
// just reads them through the shared accessor instead of the local
|
|
// constants, so all three platforms resolve SMTP identically.
|
|
$r = platform_mail_send([
|
|
'to' => $row['to_address'],
|
|
'to_name' => $row['to_name'] ?? '',
|
|
// A fila não deve depender de uma constante que não é dela. O
|
|
// EMAIL_SUBJECT_PREFIX é definido no functions.php, e o index.php não o
|
|
// inclui de propósito -- o resultado era «Undefined constant» apanhado
|
|
// pelo flush_message() e gravado em last_error, com a página a dizer só
|
|
// "Ocorreu um erro" e nada no log do Apache.
|
|
'subject' => (defined('EMAIL_SUBJECT_PREFIX') ? EMAIL_SUBJECT_PREFIX : '') . ($row['subject'] ?? ''),
|
|
'body' => $row['body'],
|
|
'alt_body' => $row['alt_body'] ?: strip_tags($row['body']),
|
|
'reply_to' => $row['reply_to'] ?? '',
|
|
// O 4º argumento liga o tecto de envio (functions_mail_rate): 20/minuto e
|
|
// 200/hora, partilhados com o resto da quinta. Sem o $pdo o tecto não se
|
|
// aplica -- o módulo estava aqui e não estava a ser usado.
|
|
], dirname(__DIR__), platform_smtp_config($GLOBALS['pdo']), $GLOBALS['pdo'] ?? null);
|
|
|
|
// flush_message() treats a thrown exception as "retry later" and records
|
|
// last_error, so a failure must throw rather than return quietly.
|
|
if (empty($r['ok'])) {
|
|
throw new RuntimeException($r['error'] ?? 'Envio de email falhou.');
|
|
}
|
|
}
|
|
|
|
// ─── Driver: SMS + WhatsApp (Twilio Messages API) ────────────────────────────
|
|
|
|
// Both channels hit the same endpoint; only the sender differs (WhatsApp needs
|
|
// the `whatsapp:` prefix on both From and To). Throws on any non-2xx so
|
|
// flush_message() records the reason and schedules a retry.
|
|
function _flush_twilio(array $row, string $channel): void {
|
|
if (APP_ENV !== 'prod' && SMS_DEV_PRINT) {
|
|
_dev_print_message($row, strtoupper($channel));
|
|
return;
|
|
}
|
|
|
|
if (TWILIO_SID === '' || TWILIO_TOKEN === '') {
|
|
throw new RuntimeException('Twilio não configurado (TWILIO_SID / TWILIO_TOKEN em falta no .env)');
|
|
}
|
|
|
|
$from = $channel === 'whatsapp' ? TWILIO_WHATSAPP_FROM : TWILIO_SMS_FROM;
|
|
if ($from === '') {
|
|
$var = $channel === 'whatsapp' ? 'TWILIO_WHATSAPP_FROM' : 'TWILIO_SMS_FROM';
|
|
throw new RuntimeException("Remetente não configurado ({$var} em falta no .env)");
|
|
}
|
|
|
|
$to = sms_normalize_number($row['to_address']);
|
|
if ($to === null) {
|
|
throw new RuntimeException("Número de telefone inválido: {$row['to_address']}");
|
|
}
|
|
|
|
if ($channel === 'whatsapp') {
|
|
$from = 'whatsapp:' . $from;
|
|
$to = 'whatsapp:' . $to;
|
|
}
|
|
|
|
// SMS/WhatsApp are plain text. Bodies built from the HTML email templates
|
|
// would arrive as raw markup, so strip tags defensively.
|
|
$body = trim($row['body']);
|
|
if ($body !== strip_tags($body)) {
|
|
$body = trim(html_entity_decode(strip_tags($body), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
|
}
|
|
if ($body === '') {
|
|
throw new RuntimeException('Mensagem vazia');
|
|
}
|
|
|
|
$url = 'https://api.twilio.com/2010-04-01/Accounts/' . rawurlencode(TWILIO_SID) . '/Messages.json';
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => 20,
|
|
CURLOPT_USERPWD => TWILIO_SID . ':' . TWILIO_TOKEN,
|
|
CURLOPT_POSTFIELDS => http_build_query([
|
|
'From' => $from,
|
|
'To' => $to,
|
|
'Body' => $body,
|
|
]),
|
|
]);
|
|
$resp = curl_exec($ch);
|
|
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$err = curl_error($ch);
|
|
curl_close($ch);
|
|
|
|
if ($resp === false) {
|
|
throw new RuntimeException("Erro de ligação ao Twilio: {$err}");
|
|
}
|
|
if ($code < 200 || $code >= 300) {
|
|
// Twilio returns {"code":21211,"message":"Invalid 'To' Phone Number"}.
|
|
// Surfacing that message is what makes the Monitor page actionable.
|
|
$data = json_decode((string) $resp, true);
|
|
$detail = is_array($data)
|
|
? trim(($data['message'] ?? '') . (isset($data['code']) ? " (Twilio {$data['code']})" : ''))
|
|
: substr((string) $resp, 0, 300);
|
|
throw new RuntimeException("Twilio HTTP {$code}: " . ($detail !== '' ? $detail : 'sem detalhe'));
|
|
}
|
|
}
|
|
|
|
// Coerces a stored phone into E.164. Accepts "+351915435034", "351915435034",
|
|
// "915435034" and anything with spaces/dashes. Returns null when it can't.
|
|
function sms_normalize_number(string $raw): ?string {
|
|
$raw = trim($raw);
|
|
$hasPlus = str_starts_with($raw, '+');
|
|
$digits = preg_replace('/\D+/', '', $raw) ?? '';
|
|
if ($digits === '') return null;
|
|
|
|
if ($hasPlus) {
|
|
return strlen($digits) >= 8 ? '+' . $digits : null;
|
|
}
|
|
|
|
$cc = preg_replace('/\D+/', '', SMS_DEFAULT_COUNTRY_CODE) ?? '';
|
|
// Already carries the country code (e.g. 351915435034) — just add the plus.
|
|
if ($cc !== '' && str_starts_with($digits, $cc) && strlen($digits) > strlen($cc)) {
|
|
return '+' . $digits;
|
|
}
|
|
// National number — prefix the default country code.
|
|
if ($cc !== '' && strlen($digits) >= 9) {
|
|
return '+' . $cc . $digits;
|
|
}
|
|
return strlen($digits) >= 11 ? '+' . $digits : null;
|
|
}
|
|
|
|
// Inverse of sms_normalize_number(): the human-readable form used for the
|
|
// printed contact on the public site. A national number under the default
|
|
// country code drops the prefix and is grouped in threes ("+351928363069" ->
|
|
// "928 363 069"); anything else keeps the international form, which is the
|
|
// only unambiguous way to write it.
|
|
function sms_format_display(string $e164): string {
|
|
$cc = preg_replace('/\D+/', '', SMS_DEFAULT_COUNTRY_CODE) ?? '';
|
|
$digits = preg_replace('/\D+/', '', $e164) ?? '';
|
|
if ($cc !== '' && str_starts_with($digits, $cc)) {
|
|
$national = substr($digits, strlen($cc));
|
|
if (strlen($national) === 9) {
|
|
return trim(chunk_split($national, 3, ' '));
|
|
}
|
|
}
|
|
return $e164;
|
|
}
|
|
|
|
// Writes the full message to logs/message_dev.log and a summary line to PHP's
|
|
// error_log. Only called under APP_ENV != prod + EMAIL_DEV_PRINT / SMS_DEV_PRINT.
|
|
function _dev_print_message(array $row, string $label): void {
|
|
$logDir = __DIR__ . '/../logs';
|
|
$logFile = $logDir . '/message_dev.log';
|
|
if (!is_dir($logDir)) @mkdir($logDir, 0775, true);
|
|
|
|
$sep = str_repeat('─', 70) . "\n";
|
|
$entry = $sep
|
|
. sprintf("[%s] %s (DEV PRINT — não enviado)\n", date('Y-m-d H:i:s'), $label)
|
|
. sprintf("To: %s%s\n", $row['to_address'], $row['to_name'] ? " <{$row['to_name']}>" : '')
|
|
. sprintf("Subject: %s\n", EMAIL_SUBJECT_PREFIX . ($row['subject'] ?? ''))
|
|
. sprintf("Context: %s#%s\n", $row['context_type'] ?? '?', $row['context_id'] ?? '?')
|
|
. $sep
|
|
. ($row['body'] ?? '') . "\n\n";
|
|
@file_put_contents($logFile, $entry, FILE_APPEND);
|
|
|
|
error_log(sprintf(
|
|
'[%s-dev] %s → %s (registado em logs/message_dev.log)',
|
|
strtolower($label),
|
|
$row['subject'] ?? '(sem assunto)',
|
|
$row['to_address']
|
|
));
|
|
}
|
|
|
|
// ─── Drain the queue ─────────────────────────────────────────────────────────
|
|
|
|
// Picks up to $limit due rows and processes them in turn. Returns counts.
|
|
// Called by cron.php every 5 minutes; also safe to call inline after a bulk
|
|
// enqueue.
|
|
function process_queue(int $limit = 20): array {
|
|
global $pdo;
|
|
$limit = max(1, $limit);
|
|
$rows = $pdo->query("
|
|
SELECT id FROM message_queue
|
|
WHERE status = 'pending'
|
|
AND deleted_at IS NULL
|
|
AND (scheduled_for IS NULL OR scheduled_for <= NOW())
|
|
AND (next_attempt_at IS NULL OR next_attempt_at <= NOW())
|
|
ORDER BY scheduled_for ASC, id ASC
|
|
LIMIT $limit
|
|
")->fetchAll();
|
|
|
|
$sent = 0; $failed = 0;
|
|
foreach ($rows as $r) {
|
|
if (flush_message((int) $r['id'])) $sent++; else $failed++;
|
|
}
|
|
return ['sent' => $sent, 'failed' => $failed, 'total' => count($rows)];
|
|
}
|
|
|
|
// Rows stuck in 'sending' mean a worker died mid-send (fatal, timeout, deploy).
|
|
// Nothing would ever pick them up again, so the cron releases them back to
|
|
// 'pending' once they're older than $minutes.
|
|
function requeue_stuck_messages(PDO $pdo, int $minutes = 15): int {
|
|
$minutes = max(1, $minutes);
|
|
return (int) $pdo->exec("
|
|
UPDATE message_queue
|
|
SET status = 'pending',
|
|
last_error = CONCAT('Retomado após bloqueio em \"sending\" (', COALESCE(last_error, ''), ')')
|
|
WHERE status = 'sending'
|
|
AND updated_at < NOW() - INTERVAL $minutes MINUTE
|
|
");
|
|
}
|
|
|
|
// ─── CRUD helpers (used by the Monitor admin page) ───────────────────────────
|
|
|
|
function db_get_message(PDO $pdo, int $id): array|false {
|
|
$stmt = $pdo->prepare("SELECT * FROM message_queue WHERE id = ?");
|
|
$stmt->execute([$id]);
|
|
return $stmt->fetch();
|
|
}
|
|
|
|
/**
|
|
* Fetch messages with rich filters.
|
|
*
|
|
* $filters keys:
|
|
* - statuses array<string> one or more valid statuses (OR)
|
|
* - channel string 'email' | 'sms' | 'whatsapp'
|
|
* - search string LIKE on to_address / to_name / subject
|
|
* - days int|null limit to created_at within N days
|
|
* - include_deleted bool default false (exclude soft-deleted)
|
|
* - only_deleted bool default false (only soft-deleted)
|
|
* - limit int default 200
|
|
*/
|
|
function db_get_messages(PDO $pdo, array $filters = []): array {
|
|
$statuses = $filters['statuses'] ?? [];
|
|
$channel = $filters['channel'] ?? null;
|
|
$search = trim((string) ($filters['search'] ?? ''));
|
|
$days = isset($filters['days']) ? (int) $filters['days'] : null;
|
|
$include_deleted = !empty($filters['include_deleted']);
|
|
$only_deleted = !empty($filters['only_deleted']);
|
|
$limit = max(1, (int) ($filters['limit'] ?? 200));
|
|
|
|
$where = [];
|
|
$args = [];
|
|
|
|
$statuses = array_values(array_intersect($statuses, MESSAGE_QUEUE_VALID_STATUSES));
|
|
if ($statuses) {
|
|
$where[] = 'q.status IN (' . implode(',', array_fill(0, count($statuses), '?')) . ')';
|
|
foreach ($statuses as $s) $args[] = $s;
|
|
}
|
|
if ($channel && in_array($channel, MESSAGE_QUEUE_VALID_CHANNELS, true)) {
|
|
$where[] = 'q.channel = ?';
|
|
$args[] = $channel;
|
|
}
|
|
if ($search !== '') {
|
|
$where[] = '(q.to_address LIKE ? OR q.to_name LIKE ? OR q.subject LIKE ?)';
|
|
$like = '%' . $search . '%';
|
|
$args[] = $like; $args[] = $like; $args[] = $like;
|
|
}
|
|
if ($days !== null && $days > 0) {
|
|
$where[] = "q.created_at >= NOW() - INTERVAL $days DAY";
|
|
}
|
|
if ($only_deleted) {
|
|
$where[] = 'q.deleted_at IS NOT NULL';
|
|
} elseif (!$include_deleted) {
|
|
$where[] = 'q.deleted_at IS NULL';
|
|
}
|
|
|
|
$sql = "
|
|
SELECT q.*, u.name AS user_name
|
|
FROM message_queue q
|
|
LEFT JOIN users u ON q.to_user_id = u.id
|
|
";
|
|
if ($where) $sql .= ' WHERE ' . implode(' AND ', $where);
|
|
$sql .= " ORDER BY q.created_at DESC LIMIT $limit";
|
|
$stmt = $pdo->prepare($sql);
|
|
$stmt->execute($args);
|
|
return $stmt->fetchAll();
|
|
}
|
|
|
|
/**
|
|
* Status counts (excluding soft-deleted by default).
|
|
* Optional $days limits the window. Adds a virtual 'deleted' key with the
|
|
* count of soft-deleted rows.
|
|
*/
|
|
function db_count_messages_by_status(PDO $pdo, ?int $days = null): array {
|
|
$where = ['deleted_at IS NULL'];
|
|
if ($days !== null && $days > 0) {
|
|
$where[] = "created_at >= NOW() - INTERVAL $days DAY";
|
|
}
|
|
$whereSql = 'WHERE ' . implode(' AND ', $where);
|
|
$counts = $pdo->query("
|
|
SELECT status, COUNT(*) AS n FROM message_queue $whereSql GROUP BY status
|
|
")->fetchAll(PDO::FETCH_KEY_PAIR);
|
|
|
|
foreach (MESSAGE_QUEUE_VALID_STATUSES as $s) $counts[$s] = (int) ($counts[$s] ?? 0);
|
|
$counts['deleted'] = (int) $pdo->query("SELECT COUNT(*) FROM message_queue WHERE deleted_at IS NOT NULL")->fetchColumn();
|
|
return $counts;
|
|
}
|
|
|
|
// Number of rows waiting for the next cron pass. Drives the navbar badge.
|
|
function db_count_pending_messages(PDO $pdo): int {
|
|
return (int) $pdo->query("
|
|
SELECT COUNT(*) FROM message_queue
|
|
WHERE status IN ('pending','failed') AND deleted_at IS NULL
|
|
")->fetchColumn();
|
|
}
|
|
|
|
function db_retry_message(PDO $pdo, int $id): void {
|
|
// Resetting `attempts` is what makes a retry meaningful — otherwise a row
|
|
// that already burned its 3 attempts would fail straight back to 'failed'
|
|
// on the first error without any backoff.
|
|
$pdo->prepare("
|
|
UPDATE message_queue
|
|
SET status='pending', attempts=0, next_attempt_at=NULL, last_error=NULL, deleted_at=NULL
|
|
WHERE id=? AND status IN ('failed','cancelled')
|
|
")->execute([$id]);
|
|
}
|
|
|
|
function db_cancel_message(PDO $pdo, int $id): void {
|
|
$pdo->prepare("
|
|
UPDATE message_queue
|
|
SET status='cancelled'
|
|
WHERE id=? AND status IN ('pending','failed')
|
|
")->execute([$id]);
|
|
}
|
|
|
|
function db_soft_delete_message(PDO $pdo, int $id): void {
|
|
$pdo->prepare("
|
|
UPDATE message_queue
|
|
SET deleted_at = NOW()
|
|
WHERE id = ? AND deleted_at IS NULL AND status <> 'sending'
|
|
")->execute([$id]);
|
|
}
|
|
|
|
function db_restore_message(PDO $pdo, int $id): void {
|
|
$pdo->prepare("
|
|
UPDATE message_queue
|
|
SET deleted_at = NULL
|
|
WHERE id = ?
|
|
")->execute([$id]);
|
|
}
|
|
|
|
/** Bulk soft-delete sent messages older than N days. Returns affected rows. */
|
|
function db_soft_delete_sent_older_than(PDO $pdo, int $days): int {
|
|
$days = max(1, (int) $days);
|
|
return (int) $pdo->exec("
|
|
UPDATE message_queue
|
|
SET deleted_at = NOW()
|
|
WHERE deleted_at IS NULL
|
|
AND status = 'sent'
|
|
AND sent_at < NOW() - INTERVAL $days DAY
|
|
");
|
|
}
|
|
|
|
/** Hard-delete rows soft-deleted more than N days ago. Called by cron.php. */
|
|
function db_purge_deleted_messages(PDO $pdo, int $days): int {
|
|
$days = max(1, (int) $days);
|
|
return (int) $pdo->exec("
|
|
DELETE FROM message_queue
|
|
WHERE deleted_at IS NOT NULL
|
|
AND deleted_at < NOW() - INTERVAL $days DAY
|
|
");
|
|
}
|
|
|
|
// ─── Display helpers ─────────────────────────────────────────────────────────
|
|
|
|
function message_status_label(string $status): string {
|
|
return match ($status) {
|
|
'pending' => 'Pendente',
|
|
'sending' => 'A enviar',
|
|
'sent' => 'Enviado',
|
|
'failed' => 'Falhou',
|
|
'cancelled' => 'Cancelado',
|
|
default => ucfirst($status),
|
|
};
|
|
}
|
|
|
|
function message_status_badge(string $status): string {
|
|
$colour = match ($status) {
|
|
'pending' => 'secondary',
|
|
'sending' => 'info',
|
|
'sent' => 'success',
|
|
'failed' => 'danger',
|
|
'cancelled' => 'dark',
|
|
default => 'secondary',
|
|
};
|
|
return '<span class="badge text-bg-' . $colour . '">' . htmlspecialchars(message_status_label($status)) . '</span>';
|
|
}
|
|
|
|
function message_channel_label(string $channel): string {
|
|
return match ($channel) {
|
|
'email' => 'Email',
|
|
'sms' => 'SMS',
|
|
'whatsapp' => 'WhatsApp',
|
|
default => ucfirst($channel),
|
|
};
|
|
}
|
|
|
|
function message_channel_icon(string $channel): string {
|
|
return match ($channel) {
|
|
'email' => 'bi-envelope',
|
|
'sms' => 'bi-chat-left-text',
|
|
'whatsapp' => 'bi-whatsapp',
|
|
default => 'bi-send',
|
|
};
|
|
}
|
|
|
|
function message_context_label(?string $type): string {
|
|
return match ($type) {
|
|
'gallery_delivery' => 'Entrega de galeria',
|
|
'session_scheduled' => 'Sessão agendada',
|
|
'session_reminder' => 'Lembrete de sessão',
|
|
'session_status' => 'Notificação de sessão',
|
|
'print_job_status' => 'Notificação de encomenda',
|
|
'password_reset' => 'Reposição de palavra-passe',
|
|
'newsletter' => 'Newsletter',
|
|
'broadcast' => 'Campanha (template)',
|
|
'quote_pack' => 'Pack de orçamento',
|
|
'quote_document' => 'Orçamento',
|
|
'voucher' => 'Vale oferta',
|
|
'contract_request' => 'Pedido de assinatura',
|
|
null, '' => '—',
|
|
default => $type,
|
|
};
|
|
}
|