Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/frontend-react/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ server {

# API reverse proxy: /api/* → msgops-api (strips /api prefix)
location /api/ {
# The email-reconcile flow POSTs whole CSV exports (hundreds of
# thousands of contacts) embedded in the JSON body. Must match the
# body-parser limit on the /imports routes in msgops-api. The public
# surfaces (/bms/, /c) intentionally keep nginx's 1m default.
client_max_body_size 64m;

# Same-origin GET requests from the SPA don't include Origin, but
# msgops-api CORS rejects origin-less requests in production.
# Synthesize Origin from $scheme (never empty) + $http_host (includes port).
Expand All @@ -41,7 +47,9 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 60s;
# Reconcile-session creation parses + matches + persists a whole CSV
# export (350k+ contacts) in one request; 60s cut it off mid-flight.
proxy_read_timeout 300s;
}

# /bms/* is the public BMS surface served by msgops-api (NOT the SPA):
Expand Down
1,008 changes: 842 additions & 166 deletions apps/frontend-react/src/features/super-admin/accounts/reconcile-emails-card.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<ReconcilePreview> {
const { data } = await apiClient.post<ReconcilePreview>(`/imports/${jobId}/reconcile/preview`, { csv });
async getSession(jobId: string): Promise<ReconcileSessionProgress | null> {
try {
const { data } = await apiClient.get<ReconcileSessionProgress>(`/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<ReconcileSessionProgress> {
// Parsing + matching + persisting 350k contacts takes a while — disable
// the client timeout and let the server/nginx budget govern.
const { data } = await apiClient.post<ReconcileSessionProgress>(`/imports/${jobId}/reconcile/session`, { csv, ignoreColumns }, { timeout: 0 });
return data;
},

async ambiguousPage(jobId: string, offset: number, limit: number, q?: string): Promise<AmbiguousPage> {
const { data } = await apiClient.get<AmbiguousPage>(`/imports/${jobId}/reconcile/session/ambiguous`, {
params: { offset, limit, ...(q ? { q } : {}) },
});
return data;
},

async itemsPage(jobId: string, query: ReconcileItemsQuery): Promise<ReconcileItemsPage> {
const { data } = await apiClient.get<ReconcileItemsPage>(`/imports/${jobId}/reconcile/session/items`, {
params: query,
});
return data;
},

async apply(jobId: string, csv: string, resolutions: ApplyResolution[]): Promise<ApplyResult> {
const { data } = await apiClient.post<ApplyResult>(`/imports/${jobId}/reconcile/apply`, { csv, resolutions });
async resolve(jobId: string, resolutions: ApplyResolution[]): Promise<ResolveBatchResult> {
const { data } = await apiClient.post<ResolveBatchResult>(`/imports/${jobId}/reconcile/session/resolve`, { resolutions });
return data;
},

async applyAuto(jobId: string, limit: number): Promise<ApplyAutoChunkResult> {
const { data } = await apiClient.post<ApplyAutoChunkResult>(`/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<BulkResolveResult> {
const { data } = await apiClient.post<BulkResolveResult>(`/imports/${jobId}/reconcile/session/bulk-resolve`, payload, { timeout: 0 });
return data;
},

async deleteSession(jobId: string): Promise<void> {
await apiClient.delete(`/imports/${jobId}/reconcile/session`);
},
};
71 changes: 70 additions & 1 deletion apps/frontend-react/src/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
71 changes: 70 additions & 1 deletion apps/frontend-react/src/locales/es-ES.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
},
Expand Down
Loading