- {items.map((item) => {
- const picked = resolutions[item.contactId];
+
+
+ {item.currentEmail}
+ {item.contactName && · {item.contactName}}
+ {item.candidatesTotal > item.candidates.length && (
+
+ {' '}
+ (
+ {t('superAdmin.accounts.import.reconcile.candidatesShown', {
+ shown: item.candidates.length,
+ total: item.candidatesTotal,
+ })}
+ )
+
+ )}
+
+
+ {item.candidates.map((candidate) => {
+ const pickedBy = pickedEmails.get(candidate.csvEmail.toLowerCase());
+ // An email reconciles ONE contact: blocked when already applied to
+ // another contact (server-computed) or picked on another item of
+ // this page (unsaved local decision).
+ const blocked = candidate.usedByContactId !== undefined || (pickedBy !== undefined && pickedBy !== item.contactId);
return (
-
-
- {item.currentEmail}
- {item.contactName && (
- · {item.contactName}
- )}
-
-
- {item.candidates.map((c) => (
-
- ))}
-
+ {picked === candidate.csvRowNumber &&
{t('superAdmin.accounts.import.reconcile.picked')}}
+
);
})}
+
onResolve(null)}
+ className={`hover:bg-secondary/50 flex w-full items-center justify-between rounded border p-2 text-left text-xs transition ${
+ picked === null ? 'border-amber-500 bg-secondary' : ''
+ }`}
+ >
+ {t('superAdmin.accounts.import.reconcile.skipThis')}
+ {picked === null && {t('superAdmin.accounts.import.reconcile.skipped')}}
+
+
+
+ );
+}
+
+const ITEMS_PAGE_SIZE = 25;
+
+// Flat, searchable "who matched what" table over every session item — auto
+// picks and ambiguous outcomes alike. Read-only: resolution stays in the
+// ambiguous queue above; this section exists for visibility and lookup.
+function ItemsSection({ jobId }: { jobId: string }) {
+ const { t } = useTranslation();
+ const [search, setSearch] = useState('');
+ const [q, setQ] = useState('');
+ const [kind, setKind] = useState<'all' | 'auto' | 'ambiguous'>('all');
+ const [status, setStatus] = useState<'all' | 'pending' | 'applied' | 'skipped' | 'failed'>('all');
+ const [offset, setOffset] = useState(0);
+
+ useEffect(() => {
+ const id = setTimeout(() => {
+ setQ(search.trim());
+ setOffset(0);
+ }, 400);
+ return () => clearTimeout(id);
+ }, [search]);
+
+ const pageQuery = useQuery({
+ queryKey: ['reconcile-items', jobId, offset, q, kind, status],
+ queryFn: () =>
+ reconcileGateway.itemsPage(jobId, {
+ offset,
+ limit: ITEMS_PAGE_SIZE,
+ q: q || undefined,
+ kind: kind === 'all' ? undefined : kind,
+ status: status === 'all' ? undefined : status,
+ }),
+ });
+
+ const total = pageQuery.data?.total ?? 0;
+ const items = pageQuery.data?.items ?? [];
+
+ return (
+
+
+
{t('superAdmin.accounts.import.reconcile.itemsTitle')}
+
{t('superAdmin.accounts.import.reconcile.itemsHint')}
+
+
+
+ setSearch(e.target.value)}
+ placeholder={t('superAdmin.accounts.import.reconcile.searchPlaceholder')}
+ className="h-8 min-w-48 flex-1"
+ />
+
+
-
+
+ {pageQuery.isLoading ? (
+
+ ) : items.length === 0 ? (
+
{t('superAdmin.accounts.import.reconcile.searchNoResults')}
+ ) : (
+
+
+
+
+ {t('superAdmin.accounts.import.reconcile.colContact')}
+ {t('superAdmin.accounts.import.reconcile.colCurrentEmail')}
+ {t('superAdmin.accounts.import.reconcile.colNewEmail')}
+ {t('superAdmin.accounts.import.reconcile.colType')}
+ {t('superAdmin.accounts.import.reconcile.colStatus')}
+
+
+
+ {items.map((item) => (
+
+
+ {item.contactName || #{item.contactId}}
+
+ {item.currentEmail}
+ {item.newEmail ?? '—'}
+
+ {item.kind === 'auto'
+ ? t('superAdmin.accounts.import.reconcile.kindAuto')
+ : t('superAdmin.accounts.import.reconcile.kindAmbiguous')}
+
+
+
+ {item.status === 'failed' && item.failureReason && (
+
+ {item.failureReason}
+
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+
+ setOffset(Math.max(0, offset - ITEMS_PAGE_SIZE))}>
+ {t('superAdmin.accounts.import.reconcile.prevPage')}
+
+
+ {t('superAdmin.accounts.import.reconcile.pageIndicator', {
+ from: Math.min(offset + 1, total).toLocaleString(),
+ to: Math.min(offset + ITEMS_PAGE_SIZE, total).toLocaleString(),
+ total: total.toLocaleString(),
+ })}
+
+ = total} onClick={() => setOffset(offset + ITEMS_PAGE_SIZE)}>
+ {t('superAdmin.accounts.import.reconcile.nextPage')}
+
+
+
);
}
+
+function ItemStatusBadge({ status }: { status: ReconcileItemRow['status'] }) {
+ const { t } = useTranslation();
+ const variant = status === 'applied' ? 'default' : status === 'failed' ? 'destructive' : status === 'skipped' ? 'outline' : 'secondary';
+ const label =
+ status === 'applied'
+ ? t('superAdmin.accounts.import.reconcile.statusApplied')
+ : status === 'failed'
+ ? t('superAdmin.accounts.import.reconcile.statusFailed')
+ : status === 'skipped'
+ ? t('superAdmin.accounts.import.reconcile.statusSkipped')
+ : t('superAdmin.accounts.import.reconcile.statusPending');
+ return
{label};
+}
diff --git a/apps/frontend-react/src/features/super-admin/accounts/reconcile-gateway.ts b/apps/frontend-react/src/features/super-admin/accounts/reconcile-gateway.ts
index d7b6de65..b88bc61e 100644
--- a/apps/frontend-react/src/features/super-admin/accounts/reconcile-gateway.ts
+++ b/apps/frontend-react/src/features/super-admin/accounts/reconcile-gateway.ts
@@ -1,31 +1,77 @@
import { apiClient } from '@/lib/api-client';
-// EVO-1464 workaround — types must match
+// Types must match
// apps/msgops-api/src/modules/enterprise-import/email-reconcile.types.ts
export interface AmbiguousCandidate {
csvRowNumber: number;
csvName: string;
csvEmail: string;
+ // Name-similarity score (0..1) — candidates arrive sorted by it, best first.
+ score: number;
+ // created_at agreement with the contact: 2 = exact instant, 1 = same date,
+ // 0 = none. Absent on sessions created before the field existed.
+ timeMatch?: number;
+ // Present when this candidate's email was already applied to another
+ // contact in this session — picking it would collide with the per-account
+ // email uniqueness, so the UI disables it.
+ usedByContactId?: number;
}
export interface AmbiguousMatch {
contactId: number;
currentEmail: string;
contactName: string;
+ // Top candidates only ("showing candidates.length of candidatesTotal").
candidates: AmbiguousCandidate[];
+ candidatesTotal: number;
}
-export interface ReconcilePreview {
+export interface ReconcileSessionProgress {
+ jobId: string;
csvRows: number;
invalidCsvRows: number;
contactsMasked: number;
- uniqueMatches: number;
- ambiguousMatches: number;
- noMatches: number;
alreadyClean: number;
- ambiguousSample: AmbiguousMatch[];
+ noMatches: number;
noMatchSample: Array<{ contactId: number; currentEmail: string }>;
+ auto: { total: number; applied: number; failed: number; pending: number };
+ ambiguous: { total: number; applied: number; skipped: number; pending: number; failed: number };
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface AmbiguousPage {
+ totalPending: number;
+ offset: number;
+ items: AmbiguousMatch[];
+}
+
+// One row of the session items table — "who matched what".
+export interface ReconcileItemRow {
+ contactId: number;
+ contactName: string;
+ currentEmail: string;
+ kind: 'auto' | 'ambiguous';
+ status: 'pending' | 'applied' | 'skipped' | 'failed';
+ newEmail: string | null;
+ csvRowNumber: number | null;
+ failureReason: string | null;
+ candidatesTotal: number | null;
+}
+
+export interface ReconcileItemsPage {
+ total: number;
+ offset: number;
+ items: ReconcileItemRow[];
+}
+
+export interface ReconcileItemsQuery {
+ offset: number;
+ limit: number;
+ q?: string;
+ kind?: 'auto' | 'ambiguous';
+ status?: 'pending' | 'applied' | 'skipped' | 'failed';
}
export interface ApplyResolution {
@@ -34,21 +80,80 @@ export interface ApplyResolution {
csvRowNumber: number | null;
}
-export interface ApplyResult {
- updated: number;
- skippedAmbiguous: number;
- skippedNoMatch: number;
+export interface ResolveBatchResult {
+ applied: number;
+ skipped: number;
+ invalid: number;
failures: Array<{ contactId: number; reason: string }>;
+ progress: ReconcileSessionProgress;
+}
+
+export interface ApplyAutoChunkResult {
+ applied: number;
+ failed: number;
+ remaining: number;
+}
+
+export interface BulkResolveResult {
+ resolved: number;
+ unresolved: number;
+ nextAfterId: string | null;
+ remainingPending: number;
}
+// Batched reconciliation over a server-side session: the CSV uploads ONCE
+// (createSession) and every later step is an incremental, quantified batch.
export const reconcileGateway = {
- async preview(jobId: string, csv: string): Promise
{
- const { data } = await apiClient.post(`/imports/${jobId}/reconcile/preview`, { csv });
+ async getSession(jobId: string): Promise {
+ try {
+ const { data } = await apiClient.get(`/imports/${jobId}/reconcile/session`);
+ return data;
+ } catch (err: any) {
+ if (err?.response?.status === 404) return null;
+ throw err;
+ }
+ },
+
+ async createSession(jobId: string, csv: string, ignoreColumns: string[] = []): Promise {
+ // Parsing + matching + persisting 350k contacts takes a while — disable
+ // the client timeout and let the server/nginx budget govern.
+ const { data } = await apiClient.post(`/imports/${jobId}/reconcile/session`, { csv, ignoreColumns }, { timeout: 0 });
+ return data;
+ },
+
+ async ambiguousPage(jobId: string, offset: number, limit: number, q?: string): Promise {
+ const { data } = await apiClient.get(`/imports/${jobId}/reconcile/session/ambiguous`, {
+ params: { offset, limit, ...(q ? { q } : {}) },
+ });
+ return data;
+ },
+
+ async itemsPage(jobId: string, query: ReconcileItemsQuery): Promise {
+ const { data } = await apiClient.get(`/imports/${jobId}/reconcile/session/items`, {
+ params: query,
+ });
return data;
},
- async apply(jobId: string, csv: string, resolutions: ApplyResolution[]): Promise {
- const { data } = await apiClient.post(`/imports/${jobId}/reconcile/apply`, { csv, resolutions });
+ async resolve(jobId: string, resolutions: ApplyResolution[]): Promise {
+ const { data } = await apiClient.post(`/imports/${jobId}/reconcile/session/resolve`, { resolutions });
return data;
},
+
+ async applyAuto(jobId: string, limit: number): Promise {
+ const { data } = await apiClient.post(`/imports/${jobId}/reconcile/session/apply-auto`, { limit }, { timeout: 0 });
+ return data;
+ },
+
+ async bulkResolve(
+ jobId: string,
+ payload: { strategy: 'best-name'; threshold: number; limit: number; afterId?: string } | { strategy: 'skip-remaining' },
+ ): Promise {
+ const { data } = await apiClient.post(`/imports/${jobId}/reconcile/session/bulk-resolve`, payload, { timeout: 0 });
+ return data;
+ },
+
+ async deleteSession(jobId: string): Promise {
+ await apiClient.delete(`/imports/${jobId}/reconcile/session`);
+ },
};
diff --git a/apps/frontend-react/src/locales/en-US.json b/apps/frontend-react/src/locales/en-US.json
index 0eef8241..f80f4cf7 100644
--- a/apps/frontend-react/src/locales/en-US.json
+++ b/apps/frontend-react/src/locales/en-US.json
@@ -2496,6 +2496,8 @@
"applyError": "Failed to apply reconciliation",
"applyDoneToast": "{{updated}} contacts updated · {{ambiguous}} ambiguous · {{noMatch}} no match",
"fileReadError": "Failed to read the CSV file",
+ "fileTooLarge": "The file is {{size}} MB, which exceeds the {{max}} MB limit. Split the CSV into smaller parts and reconcile in batches.",
+ "payloadTooLarge": "The server rejected the upload: the file is too large ({{max}} MB limit). Split the CSV into smaller parts and reconcile in batches.",
"statCsvRows": "CSV rows",
"statContactsMasked": "Masked contacts",
"statUniqueMatches": "Unique matches",
@@ -2507,7 +2509,74 @@
"ambiguousShownLimited": "Showing {{shown}} of {{total}} ambiguous cases. Apply and re-preview to see more.",
"picked": "Picked",
"skipThis": "Skip this (leave masked)",
- "skipped": "Skipped"
+ "skipped": "Skipped",
+ "processCsv": "Process CSV",
+ "processingCsv": "Processing CSV…",
+ "uploadHint": "The CSV is processed once. From then on, progress is stored server-side — you can leave the page and pick up where you left off without re-uploading the file.",
+ "sessionCreateError": "Failed to process the CSV",
+ "sessionError": "Failed to load the reconciliation session",
+ "statAuto": "Automatic fixes",
+ "autoTitle": "Automatic fixes",
+ "autoProgress": "{{applied}} of {{total}} applied",
+ "autoFailed": "{{failed}} failures",
+ "autoDone": "All automatic fixes have been applied.",
+ "chunkSize": "Batch",
+ "applyAutoStart": "Apply automatic",
+ "applyAutoStop": "Pause",
+ "bulkTitle": "Bulk actions — ambiguous",
+ "bulkThreshold": "Minimum confidence",
+ "thresholdHigh": "High (80% by name)",
+ "thresholdMedium": "Medium (60% by name)",
+ "thresholdLow": "Low (50% by name)",
+ "bulkBestName": "Auto-resolve by name",
+ "bulkBestNameRunning": "Resolving…",
+ "bulkBestNameDone": "{{resolved}} auto-resolved · {{unresolved}} still pending",
+ "skipRemaining": "Skip all remaining",
+ "skipRemainingConfirm": "Mark all {{count}} pending ambiguous cases as skipped? The contacts keep their masked email.",
+ "skipRemainingDone": "{{count}} contacts skipped",
+ "bulkHint": "Auto-resolve applies the candidate with the highest name similarity when it clears the chosen confidence and there is no tie. The rest stay in the manual queue.",
+ "ambiguousAllDone": "All ambiguous cases handled: {{applied}} applied · {{skipped}} skipped.",
+ "ambiguousPendingHeader": "{{pending}} pending of {{total}} ambiguous cases. Resolve per page or use the bulk actions.",
+ "pageSize": "Per page",
+ "prevPage": "Previous",
+ "nextPage": "Next",
+ "pageIndicator": "{{from}}–{{to}} of {{total}}",
+ "candidatesShown": "showing {{shown}} of {{total}} candidates",
+ "score": "similarity {{pct}}%",
+ "saveDecisions": "Save decisions ({{count}})",
+ "decisionsSaved": "{{applied}} applied · {{skipped}} skipped",
+ "discardSession": "Discard session",
+ "discardConfirm": "Discard the reconciliation session? Fixes already applied to contacts remain — only the work queue is removed.",
+ "columnsDetected": "Columns detected in the file",
+ "columnRequired": "required",
+ "columnOptional": "optional",
+ "columnsMissingTitle": "Invalid file",
+ "columnsMissing": "The file is missing required columns: {{columns}}. Export the CSV from BMS with these columns and try again.",
+ "columnsIgnoredHint": "Required columns drive the match (email = key; created_at and the name break collision ties). The name may come as a name column or as the first_name + last_name pair. Unchecked optional columns are ignored during processing.",
+ "columnNameGroup": "name (or first_name + last_name)",
+ "missingColumnsError": "The CSV is missing required columns: {{columns}}",
+ "timeExact": "exact date/time",
+ "timeSameDay": "same date",
+ "decisionsFailed": "{{count}} not applied — {{reason}}",
+ "usedByContact": "already used by contact #{{id}}",
+ "pickedElsewhere": "picked for another contact",
+ "searchPlaceholder": "Search by name or email…",
+ "searchNoResults": "No items found for this search.",
+ "itemsTitle": "Session items",
+ "itemsHint": "Who matched what — auto and ambiguous matches with their apply status. Resolution stays in the queue above.",
+ "kindAll": "All kinds",
+ "kindAuto": "Automatic",
+ "kindAmbiguous": "Ambiguous",
+ "statusAll": "All statuses",
+ "statusPending": "Pending",
+ "statusApplied": "Applied",
+ "statusSkipped": "Skipped",
+ "statusFailed": "Failed",
+ "colContact": "Contact",
+ "colCurrentEmail": "Current email",
+ "colNewEmail": "New email",
+ "colType": "Kind",
+ "colStatus": "Status"
},
"progressSkipped": "skipped ({{reason}})",
"progressDone": "done",
diff --git a/apps/frontend-react/src/locales/es-ES.json b/apps/frontend-react/src/locales/es-ES.json
index 6c0bb2bb..9a1baf04 100644
--- a/apps/frontend-react/src/locales/es-ES.json
+++ b/apps/frontend-react/src/locales/es-ES.json
@@ -2524,6 +2524,8 @@
"applyError": "Error al aplicar la reconciliación",
"applyDoneToast": "{{updated}} contactos actualizados · {{ambiguous}} ambiguos · {{noMatch}} sin coincidencia",
"fileReadError": "Error al leer el archivo CSV",
+ "fileTooLarge": "El archivo pesa {{size}} MB y supera el límite de {{max}} MB. Divide el CSV en partes más pequeñas y reconcilia por etapas.",
+ "payloadTooLarge": "El servidor rechazó el envío: el archivo es demasiado grande (límite de {{max}} MB). Divide el CSV en partes más pequeñas y reconcilia por etapas.",
"statCsvRows": "Filas en el CSV",
"statContactsMasked": "Contactos enmascarados",
"statUniqueMatches": "Coincidencia única",
@@ -2535,7 +2537,74 @@
"ambiguousShownLimited": "Mostrando {{shown}} de {{total}} casos ambiguos. Aplique y rehaga la previsualización para ver los próximos.",
"picked": "Elegido",
"skipThis": "Saltar este (dejar enmascarado)",
- "skipped": "Saltado"
+ "skipped": "Saltado",
+ "processCsv": "Procesar CSV",
+ "processingCsv": "Procesando CSV…",
+ "uploadHint": "El CSV se procesa una sola vez. A partir de ahí, el progreso queda guardado en el servidor — puedes salir de la página y continuar donde lo dejaste sin volver a subir el archivo.",
+ "sessionCreateError": "Error al procesar el CSV",
+ "sessionError": "Error al cargar la sesión de reconciliación",
+ "statAuto": "Correcciones automáticas",
+ "autoTitle": "Correcciones automáticas",
+ "autoProgress": "{{applied}} de {{total}} aplicadas",
+ "autoFailed": "{{failed}} fallos",
+ "autoDone": "Todas las correcciones automáticas fueron aplicadas.",
+ "chunkSize": "Lote",
+ "applyAutoStart": "Aplicar automáticas",
+ "applyAutoStop": "Pausar",
+ "bulkTitle": "Acciones masivas — ambiguos",
+ "bulkThreshold": "Confianza mínima",
+ "thresholdHigh": "Alta (80% por nombre)",
+ "thresholdMedium": "Media (60% por nombre)",
+ "thresholdLow": "Baja (50% por nombre)",
+ "bulkBestName": "Auto-resolver por nombre",
+ "bulkBestNameRunning": "Resolviendo…",
+ "bulkBestNameDone": "{{resolved}} resueltos automáticamente · {{unresolved}} siguen pendientes",
+ "skipRemaining": "Saltar todos los restantes",
+ "skipRemainingConfirm": "¿Marcar los {{count}} casos ambiguos pendientes como saltados? Los contactos mantienen el email enmascarado.",
+ "skipRemainingDone": "{{count}} contactos saltados",
+ "bulkHint": "Auto-resolver aplica el candidato con mayor similitud de nombre cuando supera la confianza elegida y no hay empate. El resto sigue en la cola manual.",
+ "ambiguousAllDone": "Todos los casos ambiguos fueron tratados: {{applied}} aplicados · {{skipped}} saltados.",
+ "ambiguousPendingHeader": "{{pending}} pendientes de {{total}} casos ambiguos. Resuelve por página o usa las acciones masivas.",
+ "pageSize": "Por página",
+ "prevPage": "Anterior",
+ "nextPage": "Siguiente",
+ "pageIndicator": "{{from}}–{{to}} de {{total}}",
+ "candidatesShown": "mostrando {{shown}} de {{total}} candidatos",
+ "score": "similitud {{pct}}%",
+ "saveDecisions": "Guardar decisiones ({{count}})",
+ "decisionsSaved": "{{applied}} aplicados · {{skipped}} saltados",
+ "discardSession": "Descartar sesión",
+ "discardConfirm": "¿Descartar la sesión de reconciliación? Las correcciones ya aplicadas a los contactos permanecen — solo se elimina la cola de trabajo.",
+ "columnsDetected": "Columnas detectadas en el archivo",
+ "columnRequired": "obligatoria",
+ "columnOptional": "opcional",
+ "columnsMissingTitle": "Archivo inválido",
+ "columnsMissing": "Faltan columnas obligatorias en el archivo: {{columns}}. Exporta el CSV desde BMS con esas columnas e inténtalo de nuevo.",
+ "columnsIgnoredHint": "Las columnas obligatorias impulsan el match (email = clave; created_at y el nombre desempatan colisiones). El nombre puede venir como columna name o como el par first_name + last_name. Las columnas opcionales desmarcadas se ignoran durante el procesamiento.",
+ "columnNameGroup": "name (o first_name + last_name)",
+ "missingColumnsError": "Al CSV le faltan las columnas obligatorias: {{columns}}",
+ "timeExact": "fecha/hora idéntica",
+ "timeSameDay": "misma fecha",
+ "decisionsFailed": "{{count}} no aplicadas — {{reason}}",
+ "usedByContact": "ya usado por el contacto #{{id}}",
+ "pickedElsewhere": "elegido para otro contacto",
+ "searchPlaceholder": "Buscar por nombre o email…",
+ "searchNoResults": "No se encontraron elementos para esta búsqueda.",
+ "itemsTitle": "Elementos de la sesión",
+ "itemsHint": "Quién coincidió con qué — coincidencias automáticas y ambiguas con su estado de aplicación. La resolución sigue en la cola de arriba.",
+ "kindAll": "Todos los tipos",
+ "kindAuto": "Automático",
+ "kindAmbiguous": "Ambiguo",
+ "statusAll": "Todos los estados",
+ "statusPending": "Pendiente",
+ "statusApplied": "Aplicado",
+ "statusSkipped": "Saltado",
+ "statusFailed": "Falló",
+ "colContact": "Contacto",
+ "colCurrentEmail": "Email actual",
+ "colNewEmail": "Email nuevo",
+ "colType": "Tipo",
+ "colStatus": "Estado"
}
}
},
diff --git a/apps/frontend-react/src/locales/pt-BR.json b/apps/frontend-react/src/locales/pt-BR.json
index 7e8c2aa9..d196c093 100644
--- a/apps/frontend-react/src/locales/pt-BR.json
+++ b/apps/frontend-react/src/locales/pt-BR.json
@@ -2496,6 +2496,8 @@
"applyError": "Falha ao aplicar reconciliação",
"applyDoneToast": "{{updated}} contatos atualizados · {{ambiguous}} ambíguos · {{noMatch}} sem match",
"fileReadError": "Falha ao ler o arquivo CSV",
+ "fileTooLarge": "O arquivo tem {{size}} MB e excede o limite de {{max}} MB. Divida o CSV em partes menores e reconcilie em etapas.",
+ "payloadTooLarge": "O servidor recusou o envio: o arquivo é grande demais (limite de {{max}} MB). Divida o CSV em partes menores e reconcilie em etapas.",
"statCsvRows": "Linhas no CSV",
"statContactsMasked": "Contatos mascarados",
"statUniqueMatches": "Match único",
@@ -2507,7 +2509,74 @@
"ambiguousShownLimited": "Mostrando {{shown}} de {{total}} casos ambíguos. Aplique e refaça a pré-visualização pra ver os próximos.",
"picked": "Escolhido",
"skipThis": "Pular este (deixar mascarado)",
- "skipped": "Pulado"
+ "skipped": "Pulado",
+ "processCsv": "Processar CSV",
+ "processingCsv": "Processando CSV…",
+ "uploadHint": "O CSV é processado uma única vez. Depois disso, o progresso fica salvo no servidor — dá pra sair da página e continuar de onde parou, sem reenviar o arquivo.",
+ "sessionCreateError": "Falha ao processar o CSV",
+ "sessionError": "Falha ao carregar a sessão de reconciliação",
+ "statAuto": "Correções automáticas",
+ "autoTitle": "Correções automáticas",
+ "autoProgress": "{{applied}} de {{total}} aplicadas",
+ "autoFailed": "{{failed}} falhas",
+ "autoDone": "Todas as correções automáticas foram aplicadas.",
+ "chunkSize": "Lote",
+ "applyAutoStart": "Aplicar automáticas",
+ "applyAutoStop": "Pausar",
+ "bulkTitle": "Ações em massa — ambíguos",
+ "bulkThreshold": "Confiança mínima",
+ "thresholdHigh": "Alta (80% por nome)",
+ "thresholdMedium": "Média (60% por nome)",
+ "thresholdLow": "Baixa (50% por nome)",
+ "bulkBestName": "Auto-resolver por nome",
+ "bulkBestNameRunning": "Resolvendo…",
+ "bulkBestNameDone": "{{resolved}} resolvidos automaticamente · {{unresolved}} continuam pendentes",
+ "skipRemaining": "Pular todos os restantes",
+ "skipRemainingConfirm": "Marcar todos os {{count}} casos ambíguos pendentes como pulados? Os contatos continuam com o email mascarado.",
+ "skipRemainingDone": "{{count}} contatos pulados",
+ "bulkHint": "Auto-resolver aplica o candidato com maior similaridade de nome quando ele supera a confiança escolhida e não há empate. Os que não passam continuam na fila manual.",
+ "ambiguousAllDone": "Todos os casos ambíguos foram tratados: {{applied}} aplicados · {{skipped}} pulados.",
+ "ambiguousPendingHeader": "{{pending}} pendentes de {{total}} casos ambíguos. Resolva por página ou use as ações em massa.",
+ "pageSize": "Por página",
+ "prevPage": "Anterior",
+ "nextPage": "Próxima",
+ "pageIndicator": "{{from}}–{{to}} de {{total}}",
+ "candidatesShown": "mostrando {{shown}} de {{total}} candidatos",
+ "score": "similaridade {{pct}}%",
+ "saveDecisions": "Salvar decisões ({{count}})",
+ "decisionsSaved": "{{applied}} aplicados · {{skipped}} pulados",
+ "discardSession": "Descartar sessão",
+ "discardConfirm": "Descartar a sessão de reconciliação? As correções já aplicadas nos contatos permanecem — apenas a fila de trabalho é removida.",
+ "columnsDetected": "Colunas detectadas no arquivo",
+ "columnRequired": "obrigatória",
+ "columnOptional": "opcional",
+ "columnsMissingTitle": "Arquivo inválido",
+ "columnsMissing": "Faltam colunas obrigatórias no arquivo: {{columns}}. Exporte o CSV do BMS com essas colunas e tente de novo.",
+ "columnsIgnoredHint": "As colunas obrigatórias são usadas no match (email = chave; created_at e o nome desempatam colisões). O nome pode vir como coluna name ou como o par first_name + last_name. Colunas opcionais desmarcadas são ignoradas no processamento.",
+ "columnNameGroup": "name (ou first_name + last_name)",
+ "missingColumnsError": "O CSV não tem as colunas obrigatórias: {{columns}}",
+ "timeExact": "data/hora idêntica",
+ "timeSameDay": "mesma data",
+ "decisionsFailed": "{{count}} não aplicadas — {{reason}}",
+ "usedByContact": "já usado pelo contato #{{id}}",
+ "pickedElsewhere": "escolhido para outro contato",
+ "searchPlaceholder": "Buscar por nome ou email…",
+ "searchNoResults": "Nenhum item encontrado para essa busca.",
+ "itemsTitle": "Itens da sessão",
+ "itemsHint": "Quem casou com o quê — matches automáticos e ambíguos, com status de aplicação. A resolução continua na fila acima.",
+ "kindAll": "Todos os tipos",
+ "kindAuto": "Automático",
+ "kindAmbiguous": "Ambíguo",
+ "statusAll": "Todos os status",
+ "statusPending": "Pendente",
+ "statusApplied": "Aplicado",
+ "statusSkipped": "Pulado",
+ "statusFailed": "Falhou",
+ "colContact": "Contato",
+ "colCurrentEmail": "Email atual",
+ "colNewEmail": "Email novo",
+ "colType": "Tipo",
+ "colStatus": "Status"
},
"progressSkipped": "pulado ({{reason}})",
"progressDone": "concluído",
diff --git a/apps/msgops-api/src/entities/email-reconcile-item.entity.ts b/apps/msgops-api/src/entities/email-reconcile-item.entity.ts
new file mode 100644
index 00000000..9aeee299
--- /dev/null
+++ b/apps/msgops-api/src/entities/email-reconcile-item.entity.ts
@@ -0,0 +1,68 @@
+import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
+
+export type EmailReconcileItemKind = 'auto' | 'ambiguous';
+export type EmailReconcileItemStatus = 'pending' | 'applied' | 'skipped' | 'failed';
+
+export interface EmailReconcileStoredCandidate {
+ csvRowNumber: number;
+ csvName: string;
+ csvEmail: string;
+ // Jaccard token similarity between the contact name and csvName (0..1).
+ // Candidates are stored sorted by it, best first, so bulk best-name
+ // resolution reads candidates[0] without recomputing.
+ score: number;
+ // created_at agreement with the contact (2 exact, 1 same day, 0 none).
+ // Absent on sessions persisted before the field existed.
+ timeMatch?: number;
+}
+
+// One row per masked contact the reconcile session has an outcome for.
+// kind=auto → unique/confident match; new_email already decided.
+// kind=ambiguous → operator (or bulk strategy) must pick a candidate.
+// Status walks pending → applied|skipped|failed. Contacts with no CSV match
+// get no item — they are only counted on the session row.
+@Entity('email_reconcile_items')
+@Index('email_reconcile_items_job_kind_status_idx', ['jobId', 'kind', 'status'])
+@Index('email_reconcile_items_job_contact_uq', ['jobId', 'contactId'], { unique: true })
+export class EmailReconcileItemEntity {
+ @PrimaryGeneratedColumn('increment', { name: 'id', type: 'bigint' })
+ id: string;
+
+ @Column('uuid', { name: 'job_id' })
+ jobId: string;
+
+ @Column('integer', { name: 'contact_id' })
+ contactId: number;
+
+ @Column('varchar', { name: 'current_email', length: 255 })
+ currentEmail: string;
+
+ @Column('varchar', { name: 'contact_name', length: 255, nullable: true })
+ contactName: string | null;
+
+ @Column('varchar', { name: 'kind', length: 16 })
+ kind: EmailReconcileItemKind;
+
+ @Column('varchar', { name: 'status', length: 16, default: 'pending' })
+ status: EmailReconcileItemStatus;
+
+ // For kind=auto: filled at session create. For kind=ambiguous: filled when
+ // the item is resolved (operator pick or bulk strategy).
+ @Column('varchar', { name: 'new_email', length: 255, nullable: true })
+ newEmail: string | null;
+
+ @Column('integer', { name: 'csv_row_number', nullable: true })
+ csvRowNumber: number | null;
+
+ // kind=ambiguous only: top candidates sorted by score desc, capped — enough
+ // for the operator UI and for bulk best-name resolution.
+ @Column('jsonb', { name: 'candidates', nullable: true })
+ candidates: EmailReconcileStoredCandidate[] | null;
+
+ // Real candidate count before the cap, so the UI can say "showing 20 of N".
+ @Column('integer', { name: 'candidates_total', nullable: true })
+ candidatesTotal: number | null;
+
+ @Column('text', { name: 'failure_reason', nullable: true })
+ failureReason: string | null;
+}
diff --git a/apps/msgops-api/src/entities/email-reconcile-session.entity.ts b/apps/msgops-api/src/entities/email-reconcile-session.entity.ts
new file mode 100644
index 00000000..b2a04b67
--- /dev/null
+++ b/apps/msgops-api/src/entities/email-reconcile-session.entity.ts
@@ -0,0 +1,41 @@
+import { Column, CreateDateColumn, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm';
+
+// One reconcile working set per import job. The CSV is parsed and matched
+// ONCE (POST /imports/:jobId/reconcile/session); the outcome is persisted here
+// plus one row per masked contact in email_reconcile_items, so the operator
+// can resolve/apply in paginated batches without re-uploading the CSV.
+// Re-creating the session replaces the previous one (job_id is the PK).
+@Entity('email_reconcile_sessions')
+export class EmailReconcileSessionEntity {
+ @PrimaryColumn('uuid', { name: 'job_id' })
+ jobId: string;
+
+ @Column('integer', { name: 'account_id' })
+ accountId: number;
+
+ @Column('integer', { name: 'csv_rows' })
+ csvRows: number;
+
+ @Column('integer', { name: 'invalid_csv_rows' })
+ invalidCsvRows: number;
+
+ @Column('integer', { name: 'contacts_masked' })
+ contactsMasked: number;
+
+ @Column('integer', { name: 'already_clean' })
+ alreadyClean: number;
+
+ @Column('integer', { name: 'no_match_total' })
+ noMatchTotal: number;
+
+ // Small capped sample for display only — the full no-match set is derivable
+ // from contacts that stay masked after the session is exhausted.
+ @Column('jsonb', { name: 'no_match_sample', default: () => "'[]'" })
+ noMatchSample: Array<{ contactId: number; currentEmail: string }>;
+
+ @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
+ createdAt: Date;
+
+ @UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone' })
+ updatedAt: Date;
+}
diff --git a/apps/msgops-api/src/main.ts b/apps/msgops-api/src/main.ts
index dd03e420..2eb31f01 100644
--- a/apps/msgops-api/src/main.ts
+++ b/apps/msgops-api/src/main.ts
@@ -70,6 +70,21 @@ async function bootstrap() {
app.getHttpAdapter().getInstance().set('query parser', 'extended');
+ // The email-reconcile flow (/imports/:jobId/reconcile/*) receives whole CSV
+ // exports embedded in the JSON body — 350k contacts easily exceed 20mb — so
+ // these routes get their own higher limit instead of raising the global one,
+ // which also guards public webhook endpoints. Must stay registered BEFORE
+ // the global parser (body-parser skips requests already parsed). Keep in
+ // sync with client_max_body_size in apps/frontend-react/nginx.conf.
+ app.use(
+ '/imports',
+ bodyParser.json({
+ limit: '64mb',
+ verify: (req: any, _res, buf) => {
+ req.rawBody = buf;
+ },
+ }),
+ );
// Captures the raw body alongside the parsed JSON. Webhook controllers
// (e.g. POST /webhooks/meta and /webhooks/evolution-hub) need it to compute
// HMAC-SHA256 signatures from the exact bytes the sender hashed.
diff --git a/apps/msgops-api/src/migrations/1783000000000-create-email-reconcile-sessions.ts b/apps/msgops-api/src/migrations/1783000000000-create-email-reconcile-sessions.ts
new file mode 100644
index 00000000..7f6cf025
--- /dev/null
+++ b/apps/msgops-api/src/migrations/1783000000000-create-email-reconcile-sessions.ts
@@ -0,0 +1,75 @@
+import { MigrationInterface, QueryRunner, Table } from 'typeorm';
+
+/**
+ * Persisted reconcile working set (email reconciliation of masked imports).
+ *
+ * The previous flow was stateless: the whole CSV traveled on every preview and
+ * a single all-or-nothing apply. Real Enterprise exports (350k+ contacts)
+ * produce tens of thousands of ambiguous matches, which made that flow
+ * unusable — the CSV is now parsed/matched once into these tables and the
+ * operator resolves/applies in paginated, quantified batches.
+ *
+ * - `email_reconcile_sessions`: one working set per import job (PK job_id —
+ * re-running the match replaces the session). Carries the counters the UI
+ * shows plus a capped no-match sample.
+ * - `email_reconcile_items`: one row per matched masked contact. kind=auto
+ * rows are ready to apply; kind=ambiguous rows hold the top candidates
+ * (jsonb, sorted by name-similarity score) for operator/bulk resolution.
+ * Composite index (job_id, kind, status) drives pagination and batch
+ * selection; the (job_id, contact_id) unique index anchors resolutions.
+ */
+export class CreateEmailReconcileSessions1783000000000 implements MigrationInterface {
+ private readonly sessionsTable = new Table({
+ name: 'email_reconcile_sessions',
+ columns: [
+ { name: 'job_id', type: 'uuid', isPrimary: true },
+ { name: 'account_id', type: 'integer', isNullable: false },
+ { name: 'csv_rows', type: 'integer', isNullable: false },
+ { name: 'invalid_csv_rows', type: 'integer', isNullable: false },
+ { name: 'contacts_masked', type: 'integer', isNullable: false },
+ { name: 'already_clean', type: 'integer', isNullable: false },
+ { name: 'no_match_total', type: 'integer', isNullable: false },
+ { name: 'no_match_sample', type: 'jsonb', isNullable: false, default: "'[]'" },
+ { name: 'created_at', type: 'TIMESTAMP WITH TIME ZONE', isNullable: false, default: 'NOW()' },
+ { name: 'updated_at', type: 'TIMESTAMP WITH TIME ZONE', isNullable: false, default: 'NOW()' },
+ ],
+ foreignKeys: [{ columnNames: ['job_id'], referencedTableName: 'enterprise_import_jobs', referencedColumnNames: ['id'], onDelete: 'CASCADE' }],
+ });
+
+ private readonly itemsTable = new Table({
+ name: 'email_reconcile_items',
+ columns: [
+ { name: 'id', type: 'bigserial', isPrimary: true },
+ { name: 'job_id', type: 'uuid', isNullable: false },
+ { name: 'contact_id', type: 'integer', isNullable: false },
+ { name: 'current_email', type: 'varchar', length: '255', isNullable: false },
+ { name: 'contact_name', type: 'varchar', length: '255', isNullable: true },
+ { name: 'kind', type: 'varchar', length: '16', isNullable: false },
+ { name: 'status', type: 'varchar', length: '16', isNullable: false, default: "'pending'" },
+ { name: 'new_email', type: 'varchar', length: '255', isNullable: true },
+ { name: 'csv_row_number', type: 'integer', isNullable: true },
+ { name: 'candidates', type: 'jsonb', isNullable: true },
+ { name: 'candidates_total', type: 'integer', isNullable: true },
+ { name: 'failure_reason', type: 'text', isNullable: true },
+ ],
+ foreignKeys: [
+ { columnNames: ['job_id'], referencedTableName: 'email_reconcile_sessions', referencedColumnNames: ['job_id'], onDelete: 'CASCADE' },
+ // CASCADE: a deleted contact simply drops out of the working set.
+ { columnNames: ['contact_id'], referencedTableName: 'contacts', referencedColumnNames: ['id'], onDelete: 'CASCADE' },
+ ],
+ indices: [
+ { name: 'email_reconcile_items_job_kind_status_idx', columnNames: ['job_id', 'kind', 'status'] },
+ { name: 'email_reconcile_items_job_contact_uq', columnNames: ['job_id', 'contact_id'], isUnique: true },
+ ],
+ });
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.createTable(this.sessionsTable, true);
+ await queryRunner.createTable(this.itemsTable, true);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.dropTable(this.itemsTable, true);
+ await queryRunner.dropTable(this.sessionsTable, true);
+ }
+}
diff --git a/apps/msgops-api/src/modules/contacts/contacts.service.ts b/apps/msgops-api/src/modules/contacts/contacts.service.ts
index 4c65b846..3c5925ae 100644
--- a/apps/msgops-api/src/modules/contacts/contacts.service.ts
+++ b/apps/msgops-api/src/modules/contacts/contacts.service.ts
@@ -24,7 +24,6 @@ import { SuppressedsPageDto } from './dto/suppressedsPage.dto';
import { ContactAutomationEntity } from 'src/entities/contact-automation.entity';
import { AuditService } from './../../utils/audits/audit.service';
import { ClickhouseProvider } from '../../providers/clickhouse.provider';
-import { maskEmail } from '../../utils/masking/email-masker';
import { EventPublisherService } from '../../providers/messaging/event-publisher.service';
import { EXCHANGES } from '@bms/messaging';
import { TagEntity } from 'src/entities/tag.entity';
@@ -93,7 +92,10 @@ export class ContactsService {
async findAll(): Promise> {
try {
- const contacts = await this.contactRepository.find({
+ // Returns the stored email verbatim. Reconciled contacts carry their
+ // real address (the send flow depends on it); never re-mask on read —
+ // contacts still pending reconcile hold the masked placeholder anyway.
+ return await this.contactRepository.find({
where: {
accountId: this.cls.get('accountId'),
},
@@ -101,10 +103,6 @@ export class ContactsService {
createdAtDate: 'DESC',
},
});
- return contacts.map((contact) => ({
- ...contact,
- email: contact.maskedEmail ?? maskEmail(contact.email),
- })) as ContactEntity[];
} catch (e) {
console.error(e);
throw new HttpException('Internal Server Error', HttpStatus.INTERNAL_SERVER_ERROR);
@@ -492,13 +490,10 @@ export class ContactsService {
return csvParser.parse(contacts);
}
- const maskedResults = results.map((result) => ({
- ...result,
- email: maskEmail(result.email),
- }));
-
+ // Stored email verbatim — same rationale as findAll/findOneById: the
+ // send flow and the operator need the real address after reconcile.
return new PaginationDto({
- results: maskedResults,
+ results,
total: results.length,
page: params.page,
itemsPerPage: params.itemsPerPage,
@@ -648,10 +643,6 @@ export class ContactsService {
returnObject[this.snakeToCamelCase(key)] = result[0][key];
});
- if (returnObject.email) {
- returnObject.email = maskEmail(returnObject.email);
- }
-
return returnObject;
} catch (e) {
console.error(e);
diff --git a/apps/msgops-api/src/modules/enterprise-import/__tests__/email-reconcile.service.spec.ts b/apps/msgops-api/src/modules/enterprise-import/__tests__/email-reconcile.service.spec.ts
index a1d6da0d..dccdf911 100644
--- a/apps/msgops-api/src/modules/enterprise-import/__tests__/email-reconcile.service.spec.ts
+++ b/apps/msgops-api/src/modules/enterprise-import/__tests__/email-reconcile.service.spec.ts
@@ -14,6 +14,9 @@ describe('EmailReconcileService', () => {
let service: EmailReconcileService;
let contactsRepo: {
update: jest.Mock;
+ create: jest.Mock;
+ save: jest.Mock;
+ findOne: jest.Mock;
createQueryBuilder: jest.Mock;
};
let jobsRepo: { findOne: jest.Mock };
@@ -28,6 +31,12 @@ describe('EmailReconcileService', () => {
beforeEach(async () => {
contactsRepo = {
update: jest.fn().mockResolvedValue({ affected: 1 }),
+ // create() passes the partial through so save() assertions can inspect
+ // exactly what would be persisted.
+ create: jest.fn((partial) => partial),
+ save: jest.fn().mockImplementation((entity) => Promise.resolve(entity)),
+ // No pre-existing holder of any email unless a test says otherwise.
+ findOne: jest.fn().mockResolvedValue(null),
createQueryBuilder: jest.fn(),
};
jobsRepo = { findOne: jest.fn() };
@@ -130,15 +139,202 @@ describe('EmailReconcileService', () => {
});
});
+ describe('required CSV columns', () => {
+ it('rejects a CSV missing created_at before processing any row', async () => {
+ setMaskedContacts([]);
+ const csv = ['name,email,status', 'Lucas Silva,lucassilva@gmail.com,Active'].join('\n');
+
+ const err = await service.preview('job-1', csv).catch((e) => e);
+ expect(err).toBeInstanceOf(BadRequestException);
+ expect(err.getResponse()).toMatchObject({ code: 'RECONCILE_MISSING_COLUMNS', missing: ['created_at'] });
+ });
+
+ it('lists every missing required column', async () => {
+ setMaskedContacts([]);
+ const csv = ['email,status', 'lucassilva@gmail.com,Active'].join('\n');
+
+ const err = await service.preview('job-1', csv).catch((e) => e);
+ expect(err).toBeInstanceOf(BadRequestException);
+ expect(err.getResponse()).toMatchObject({ missing: ['name (or first_name + last_name)', 'created_at'] });
+ });
+
+ it('accepts first_name/last_name in place of name, composing the full name', async () => {
+ setMaskedContacts([{ id: 10, email: 'lucas***@gmail.com', firstName: 'Joao', lastName: 'Pereira' }]);
+
+ // Collision decided by name only (contact has no createdAt) — proves the
+ // composed first+last name feeds the similarity scoring.
+ const csv = [
+ 'first_name,last_name,email,status,created_at',
+ 'Joao,Pereira,lucassilva@gmail.com,Active,2026-01-01',
+ 'Carlos,Melo,lucasrocha@gmail.com,Active,2026-01-02',
+ ].join('\n');
+
+ const out = await service.preview('job-1', csv);
+ expect(out.uniqueMatches).toBe(1);
+ expect(out.ambiguousMatches).toBe(0);
+ });
+
+ it('rejects first_name without last_name when name is absent', async () => {
+ setMaskedContacts([]);
+ const csv = ['first_name,email,created_at', 'Lucas,lucassilva@gmail.com,2026-01-01'].join('\n');
+
+ const err = await service.preview('job-1', csv).catch((e) => e);
+ expect(err).toBeInstanceOf(BadRequestException);
+ expect(err.getResponse()).toMatchObject({ missing: ['name (or first_name + last_name)'] });
+ });
+
+ it('accepts headers regardless of case and semicolon delimiter', async () => {
+ setMaskedContacts([{ id: 10, email: 'lucas***@gmail.com', firstName: 'Lucas', lastName: 'Silva' }]);
+ const csv = ['Name;Email;Status;Created_At', 'Lucas Silva;lucassilva@gmail.com;Active;2026-01-01'].join('\n');
+
+ const out = await service.preview('job-1', csv);
+ expect(out.uniqueMatches).toBe(1);
+ });
+ });
+
+ describe('created_at disambiguation', () => {
+ it('auto-matches when exactly one collision candidate shares the exact timestamp', async () => {
+ // Names are useless here (both totally different people) — only the
+ // timestamp separates them.
+ setMaskedContacts([{ id: 10, email: 'lucas***@gmail.com', firstName: 'Joao', lastName: 'Pereira', createdAt: new Date('2023-04-10T14:22:31Z') }]);
+
+ const csv = ['name,email,status,created_at', 'Ana Souza,lucassilva@gmail.com,Active,2023-04-10 14:22:31', 'Rita Melo,lucasrocha@gmail.com,Active,2024-08-01 09:00:00'].join(
+ '\n',
+ );
+
+ const out = await service.preview('job-1', csv);
+ expect(out.uniqueMatches).toBe(1);
+ expect(out.ambiguousMatches).toBe(0);
+ });
+
+ it('tolerates a fixed timezone offset in offset-less timestamps', async () => {
+ setMaskedContacts([{ id: 10, email: 'lucas***@gmail.com', firstName: 'Joao', lastName: 'Pereira', createdAt: new Date('2023-04-10T14:22:31Z') }]);
+
+ // Export written in America/Sao_Paulo local time (UTC-3), no offset marker.
+ const csv = ['name,email,status,created_at', 'Ana Souza,lucassilva@gmail.com,Active,2023-04-10 11:22:31', 'Rita Melo,lucasrocha@gmail.com,Active,2024-08-01 09:00:00'].join(
+ '\n',
+ );
+
+ const out = await service.preview('job-1', csv);
+ expect(out.uniqueMatches).toBe(1);
+ expect(out.ambiguousMatches).toBe(0);
+ });
+
+ it('uses date-only agreement when the CSV carries no time component', async () => {
+ setMaskedContacts([{ id: 10, email: 'lucas***@gmail.com', firstName: 'Joao', lastName: 'Pereira', createdAt: new Date('2023-04-10T14:22:31Z') }]);
+
+ const csv = ['name,email,status,created_at', 'Ana Souza,lucassilva@gmail.com,Active,2023-04-10', 'Rita Melo,lucasrocha@gmail.com,Active,2024-08-01'].join('\n');
+
+ const out = await service.preview('job-1', csv);
+ expect(out.uniqueMatches).toBe(1);
+ });
+
+ it('keeps ambiguity but drops time-disagreeing candidates when several share the date', async () => {
+ setMaskedContacts([{ id: 10, email: 'lucas***@gmail.com', firstName: 'Joao', lastName: 'Pereira', createdAt: new Date('2023-04-10T14:22:31Z') }]);
+
+ const csv = [
+ 'name,email,status,created_at',
+ 'Ana Souza,lucassilva@gmail.com,Active,2023-04-10',
+ 'Rita Melo,lucasrocha@gmail.com,Active,2023-04-10',
+ 'Bia Costa,lucasbia@gmail.com,Active,2024-08-01',
+ ].join('\n');
+
+ const out = await service.preview('job-1', csv);
+ expect(out.ambiguousMatches).toBe(1);
+ // Only the two same-day candidates survive; each carries timeMatch=1.
+ expect(out.ambiguousSample[0]?.candidates).toHaveLength(2);
+ expect(out.ambiguousSample[0]?.candidates.every((c) => c.timeMatch === 1)).toBe(true);
+ });
+
+ it('compares at minute precision, ignoring seconds and milliseconds', async () => {
+ // DB keeps ms, export truncates to the minute — the instant tier must
+ // still discriminate between two same-day candidates.
+ setMaskedContacts([{ id: 10, email: 'lucas***@gmail.com', firstName: 'Joao', lastName: 'Pereira', createdAt: new Date('2023-04-10T14:22:31.874Z') }]);
+
+ const csv = [
+ 'name,email,status,created_at',
+ // 11:22 local (UTC-3) == 14:22Z — same minute, no seconds written.
+ 'Ana Souza,lucassilva@gmail.com,Active,2023-04-10 11:22',
+ // Same day but a different minute — must lose to the row above.
+ 'Rita Melo,lucasrocha@gmail.com,Active,2023-04-10 09:15',
+ ].join('\n');
+
+ const out = await service.preview('job-1', csv);
+ expect(out.uniqueMatches).toBe(1);
+ expect(out.ambiguousMatches).toBe(0);
+ });
+
+ it('matches names regardless of punctuation and token order', async () => {
+ setMaskedContacts([{ id: 10, email: 'lucas***@gmail.com', firstName: 'Lucas', lastName: 'Silva' }]);
+
+ const csv = ['name,email,status,created_at', '"Silva, Lucas",lucassilva@gmail.com,Active,2026-01-01', 'Carlos Pereira,lucasrocha@gmail.com,Active,2026-01-02'].join('\n');
+
+ const out = await service.preview('job-1', csv);
+ expect(out.uniqueMatches).toBe(1);
+ expect(out.ambiguousMatches).toBe(0);
+ });
+
+ it('does not hand a lone CSV row to every contact sharing the mask (partial CSV)', async () => {
+ // Real incident: a 1-row test CSV auto-matched all 82 contacts whose
+ // mask collided with the row's — the same email assigned 82 times.
+ setMaskedContacts([
+ { id: 10, email: 'franc***@gmail.com', firstName: 'Francyne', lastName: 'Ferraz', createdAt: new Date('2026-07-09T07:48:16.039Z') },
+ { id: 11, email: 'franc***@gmail.com', firstName: 'Francisco', lastName: 'Picone', createdAt: new Date('2026-04-11T14:02:32.298Z') },
+ { id: 12, email: 'franc***@gmail.com', firstName: 'Franco', lastName: 'Caputo', createdAt: new Date('2026-03-16T16:44:14.642Z') },
+ ]);
+
+ // Export truncates seconds — 04:48 local (UTC-3) vs 07:48:16.039Z still
+ // agrees at minute precision (seconds/ms ignored on both sides).
+ const csv = ['first_name,last_name,email,status,created_at', 'Francyne,Ferraz,francferraz98@gmail.com,Active,2026-07-09 04:48'].join('\n');
+
+ const out = await service.preview('job-1', csv);
+
+ expect(out.uniqueMatches).toBe(1);
+ // The other two collision contacts must NOT auto-receive the same email;
+ // the operator sees the row as a (time-disagreeing) manual option.
+ expect(out.ambiguousMatches).toBe(2);
+ expect(out.ambiguousSample.map((a) => a.contactId).sort()).toEqual([11, 12]);
+ });
+
+ it('gives a CSV row claimed by two auto picks to the strongest agreement only', async () => {
+ // Bulk-created base: both contacts share the mask AND the creation
+ // minute, so both would auto-pick the lone row. Email is unique per
+ // account — the better name keeps it, the other goes to the operator.
+ setMaskedContacts([
+ { id: 10, email: 'lucas***@gmail.com', firstName: 'Lucas', lastName: 'Silva', createdAt: new Date('2026-01-05T12:30:10Z') },
+ { id: 11, email: 'lucas***@gmail.com', firstName: 'Pedro', lastName: 'Costa', createdAt: new Date('2026-01-05T12:30:40Z') },
+ ]);
+
+ const csv = ['name,email,status,created_at', 'Lucas Silva,lucassilva@gmail.com,Active,2026-01-05 12:30'].join('\n');
+
+ const out = await service.preview('job-1', csv);
+
+ expect(out.uniqueMatches).toBe(1);
+ expect(out.ambiguousMatches).toBe(1);
+ expect(out.ambiguousSample[0]?.contactId).toBe(11);
+ expect(out.ambiguousSample[0]?.candidates).toHaveLength(1);
+ });
+
+ it('falls back to name-only behavior when the contact has no createdAt', async () => {
+ setMaskedContacts([{ id: 10, email: 'lucas***@gmail.com', firstName: 'Joao', lastName: 'Pereira' }]);
+
+ const csv = ['name,email,status,created_at', 'Lucas Silva,lucassilva@gmail.com,Active,2026-01-01', 'Lucas Souza,lucasrocha@gmail.com,Active,2026-01-02'].join('\n');
+
+ const out = await service.preview('job-1', csv);
+ expect(out.ambiguousMatches).toBe(1);
+ expect(out.ambiguousSample[0]?.candidates).toHaveLength(2);
+ });
+ });
+
describe('apply', () => {
- it('writes a row per unique match using repository.update so the BeforeUpdate listener fires', async () => {
+ it('writes a row per unique match using save() so the BeforeUpdate listener fires', async () => {
setMaskedContacts([{ id: 10, email: 'lucas***@gmail.com', firstName: 'Lucas', lastName: 'Silva' }]);
const csv = ['name,email,status,created_at', 'Lucas Silva,lucassilva@gmail.com,Active,2026-01-01'].join('\n');
const out = await service.apply('job-1', csv, []);
- expect(contactsRepo.update).toHaveBeenCalledWith({ id: 10 }, { email: 'lucassilva@gmail.com' });
+ expect(contactsRepo.save).toHaveBeenCalledWith({ id: 10, email: 'lucassilva@gmail.com' });
expect(out.updated).toBe(1);
expect(out.skippedAmbiguous).toBe(0);
});
@@ -151,7 +347,7 @@ describe('EmailReconcileService', () => {
// Operator picks row 2 even though row 1 would otherwise be ambiguous.
const out = await service.apply('job-1', csv, [{ contactId: 10, csvRowNumber: 2 }]);
- expect(contactsRepo.update).toHaveBeenCalledWith({ id: 10 }, { email: 'lucasrocha@gmail.com' });
+ expect(contactsRepo.save).toHaveBeenCalledWith({ id: 10, email: 'lucasrocha@gmail.com' });
expect(out.updated).toBe(1);
});
@@ -162,17 +358,32 @@ describe('EmailReconcileService', () => {
const out = await service.apply('job-1', csv, [{ contactId: 10, csvRowNumber: null }]);
- expect(contactsRepo.update).not.toHaveBeenCalled();
+ expect(contactsRepo.save).not.toHaveBeenCalled();
expect(out.updated).toBe(0);
expect(out.skippedAmbiguous).toBe(1);
});
+ it('fails friendly when the email already belongs to another contact in the account', async () => {
+ setMaskedContacts([{ id: 10, email: 'lucas***@gmail.com', firstName: 'Lucas', lastName: 'Silva' }]);
+ // Someone else (a clean contact) already holds the address.
+ contactsRepo.findOne.mockResolvedValue({ id: 999 });
+
+ const csv = ['name,email,status,created_at', 'Lucas Silva,lucassilva@gmail.com,Active,2026-01-01'].join('\n');
+
+ const out = await service.apply('job-1', csv, []);
+
+ expect(contactsRepo.save).not.toHaveBeenCalled();
+ expect(out.updated).toBe(0);
+ expect(out.failures).toHaveLength(1);
+ expect(out.failures[0]?.reason).toContain('#999');
+ });
+
it('records failures without aborting the batch', async () => {
setMaskedContacts([
{ id: 10, email: 'lucas***@gmail.com', firstName: 'Lucas', lastName: 'Silva' },
{ id: 11, email: 'maria***@gmail.com', firstName: 'Maria', lastName: 'Souza' },
]);
- contactsRepo.update.mockImplementationOnce(() => Promise.reject(new Error('DB down')));
+ contactsRepo.save.mockImplementationOnce(() => Promise.reject(new Error('DB down')));
const csv = ['name,email,status,created_at', 'Lucas Silva,lucassilva@gmail.com,Active,2026-01-01', 'Maria Souza,mariasouza@gmail.com,Active,2026-01-02'].join('\n');
diff --git a/apps/msgops-api/src/modules/enterprise-import/dtos/reconcile-session.dto.ts b/apps/msgops-api/src/modules/enterprise-import/dtos/reconcile-session.dto.ts
new file mode 100644
index 00000000..67893635
--- /dev/null
+++ b/apps/msgops-api/src/modules/enterprise-import/dtos/reconcile-session.dto.ts
@@ -0,0 +1,61 @@
+import * as Joi from 'joi';
+import { JoiSchema, JoiSchemaOptions } from 'nestjs-joi';
+
+// Same ceiling as the stateless preview/apply DTOs (reconcile.dto.ts) — the
+// CSV arrives once at session create and never travels again afterwards.
+const CSV_MAX_BYTES = 50 * 1024 * 1024;
+
+const resolutionSchema = Joi.object({
+ contactId: Joi.number().integer().positive().required(),
+ // null = explicit "skip / leave masked". Number = pick this CSV row.
+ csvRowNumber: Joi.number().integer().positive().allow(null).required(),
+});
+
+@JoiSchemaOptions({ stripUnknown: true })
+export class ReconcileSessionCreateDto {
+ @JoiSchema(Joi.string().min(10).max(CSV_MAX_BYTES).required())
+ csv: string;
+
+ // Optional columns the operator deselected in the upload form (e.g.
+ // `status`). Required columns are never ignorable — the service drops them
+ // from this list defensively.
+ @JoiSchema(Joi.array().items(Joi.string().max(100)).max(50).default([]))
+ ignoreColumns: string[];
+}
+
+@JoiSchemaOptions({ stripUnknown: true })
+export class ReconcileResolveBatchDto {
+ // Bounded to a UI page — decisions apply immediately, so each call must
+ // stay interactive.
+ @JoiSchema(Joi.array().items(resolutionSchema).min(1).max(500).required())
+ resolutions: Array<{ contactId: number; csvRowNumber: number | null }>;
+}
+
+@JoiSchemaOptions({ stripUnknown: true })
+export class ReconcileApplyAutoDto {
+ // Operator-tunable chunk: bigger = fewer round trips, smaller = more
+ // granular progress. Each row is an individual repo.update (BeforeUpdate
+ // listener), so the ceiling keeps a chunk comfortably under proxy timeouts.
+ @JoiSchema(Joi.number().integer().min(100).max(20000).default(5000))
+ limit: number;
+}
+
+@JoiSchemaOptions({ stripUnknown: true })
+export class ReconcileBulkResolveDto {
+ @JoiSchema(Joi.string().valid('best-name', 'skip-remaining').required())
+ strategy: 'best-name' | 'skip-remaining';
+
+ // best-name only: minimum score of the top candidate. 0.8 mirrors the
+ // automatic tie-break; lower values trade precision for coverage under
+ // explicit operator consent.
+ @JoiSchema(Joi.number().min(0.1).max(1).default(0.5))
+ threshold: number;
+
+ @JoiSchema(Joi.number().integer().min(100).max(20000).default(5000))
+ limit: number;
+
+ // Pagination cursor from the previous call (items left pending are not
+ // re-examined within one sweep).
+ @JoiSchema(Joi.string().pattern(/^\d+$/).optional())
+ afterId?: string;
+}
diff --git a/apps/msgops-api/src/modules/enterprise-import/dtos/reconcile.dto.ts b/apps/msgops-api/src/modules/enterprise-import/dtos/reconcile.dto.ts
index 66bdfd5d..9a2488fb 100644
--- a/apps/msgops-api/src/modules/enterprise-import/dtos/reconcile.dto.ts
+++ b/apps/msgops-api/src/modules/enterprise-import/dtos/reconcile.dto.ts
@@ -1,7 +1,11 @@
import * as Joi from 'joi';
import { JoiSchema, JoiSchemaOptions } from 'nestjs-joi';
-const CSV_MAX_BYTES = 12 * 1024 * 1024; // 12MB — comfortably above 30k+ contact rows.
+// 50MB of CSV text — real Enterprise exports reach hundreds of thousands of
+// contacts (350k ≈ 20MB). Must stay under the 64mb body-parser limit on the
+// /imports routes (main.ts) and match MAX_CSV_FILE_MB in the frontend's
+// reconcile-emails-card.tsx, which pre-checks the file before uploading.
+const CSV_MAX_BYTES = 50 * 1024 * 1024;
const resolutionSchema = Joi.object({
contactId: Joi.number().integer().positive().required(),
diff --git a/apps/msgops-api/src/modules/enterprise-import/email-reconcile-session.service.ts b/apps/msgops-api/src/modules/enterprise-import/email-reconcile-session.service.ts
new file mode 100644
index 00000000..ef4474d9
--- /dev/null
+++ b/apps/msgops-api/src/modules/enterprise-import/email-reconcile-session.service.ts
@@ -0,0 +1,451 @@
+import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { In, Repository } from 'typeorm';
+import { ContactEntity } from '../../entities/contact.entity';
+import { EnterpriseImportJobEntity } from '../../entities/enterprise-import-job.entity';
+import { EmailReconcileItemEntity, EmailReconcileStoredCandidate } from '../../entities/email-reconcile-item.entity';
+import { EmailReconcileSessionEntity } from '../../entities/email-reconcile-session.entity';
+import { AMBIGUOUS_CANDIDATES_LIMIT, EmailReconcileService } from './email-reconcile.service';
+import type {
+ AmbiguousPageResult,
+ ApplyAutoChunkResult,
+ ApplyResolution,
+ BulkResolveResult,
+ ReconcileItemsPage,
+ ReconcileSessionProgress,
+ ResolveBatchResult,
+} from './email-reconcile.types';
+
+const NO_MATCH_SAMPLE_LIMIT = 50;
+const INSERT_CHUNK = 1000;
+
+/**
+ * Batched reconciliation over a persisted working set.
+ *
+ * The stateless preview/apply flow collapses on real Enterprise exports:
+ * 350k contacts yield tens of thousands of ambiguous matches, and both the
+ * response payload and the single all-or-nothing apply become unmanageable.
+ *
+ * Here the CSV is parsed and matched ONCE (createSession) and the outcome is
+ * persisted — one item per matched contact. From then on everything is
+ * incremental and quantified:
+ * - auto items are applied in operator-sized chunks (applyAutoChunk),
+ * - ambiguous items are reviewed in pages (getAmbiguousPage + resolveBatch),
+ * - bulk strategies (best-name / skip-remaining) clear the long tail.
+ * Progress is derivable at any point from item statuses, so the operator can
+ * leave and resume without re-uploading the CSV.
+ */
+@Injectable()
+export class EmailReconcileSessionService {
+ private readonly logger = new Logger(EmailReconcileSessionService.name);
+
+ constructor(
+ private readonly reconcileService: EmailReconcileService,
+ @InjectRepository(ContactEntity)
+ private readonly contactsRepo: Repository,
+ @InjectRepository(EnterpriseImportJobEntity)
+ private readonly jobsRepo: Repository,
+ @InjectRepository(EmailReconcileSessionEntity)
+ private readonly sessionsRepo: Repository,
+ @InjectRepository(EmailReconcileItemEntity)
+ private readonly itemsRepo: Repository,
+ ) {}
+
+ async createSession(jobId: string, csv: string, ignoreColumns: string[] = []): Promise {
+ const job = await this.requireJob(jobId);
+ const computation = await this.reconcileService.computeReconciliation(csv, job.accountId!, ignoreColumns);
+
+ // Replace any previous working set — items cascade with the session row.
+ await this.sessionsRepo.delete({ jobId });
+ await this.sessionsRepo.insert({
+ jobId,
+ accountId: job.accountId!,
+ csvRows: computation.csvRows,
+ invalidCsvRows: computation.invalidCsvRows,
+ contactsMasked: computation.contactsMasked,
+ alreadyClean: computation.alreadyClean,
+ noMatchTotal: computation.noMatches.length,
+ noMatchSample: computation.noMatches.slice(0, NO_MATCH_SAMPLE_LIMIT),
+ });
+
+ const items: Array> = [
+ ...computation.matches.map((m) => ({
+ jobId,
+ contactId: m.contactId,
+ currentEmail: m.currentEmail,
+ contactName: m.contactName || null,
+ kind: 'auto' as const,
+ status: 'pending' as const,
+ newEmail: m.newEmail,
+ csvRowNumber: m.csvRowNumber,
+ })),
+ ...computation.ambiguous.map((a) => ({
+ jobId,
+ contactId: a.contactId,
+ currentEmail: a.currentEmail,
+ contactName: a.contactName || null,
+ kind: 'ambiguous' as const,
+ status: 'pending' as const,
+ candidates: a.candidates.slice(0, AMBIGUOUS_CANDIDATES_LIMIT) as EmailReconcileStoredCandidate[],
+ candidatesTotal: a.candidatesTotal,
+ })),
+ ];
+
+ for (let i = 0; i < items.length; i += INSERT_CHUNK) {
+ await this.itemsRepo.insert(items.slice(i, i + INSERT_CHUNK));
+ }
+
+ this.logger.log(
+ `[email-reconcile] session created jobId=${jobId} accountId=${job.accountId} auto=${computation.matches.length} ambiguous=${computation.ambiguous.length} no_match=${computation.noMatches.length}`,
+ );
+ return this.getProgress(jobId);
+ }
+
+ async getProgress(jobId: string): Promise {
+ const session = await this.sessionsRepo.findOne({ where: { jobId } });
+ if (!session) throw new NotFoundException(`No reconcile session for job ${jobId}`);
+
+ const counts: Array<{ kind: string; status: string; count: string }> = await this.itemsRepo
+ .createQueryBuilder('i')
+ .select('i.kind', 'kind')
+ .addSelect('i.status', 'status')
+ .addSelect('COUNT(*)', 'count')
+ .where('i.job_id = :jobId', { jobId })
+ .groupBy('i.kind')
+ .addGroupBy('i.status')
+ .getRawMany();
+
+ const get = (kind: string, status: string): number => Number(counts.find((c) => c.kind === kind && c.status === status)?.count ?? 0);
+ const auto = {
+ applied: get('auto', 'applied'),
+ failed: get('auto', 'failed'),
+ pending: get('auto', 'pending'),
+ total: 0,
+ };
+ auto.total = auto.applied + auto.failed + auto.pending;
+ const ambiguous = {
+ applied: get('ambiguous', 'applied'),
+ skipped: get('ambiguous', 'skipped'),
+ pending: get('ambiguous', 'pending'),
+ failed: get('ambiguous', 'failed'),
+ total: 0,
+ };
+ ambiguous.total = ambiguous.applied + ambiguous.skipped + ambiguous.pending + ambiguous.failed;
+
+ return {
+ jobId,
+ csvRows: session.csvRows,
+ invalidCsvRows: session.invalidCsvRows,
+ contactsMasked: session.contactsMasked,
+ alreadyClean: session.alreadyClean,
+ noMatches: session.noMatchTotal,
+ noMatchSample: session.noMatchSample,
+ auto: { total: auto.total, applied: auto.applied, failed: auto.failed, pending: auto.pending },
+ ambiguous: {
+ total: ambiguous.total,
+ applied: ambiguous.applied,
+ skipped: ambiguous.skipped,
+ pending: ambiguous.pending,
+ failed: ambiguous.failed,
+ },
+ createdAt: session.createdAt.toISOString(),
+ updatedAt: session.updatedAt.toISOString(),
+ };
+ }
+
+ async getAmbiguousPage(jobId: string, offset: number, limit: number, q?: string): Promise {
+ await this.requireSession(jobId);
+ const qb = this.itemsRepo
+ .createQueryBuilder('i')
+ .where('i.job_id = :jobId', { jobId })
+ .andWhere("i.kind = 'ambiguous'")
+ .andWhere("i.status = 'pending'")
+ .orderBy('i.id', 'ASC')
+ .skip(offset)
+ .take(limit);
+ this.applySearch(qb, q);
+ const [rows, totalPending] = await qb.getManyAndCount();
+
+ const usedBy = await this.findUsedCandidates(jobId, rows);
+
+ return {
+ totalPending,
+ offset,
+ items: rows.map((r) => ({
+ contactId: r.contactId,
+ currentEmail: r.currentEmail,
+ contactName: r.contactName ?? '',
+ candidates: (r.candidates ?? []).map((c) => {
+ const usedByContactId = usedBy.get(c.csvEmail.toLowerCase());
+ return usedByContactId !== undefined && usedByContactId !== r.contactId ? { ...c, usedByContactId } : c;
+ }),
+ candidatesTotal: r.candidatesTotal ?? r.candidates?.length ?? 0,
+ })),
+ };
+ }
+
+ /**
+ * Emails from this page's candidates that were already applied in this
+ * session, mapped to the contact that received them. Keyed by email (not
+ * CSV row number) so duplicate CSV rows carrying the same address are
+ * flagged too. Lets the UI disable picks that would collide with the
+ * per-account email uniqueness instead of failing after the fact.
+ */
+ private async findUsedCandidates(jobId: string, rows: EmailReconcileItemEntity[]): Promise