feat(notificaciones): one send log across servicios and pólizas
Renewal avisos left behind only a `RenewalNotice` row, whose sole job is gating: a row with `sentAt` drops the policy off the pending list. It cannot represent a failed send or a customer with no address, so the Pólizas tab had no "Registro de envíos" to show and a sent notice simply vanished from the list. Renewals now write `email_notification_log` — the same table the four bulk jobs write — as `RENEWAL_NOTICE` / `POLICIES`, with rows for failures and no-email skips too. `RenewalNotice` keeps its gating role unchanged; the two are complementary, not redundant. - extend `EmailNotificationType` (+RENEWAL_NOTICE) and `EmailNotificationServicio` (+POLICIES); `level` now carries the aviso generation on renewal rows, so every reader must branch on the type first (`notificationLevelLabel()` is the one place that lives) - backfill emailed notices (`channel = 'EMAIL'`) into the log; MAIL-channel rows are legacy printed letters and are deliberately left out - extract `NotificationLogService`/`NotificationLogModule` as the single writer, so a feature that sends mail records it without pulling the bulk-job pipelines into its module - `GET /notifications/log` and `/stats` take a comma-separated `servicio` list; each tab reads its own slice. This also fixes the "Omitidos" view, which mapped to no filter at all and showed every row - share one `NotificationLogPanel` between both tabs - pass SES_* / NOTIFICATION_ADMIN_EMAILS through the galactus compose, which was missing them entirely — mail is runtime config, not a CI secret, and the prod image sets NODE_ENV=production so a blank config fails loudly instead of falling back to stdout Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { formatDate, formatMoney } from "@/lib/labels";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { NotificationLogPanel } from "@/components/NotificationLogPanel";
|
||||
import { apiFetch, POLIZAS_LOG_SCOPE } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* Renewal notices — the "Pólizas" half of /notificaciones. Shows which
|
||||
@@ -11,6 +12,11 @@ import { apiFetch } from "@/lib/api";
|
||||
* one row at a time or as a whole sweep. Sending is what marks a notice as
|
||||
* delivered — there is no manual "mark as sent", so the list can never claim
|
||||
* a letter went out when no mail was ever sent. Gated on `renewal:send`.
|
||||
*
|
||||
* Sends are recorded in the same `email_notification_log` the Servicios tab
|
||||
* reads, so "Registro de envíos" below is the same component with the
|
||||
* POLICIES slice — failures and no-email skips included, which the pending
|
||||
* list alone cannot show.
|
||||
*/
|
||||
|
||||
export interface RenewalLetter {
|
||||
@@ -60,6 +66,8 @@ export function NotificacionesPolizas() {
|
||||
const [sweeping, setSweeping] = useState(false);
|
||||
/** `policyId-generation` of the row currently being sent, if any. */
|
||||
const [sendingKey, setSendingKey] = useState<string | null>(null);
|
||||
/** Raised after every send so the log panel reloads. */
|
||||
const [logToken, setLogToken] = useState(0);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setPendingError(null);
|
||||
@@ -89,9 +97,11 @@ export function NotificacionesPolizas() {
|
||||
method: "POST",
|
||||
});
|
||||
setNotice(`Enviados ${result.sent} avisos (${result.failed} con error).`);
|
||||
setLogToken((t) => t + 1);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setActionError((e as Error)?.message ?? "No se pudo ejecutar el barrido.");
|
||||
setLogToken((t) => t + 1);
|
||||
} finally {
|
||||
setSweeping(false);
|
||||
}
|
||||
@@ -115,9 +125,12 @@ export function NotificacionesPolizas() {
|
||||
}),
|
||||
});
|
||||
setNotice(`Aviso enviado a ${result.to}.`);
|
||||
setLogToken((t) => t + 1);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setActionError((e as Error)?.message ?? "No se pudo enviar el aviso.");
|
||||
// A rejected send may still have written a FAILED row; reload either way.
|
||||
setLogToken((t) => t + 1);
|
||||
} finally {
|
||||
setSendingKey(null);
|
||||
}
|
||||
@@ -253,6 +266,12 @@ export function NotificacionesPolizas() {
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<NotificationLogPanel
|
||||
servicio={POLIZAS_LOG_SCOPE}
|
||||
reloadToken={logToken}
|
||||
emptyHint="Todavía no se ha enviado ningún aviso de renovación con este filtro."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,29 +2,26 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { formatDateTime } from "@/lib/labels";
|
||||
import {
|
||||
NOTIFICATION_STATUS_COLORS,
|
||||
formatDateTime,
|
||||
NOTIFICATION_STATUS_LABELS,
|
||||
NOTIFICATION_SERVICIO_LABELS,
|
||||
NOTIFICATION_TYPE_LABELS,
|
||||
} from "@/lib/labels";
|
||||
import { NotificationLogPanel } from "@/components/NotificationLogPanel";
|
||||
import {
|
||||
getNotificationStats,
|
||||
listNotificationLog,
|
||||
runAccountStatus,
|
||||
runAllNotifications,
|
||||
runOutstandingPayments,
|
||||
runPaymentConfirmation,
|
||||
runTrustConfirmation,
|
||||
SERVICIOS_LOG_SCOPE,
|
||||
} from "@/lib/api";
|
||||
import type {
|
||||
NotificationFlags,
|
||||
NotificationJobResponse,
|
||||
NotificationLogPage,
|
||||
NotificationRunAllResponse,
|
||||
NotificationStats,
|
||||
NotificationStatus,
|
||||
} from "@/lib/api";
|
||||
|
||||
/**
|
||||
@@ -87,24 +84,13 @@ const JOB_TITLES: Record<JobKind, string> = JOBS.reduce(
|
||||
{} as Record<JobKind, string>,
|
||||
);
|
||||
|
||||
const LOG_VIEWS = [
|
||||
{ key: "all", label: "Todos" },
|
||||
{ key: "sent", label: "Enviados" },
|
||||
{ key: "failed", label: "Fallidos" },
|
||||
{ key: "skipped", label: "Omitidos" },
|
||||
] as const;
|
||||
|
||||
export function NotificacionesServicios() {
|
||||
const allowed = useCan("notification:send");
|
||||
|
||||
const [flags, setFlags] = useState<NotificationFlags>({ debug: true });
|
||||
const [stats, setStats] = useState<NotificationStats | null>(null);
|
||||
const [log, setLog] = useState<NotificationLogPage | null>(null);
|
||||
const [logFilter, setLogFilter] = useState<{
|
||||
status?: NotificationStatus;
|
||||
view: "all" | "sent" | "failed" | "skipped";
|
||||
}>({ view: "all" });
|
||||
const [logPage, setLogPage] = useState(1);
|
||||
/** Raised after every run so the shared log panel reloads. */
|
||||
const [logToken, setLogToken] = useState(0);
|
||||
const [busy, setBusy] = useState<JobKind | "all" | null>(null);
|
||||
const [lastResult, setLastResult] = useState<
|
||||
NotificationJobResponse | NotificationRunAllResponse | null
|
||||
@@ -113,22 +99,13 @@ export function NotificacionesServicios() {
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [s, l] = await Promise.all([
|
||||
getNotificationStats(),
|
||||
listNotificationLog({
|
||||
page: logPage,
|
||||
pageSize: 50,
|
||||
status: logFilter.status,
|
||||
view: logFilter.view === "all" ? undefined : logFilter.view,
|
||||
}),
|
||||
]);
|
||||
setStats(s);
|
||||
setLog(l);
|
||||
setStats(await getNotificationStats(SERVICIOS_LOG_SCOPE));
|
||||
setLogToken((t) => t + 1);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, [logPage, logFilter]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
@@ -420,111 +397,10 @@ export function NotificacionesServicios() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<h2 className="section-title">Registro de envíos</h2>
|
||||
<div className="seg" role="tablist">
|
||||
{LOG_VIEWS.map((v) => (
|
||||
<button
|
||||
key={v.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={logFilter.view === v.key}
|
||||
className={`seg-btn ${logFilter.view === v.key ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
setLogFilter({ view: v.key });
|
||||
setLogPage(1);
|
||||
}}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tx-scroll" style={{ marginTop: 12 }}>
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Tipo</th>
|
||||
<th>Servicio</th>
|
||||
<th>Cliente</th>
|
||||
<th>Email</th>
|
||||
<th>Estado</th>
|
||||
<th>Asunto</th>
|
||||
<th>Provider</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{log?.items.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{formatDateTime(row.sendDate)}</td>
|
||||
<td>
|
||||
{NOTIFICATION_TYPE_LABELS[row.notificationType]}
|
||||
{row.level !== null && (row.level === 0 ? " (amarilla)" : " (roja)")}
|
||||
</td>
|
||||
<td>{NOTIFICATION_SERVICIO_LABELS[row.servicio]}</td>
|
||||
<td>
|
||||
{row.customerName}
|
||||
{row.debug ? " · debug" : ""}
|
||||
</td>
|
||||
<td>{row.customerEmail}</td>
|
||||
<td style={{ color: NOTIFICATION_STATUS_COLORS[row.status] }}>
|
||||
{NOTIFICATION_STATUS_LABELS[row.status]}
|
||||
</td>
|
||||
<td>{row.subject}</td>
|
||||
<td className="muted small">
|
||||
{row.providerMessageId ?? row.error ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{log && log.items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8}>
|
||||
<span className="empty-inline">
|
||||
Sin envíos con el filtro actual.
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{log && log.pageCount > 1 && (
|
||||
<div className="pager" style={{ marginTop: 14 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={log.page <= 1}
|
||||
onClick={() => setLogPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
← Anterior
|
||||
</button>
|
||||
<span className="pager-info">
|
||||
{log.total} fila{log.total === 1 ? "" : "s"} · página {log.page} de{" "}
|
||||
{log.pageCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={log.page >= log.pageCount}
|
||||
onClick={() => setLogPage((p) => Math.min(log.pageCount, p + 1))}
|
||||
>
|
||||
Siguiente →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<NotificationLogPanel
|
||||
servicio={SERVICIOS_LOG_SCOPE}
|
||||
reloadToken={logToken}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
listNotificationLog,
|
||||
type NotificationLogPage,
|
||||
type NotificationServicio,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
formatDateTime,
|
||||
NOTIFICATION_SERVICIO_LABELS,
|
||||
NOTIFICATION_STATUS_COLORS,
|
||||
NOTIFICATION_STATUS_LABELS,
|
||||
NOTIFICATION_TYPE_LABELS,
|
||||
notificationLevelLabel,
|
||||
} from "@/lib/labels";
|
||||
|
||||
/**
|
||||
* "Registro de envíos" — the send history over `email_notification_log`.
|
||||
*
|
||||
* Every outbound email the platform sends writes to that one table (the four
|
||||
* bulk jobs and the renewal avisos alike), so this component is shared by
|
||||
* both /notificaciones tabs; each passes the `servicio` slice it owns. Rows
|
||||
* cover failures and skips too, which is the whole point: a notice that never
|
||||
* left is invisible everywhere else.
|
||||
*/
|
||||
|
||||
const LOG_VIEWS = [
|
||||
{ key: "all", label: "Todos" },
|
||||
{ key: "sent", label: "Enviados" },
|
||||
{ key: "failed", label: "Fallidos" },
|
||||
{ key: "skipped", label: "Omitidos" },
|
||||
] as const;
|
||||
|
||||
export type LogView = (typeof LOG_VIEWS)[number]["key"];
|
||||
|
||||
export function NotificationLogPanel({
|
||||
servicio,
|
||||
emptyHint = "Sin envíos con el filtro actual.",
|
||||
/** Bump to force a reload — the parent raises it after a send. */
|
||||
reloadToken = 0,
|
||||
}: {
|
||||
servicio: NotificationServicio[];
|
||||
emptyHint?: string;
|
||||
reloadToken?: number;
|
||||
}) {
|
||||
const [log, setLog] = useState<NotificationLogPage | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [view, setView] = useState<LogView>("all");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// `servicio` is a literal array at every call site, so a new identity each
|
||||
// render would re-fetch forever. Key the effect on its contents instead.
|
||||
const servicioKey = servicio.join(",");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const data = await listNotificationLog({
|
||||
page,
|
||||
pageSize: 50,
|
||||
servicio: servicioKey.split(",") as NotificationServicio[],
|
||||
view: view === "all" ? undefined : view,
|
||||
});
|
||||
setLog(data);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, [page, view, servicioKey]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh, reloadToken]);
|
||||
|
||||
return (
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<h2 className="section-title">Registro de envíos</h2>
|
||||
<div className="seg" role="tablist">
|
||||
{LOG_VIEWS.map((v) => (
|
||||
<button
|
||||
key={v.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === v.key}
|
||||
className={`seg-btn ${view === v.key ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
setView(v.key);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="tx-scroll" style={{ marginTop: 12 }}>
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Tipo</th>
|
||||
<th>Servicio</th>
|
||||
<th>Cliente</th>
|
||||
<th>Email</th>
|
||||
<th>Estado</th>
|
||||
<th>Asunto</th>
|
||||
<th>Provider</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{log?.items.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{formatDateTime(row.sendDate)}</td>
|
||||
<td>
|
||||
{NOTIFICATION_TYPE_LABELS[row.notificationType]}
|
||||
{notificationLevelLabel(row.notificationType, row.level)}
|
||||
</td>
|
||||
<td>{NOTIFICATION_SERVICIO_LABELS[row.servicio]}</td>
|
||||
<td>
|
||||
{row.customerName}
|
||||
{row.debug ? " · debug" : ""}
|
||||
</td>
|
||||
<td>{row.customerEmail || "—"}</td>
|
||||
<td style={{ color: NOTIFICATION_STATUS_COLORS[row.status] }}>
|
||||
{NOTIFICATION_STATUS_LABELS[row.status]}
|
||||
</td>
|
||||
<td>{row.subject}</td>
|
||||
<td className="muted small">
|
||||
{row.providerMessageId ?? row.error ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{log && log.items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8}>
|
||||
<span className="empty-inline">{emptyHint}</span>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{log && log.pageCount > 1 && (
|
||||
<div className="pager" style={{ marginTop: 14 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={log.page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
← Anterior
|
||||
</button>
|
||||
<span className="pager-info">
|
||||
{log.total} fila{log.total === 1 ? "" : "s"} · página {log.page} de{" "}
|
||||
{log.pageCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={log.page >= log.pageCount}
|
||||
onClick={() => setPage((p) => Math.min(log.pageCount, p + 1))}
|
||||
>
|
||||
Siguiente →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+17
-6
@@ -979,9 +979,14 @@ export type NotificationType =
|
||||
| "OUTSTANDING_PAYMENT"
|
||||
| "PAYMENT_CONFIRMATION"
|
||||
| "ACCOUNT_STATUS"
|
||||
| "TRUST_PAYMENT_CONFIRMATION";
|
||||
| "TRUST_PAYMENT_CONFIRMATION"
|
||||
| "RENEWAL_NOTICE";
|
||||
|
||||
export type NotificationServicio = "CUSTOMERS" | "TRUST";
|
||||
export type NotificationServicio = "CUSTOMERS" | "TRUST" | "POLICIES";
|
||||
|
||||
/** Which servicios each /notificaciones tab reads out of the shared log. */
|
||||
export const SERVICIOS_LOG_SCOPE: NotificationServicio[] = ["CUSTOMERS", "TRUST"];
|
||||
export const POLIZAS_LOG_SCOPE: NotificationServicio[] = ["POLICIES"];
|
||||
|
||||
export type NotificationStatus =
|
||||
| "SENT"
|
||||
@@ -1161,7 +1166,8 @@ export interface NotificationLogQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
type?: NotificationType;
|
||||
servicio?: NotificationServicio;
|
||||
/** One or more servicios; omitted = the whole log. */
|
||||
servicio?: NotificationServicio[];
|
||||
status?: NotificationStatus;
|
||||
view?: "sent" | "failed" | "skipped" | "all";
|
||||
}
|
||||
@@ -1173,15 +1179,20 @@ export function listNotificationLog(
|
||||
if (q.page) qs.set("page", String(q.page));
|
||||
if (q.pageSize) qs.set("pageSize", String(q.pageSize));
|
||||
if (q.type) qs.set("type", q.type);
|
||||
if (q.servicio) qs.set("servicio", q.servicio);
|
||||
if (q.servicio?.length) qs.set("servicio", q.servicio.join(","));
|
||||
if (q.status) qs.set("status", q.status);
|
||||
if (q.view) qs.set("view", q.view);
|
||||
const tail = qs.toString();
|
||||
return apiFetch<NotificationLogPage>(`/notifications/log${tail ? `?${tail}` : ""}`);
|
||||
}
|
||||
|
||||
export function getNotificationStats(): Promise<NotificationStats> {
|
||||
return apiFetch<NotificationStats>("/notifications/stats");
|
||||
export function getNotificationStats(
|
||||
servicio?: NotificationServicio[],
|
||||
): Promise<NotificationStats> {
|
||||
const tail = servicio?.length
|
||||
? `?servicio=${encodeURIComponent(servicio.join(","))}`
|
||||
: "";
|
||||
return apiFetch<NotificationStats>(`/notifications/stats${tail}`);
|
||||
}
|
||||
|
||||
/** Build a download URL for a report's file output. The session cookie
|
||||
|
||||
@@ -393,13 +393,35 @@ export const NOTIFICATION_TYPE_LABELS: Record<NotificationType, string> = {
|
||||
PAYMENT_CONFIRMATION: "Confirmación de pago",
|
||||
ACCOUNT_STATUS: "Estado de cuenta",
|
||||
TRUST_PAYMENT_CONFIRMATION: "Confirmación fideicomiso",
|
||||
RENEWAL_NOTICE: "Aviso de renovación",
|
||||
};
|
||||
|
||||
export const NOTIFICATION_SERVICIO_LABELS: Record<NotificationServicio, string> = {
|
||||
CUSTOMERS: "Clientes",
|
||||
TRUST: "Fideicomiso",
|
||||
POLICIES: "Pólizas",
|
||||
};
|
||||
|
||||
/**
|
||||
* The `level` column means something different per notification type, so it
|
||||
* can only be read alongside one. ACCOUNT_STATUS uses it for the alert colour;
|
||||
* RENEWAL_NOTICE for the aviso generation. Everything else leaves it null.
|
||||
*/
|
||||
export function notificationLevelLabel(
|
||||
type: NotificationType,
|
||||
level: number | null,
|
||||
): string {
|
||||
if (level === null) return "";
|
||||
if (type === "ACCOUNT_STATUS") return level === 0 ? " (amarilla)" : " (roja)";
|
||||
if (type === "RENEWAL_NOTICE") {
|
||||
if (level === 1) return " (1.º, 30 días antes)";
|
||||
if (level === 2) return " (2.º, 15 días antes)";
|
||||
if (level === 3) return " (3.º, 7 días después)";
|
||||
return ` (aviso ${level})`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export const NOTIFICATION_STATUS_LABELS: Record<NotificationStatus, string> = {
|
||||
SENT: "Enviado",
|
||||
FAILED: "Falló",
|
||||
|
||||
Reference in New Issue
Block a user