- shared/platform-tools: ea3616a → 12f26fd (27 commits). Traz o módulo de audit (f761d99, o pin da frota), «manter sessão iniciada», o registo de segurança da frota (o gancho que o login já tinha passa a funcionar) e a correção do IP do cliente encontrada nesta auditoria (último X-Forwarded-For / X-Real-IP), promovida ao partilhado. client_ip() da app delega nele. - config/functions_audit.php: audit() sobre platform_audit(); tabela audit_log criada no primeiro uso, segredos ocultados, nunca parte a ação. - Registado: login, falha de login, bloqueio por excesso de tentativas, logout, pedidos de recuperação, mudança/definição de palavra-passe, registo e criação de utilizadores, convites, perfil (antes/depois), todas as definições (db_update_setting, com valor anterior), cada passo da lixeira (apagar, restaurar, eliminar de vez) e ações nas mensagens. - Sistema → Auditoria (views/sysadmin/audit.php), só sysadmin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
228 lines
12 KiB
PHP
228 lines
12 KiB
PHP
<?php
|
|
// Monitor da fila de mensagens — o que saiu, o que falhou e porquê.
|
|
//
|
|
// The queue is only useful if someone can see into it: a message that failed
|
|
// three times and parked in 'failed' is invisible everywhere else in the app,
|
|
// and last_error is the only place the real SMTP rejection is written down.
|
|
//
|
|
// Sysadmin-only: message bodies contain personal data.
|
|
|
|
require __DIR__ . '/../../config/db.php';
|
|
require __DIR__ . '/../../config/functions.php';
|
|
$user = require_sysadmin();
|
|
|
|
$notice = '';
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
csrf_check();
|
|
$id = (int) ($_POST['id'] ?? 0);
|
|
$action = $_POST['_action'] ?? '';
|
|
if (in_array($action, ['retry', 'cancel', 'delete', 'restore'], true) && $id > 0) {
|
|
audit('message.' . $action, ['target_type' => 'message', 'target_id' => $id]);
|
|
}
|
|
|
|
if ($id > 0) {
|
|
switch ($action) {
|
|
case 'retry':
|
|
// Back to 'pending' with the attempt counter cleared, then sent
|
|
// inline so the operator sees the outcome now rather than
|
|
// waiting for the next cron tick.
|
|
db_retry_message($pdo, $id);
|
|
$notice = flush_message($id)
|
|
? "Mensagem #{$id} enviada."
|
|
: "Mensagem #{$id} voltou a falhar — veja o erro na linha.";
|
|
break;
|
|
case 'cancel':
|
|
db_cancel_message($pdo, $id);
|
|
$notice = "Mensagem #{$id} cancelada.";
|
|
break;
|
|
case 'delete':
|
|
db_soft_delete_message($pdo, $id);
|
|
$notice = "Mensagem #{$id} arquivada.";
|
|
break;
|
|
case 'restore':
|
|
db_restore_message($pdo, $id);
|
|
$notice = "Mensagem #{$id} restaurada.";
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
$status = (string) ($_GET['status'] ?? '');
|
|
$channel = (string) ($_GET['channel'] ?? '');
|
|
$search = trim((string) ($_GET['q'] ?? ''));
|
|
$trash = !empty($_GET['trash']);
|
|
$detail = (int) ($_GET['id'] ?? 0);
|
|
|
|
$counts = db_count_messages_by_status($pdo, 30);
|
|
// db_get_messages() takes a `statuses` ARRAY and `only_deleted`, not the
|
|
// singular/`trash` names the query string uses.
|
|
$messages = db_get_messages($pdo, [
|
|
'statuses' => $status !== '' ? [$status] : [],
|
|
'channel' => $channel ?: null,
|
|
'search' => $search ?: null,
|
|
'only_deleted' => $trash,
|
|
'limit' => 200,
|
|
]);
|
|
$row = $detail > 0 ? db_get_message($pdo, $detail) : false;
|
|
|
|
$page_title = 'Mensagens';
|
|
include __DIR__ . '/../../includes/header.php';
|
|
?>
|
|
<div class="container">
|
|
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
|
<h1 class="h3 mb-0">Mensagens</h1>
|
|
<a class="btn btn-sm btn-outline-secondary" href="<?= base_url('views/sysadmin/comms.php') ?>">
|
|
<i class="bi bi-sliders me-1"></i>Configuração SMTP
|
|
</a>
|
|
</div>
|
|
|
|
<?php if ($notice): ?><div class="alert alert-info"><?= htmlspecialchars($notice) ?></div><?php endif; ?>
|
|
|
|
<!-- ─── Contadores (últimos 30 dias) ───────────────────────────────── -->
|
|
<div class="row g-2 mb-3">
|
|
<?php foreach (['pending' => 'Em espera', 'sending' => 'A enviar', 'sent' => 'Enviadas', 'failed' => 'Falhadas', 'cancelled' => 'Canceladas'] as $k => $lbl): ?>
|
|
<div class="col-6 col-md">
|
|
<a class="text-decoration-none" href="<?= base_url('views/sysadmin/messages.php?status=' . $k) ?>">
|
|
<div class="card shadow-sm h-100"><div class="card-body py-2 px-3">
|
|
<div class="text-muted small"><?= htmlspecialchars($lbl) ?></div>
|
|
<div class="fs-4"><?= (int) ($counts[$k] ?? 0) ?></div>
|
|
</div></div>
|
|
</a>
|
|
</div>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
|
|
<!-- ─── Filtros ────────────────────────────────────────────────────── -->
|
|
<form class="card shadow-sm mb-3" method="GET"><div class="card-body py-2">
|
|
<div class="row g-2 align-items-end">
|
|
<div class="col-md-3">
|
|
<label class="form-label small mb-1" for="f-status">Estado</label>
|
|
<select class="form-select form-select-sm" id="f-status" name="status">
|
|
<option value="">Todos</option>
|
|
<?php foreach (MESSAGE_QUEUE_VALID_STATUSES as $s): ?>
|
|
<option value="<?= $s ?>" <?= $status === $s ? 'selected' : '' ?>><?= htmlspecialchars(message_status_label($s)) ?></option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label small mb-1" for="f-channel">Canal</label>
|
|
<select class="form-select form-select-sm" id="f-channel" name="channel">
|
|
<option value="">Todos</option>
|
|
<?php foreach (MESSAGE_QUEUE_VALID_CHANNELS as $c): ?>
|
|
<option value="<?= $c ?>" <?= $channel === $c ? 'selected' : '' ?>><?= htmlspecialchars(message_channel_label($c)) ?></option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label small mb-1" for="f-q">Pesquisar</label>
|
|
<input class="form-control form-control-sm" id="f-q" name="q" value="<?= htmlspecialchars($search) ?>" placeholder="destinatário ou assunto">
|
|
</div>
|
|
<div class="col-md-2 d-grid">
|
|
<button class="btn btn-sm btn-primary">Filtrar</button>
|
|
</div>
|
|
</div>
|
|
<div class="form-check small mt-2">
|
|
<input class="form-check-input" type="checkbox" value="1" id="f-trash" name="trash" <?= $trash ? 'checked' : '' ?>>
|
|
<label class="form-check-label text-muted" for="f-trash">Mostrar arquivadas</label>
|
|
</div>
|
|
</div></form>
|
|
|
|
<!-- ─── Detalhe ────────────────────────────────────────────────────── -->
|
|
<?php if ($row): ?>
|
|
<div class="card shadow-sm mb-3"><div class="card-body">
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<h2 class="h6 mb-2">#<?= (int) $row['id'] ?> · <?= htmlspecialchars($row['subject'] ?? '(sem assunto)') ?></h2>
|
|
<a class="btn-close" href="<?= base_url('views/sysadmin/messages.php') ?>" aria-label="Fechar"></a>
|
|
</div>
|
|
<dl class="row small mb-2">
|
|
<dt class="col-sm-3 fw-normal text-muted">Para</dt>
|
|
<dd class="col-sm-9"><?= htmlspecialchars($row['to_address']) ?><?= $row['to_name'] ? ' (' . htmlspecialchars($row['to_name']) . ')' : '' ?></dd>
|
|
<dt class="col-sm-3 fw-normal text-muted">Estado</dt>
|
|
<dd class="col-sm-9"><?= message_status_badge($row['status']) ?> · <?= (int) $row['attempts'] ?>/<?= (int) $row['max_attempts'] ?> tentativas</dd>
|
|
<dt class="col-sm-3 fw-normal text-muted">Contexto</dt>
|
|
<dd class="col-sm-9"><?= htmlspecialchars(message_context_label($row['context_type'])) ?></dd>
|
|
<?php if (!empty($row['last_error'])): ?>
|
|
<dt class="col-sm-3 fw-normal text-muted">Último erro</dt>
|
|
<dd class="col-sm-9"><code class="text-danger"><?= htmlspecialchars($row['last_error']) ?></code></dd>
|
|
<?php endif; ?>
|
|
</dl>
|
|
<details>
|
|
<summary class="small text-muted">Corpo da mensagem</summary>
|
|
<pre class="small bg-light p-2 mt-2 mb-0" style="max-height:340px;overflow:auto;white-space:pre-wrap;"><?= htmlspecialchars($row['body']) ?></pre>
|
|
</details>
|
|
</div></div>
|
|
<?php endif; ?>
|
|
|
|
<!-- ─── Lista ──────────────────────────────────────────────────────── -->
|
|
<div class="card shadow-sm"><div class="card-body">
|
|
<?php if (!$messages): ?>
|
|
<p class="text-muted text-center py-4 mb-0">Sem mensagens.</p>
|
|
<?php else: ?>
|
|
<div class="table-responsive">
|
|
<table class="table table-hover align-middle small">
|
|
<thead><tr>
|
|
<th>#</th><th>Canal</th><th>Para</th><th>Assunto</th>
|
|
<th>Estado</th><th>Criada</th><th class="text-end">Ações</th>
|
|
</tr></thead>
|
|
<tbody>
|
|
<?php foreach ($messages as $m): ?>
|
|
<tr>
|
|
<td><a href="<?= base_url('views/sysadmin/messages.php?id=' . (int) $m['id']) ?>">#<?= (int) $m['id'] ?></a></td>
|
|
<td><i class="bi <?= htmlspecialchars(message_channel_icon($m['channel'])) ?>" title="<?= htmlspecialchars(message_channel_label($m['channel'])) ?>"></i></td>
|
|
<td><?= htmlspecialchars($m['to_address']) ?></td>
|
|
<td class="text-truncate" style="max-width:260px;"><?= htmlspecialchars($m['subject'] ?? '—') ?></td>
|
|
<td>
|
|
<?= message_status_badge($m['status']) ?>
|
|
<?php if (!empty($m['last_error'])): ?>
|
|
<i class="bi bi-exclamation-circle text-danger ms-1" title="<?= htmlspecialchars(mb_substr($m['last_error'], 0, 200)) ?>"></i>
|
|
<?php endif; ?>
|
|
</td>
|
|
<td class="text-muted"><?= htmlspecialchars(format_date_pt($m['created_at'])) ?></td>
|
|
<td class="text-end text-nowrap">
|
|
<?php if (!$trash): ?>
|
|
<?php if (in_array($m['status'], ['failed', 'cancelled', 'pending'], true)): ?>
|
|
<form method="POST" class="d-inline">
|
|
<?= csrf_field() ?>
|
|
<input type="hidden" name="id" value="<?= (int) $m['id'] ?>">
|
|
<input type="hidden" name="_action" value="retry">
|
|
<button class="btn btn-sm btn-outline-primary" title="Reenviar agora"><i class="bi bi-arrow-repeat"></i></button>
|
|
</form>
|
|
<?php endif; ?>
|
|
<?php if (in_array($m['status'], ['pending', 'failed'], true)): ?>
|
|
<form method="POST" class="d-inline">
|
|
<?= csrf_field() ?>
|
|
<input type="hidden" name="id" value="<?= (int) $m['id'] ?>">
|
|
<input type="hidden" name="_action" value="cancel">
|
|
<button class="btn btn-sm btn-outline-secondary" title="Cancelar"><i class="bi bi-slash-circle"></i></button>
|
|
</form>
|
|
<?php endif; ?>
|
|
<form method="POST" class="d-inline">
|
|
<?= csrf_field() ?>
|
|
<input type="hidden" name="id" value="<?= (int) $m['id'] ?>">
|
|
<input type="hidden" name="_action" value="delete">
|
|
<button class="btn btn-sm btn-outline-danger" title="Arquivar"><i class="bi bi-archive"></i></button>
|
|
</form>
|
|
<?php else: ?>
|
|
<form method="POST" class="d-inline">
|
|
<?= csrf_field() ?>
|
|
<input type="hidden" name="id" value="<?= (int) $m['id'] ?>">
|
|
<input type="hidden" name="_action" value="restore">
|
|
<button class="btn btn-sm btn-outline-secondary" title="Restaurar"><i class="bi bi-arrow-counterclockwise"></i></button>
|
|
</form>
|
|
<?php endif; ?>
|
|
</td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p class="text-muted small mb-0">
|
|
Mostradas até 200. A fila é drenada pelo <code>cron.php</code>; as falhas voltam a ser tentadas
|
|
com espera exponencial até <code>max_attempts</code>.
|
|
</p>
|
|
<?php endif; ?>
|
|
</div></div>
|
|
</div>
|
|
<?php include __DIR__ . '/../../includes/footer.php'; ?>
|