// ============ AcademIA8 · Consola — Módulo Campañas · CAPA DE API (REAL) ============ // Adaptador contra el backend a8mail (AWS · SES). TODO el acceso a datos vive acá. // Las FIRMAS de cada función se mantienen idénticas al mock: el resto del front no // distingue mock de real. La traducción de claves y los mapeos que no son 1:1 viven // exclusivamente en este archivo (ver README de cableado: console/CABLEADO-BACKEND.md). // // Fuente de verdad: docs/API_CONTRACT.md del repo majul-ai/academia8-ses. // Base URL y API key: se configuran fuera del código versionado (ver getBaseUrl/getApiKey). // // Contrato (rutas admin, header x-api-key): // POST /admin/contacts/import -> importarContactos() // GET /admin/contacts/summary -> resumenContactos() // GET /admin/contacts -> listarContactos() (hidrata /{id}) // GET /admin/contacts/{id} -> (hidratación tags+estado) // PATCH /admin/contacts/{id}/tags -> actualizarContacto() // DELETE /admin/contacts/{id} -> eliminarContacto() // GET /admin/tags -> listarEtiquetas() // POST /admin/campaigns -> crearCampania() // GET /admin/campaigns -> listarCampanias() (pagina todo) // GET /admin/campaigns/{id} -> obtenerCampania() // PATCH /admin/campaigns/{id} -> actualizarCampania() // DELETE /admin/campaigns/{id} -> eliminarCampania() // GET /admin/campaigns/{id}/preview -> previewCampania() / contarDestinatarios() // POST /admin/campaigns/{id}/test -> enviarPrueba() // POST /admin/campaigns/{id}/send -> enviarAhora() / programarCampania() // POST /admin/campaigns/{id}/cancel -> cancelarProgramacion() // GET /admin/config/sender -> obtenerConfig() // PUT /admin/config/sender -> guardarRemitente() (function(){ // --------------------------------------------------------------------------- // Config de conexión — NO se hardcodea la key en el código versionado. // 1) window.A8MAIL_CONFIG (definido en console/campaigns-config.local.js, ignorado por git) // 2) sessionStorage 'a8mail_api_key' (persiste durante la sesión del navegador) // 3) prompt() la primera vez y se guarda en sessionStorage // La Base URL NO es secreta (está en el contrato); la key la provee Nahir (RUNBOOK §4). // --------------------------------------------------------------------------- const DEFAULT_BASE = 'https://61zemntvb5.execute-api.us-east-1.amazonaws.com'; const KEY_LS = 'a8mail_api_key'; function getBaseUrl(){ const cfg = (typeof window!=='undefined' && window.A8MAIL_CONFIG) || {}; return (cfg.baseUrl || DEFAULT_BASE).replace(/\/+$/,''); } function getApiKey(){ const cfg = (typeof window!=='undefined' && window.A8MAIL_CONFIG) || {}; if (cfg.apiKey) return String(cfg.apiKey).trim(); let k = ''; try{ k = sessionStorage.getItem(KEY_LS) || ''; }catch(e){} if (!k){ k = (window.prompt('Pegá la API key de a8mail (te la da Nahir · RUNBOOK §4):') || '').trim(); if (k){ try{ sessionStorage.setItem(KEY_LS, k); }catch(e){} } } return k; } function clearApiKey(){ try{ sessionStorage.removeItem(KEY_LS); }catch(e){} } const delay = (ms)=> new Promise(r=>setTimeout(r, ms)); // ---------- fetch wrapper (auth + errores del contrato) ---------- // El stage tiene throttling (20 req/s, burst 40) y el contrato incorpora // 429 too_many_requests. Ante 429 reintentamos con backoff exponencial simple // (respetando Retry-After si viene). El 422 se eliminó del contrato (nunca se emitía). const MAX_429_RETRIES = 5; async function req(method, path, body){ const headers = { 'content-type':'application/json', 'x-api-key': getApiKey() }; const payload = body!=null ? JSON.stringify(body) : undefined; let attempt = 0; while (true){ let res; try{ res = await fetch(getBaseUrl()+path, { method, headers, body: payload }); }catch(e){ throw new Error('No se pudo conectar con el servidor. Revisá tu conexión a internet.'); } // 429: backoff exponencial simple y reintento (hasta MAX_429_RETRIES). if (res.status===429 && attempt front const ESTC_FROM = { active:'activo', unsubscribed:'dado_de_baja', bounced:'rebotado', complained:'dado_de_baja' }; const ESTCAMP_FROM = { draft:'borrador', scheduled:'programada', sending:'enviando', sent:'enviada', failed:'fallida' }; // labels que muestra el front (claves = estados en español) const ESTADO_CONTACTO = { activo:'Activo', dado_de_baja:'Dado de baja', rebotado:'Rebotado' }; const ESTADO_CAMPANIA = { borrador:'Borrador', programada:'Programada', enviando:'Enviando', enviada:'Enviada', fallida:'Fallida' }; const DOMINIO_ENVIO = 'mail.academia8.com'; const MAX_PRUEBA = 5; const TODAY = new Date().toISOString().slice(0,10); // ---------- mapeos de forma ---------- function isoDate(s){ return (s||'').slice(0,10); } function isoMin(s){ if(!s) return null; const t=String(s); return t.length>=16 ? t.slice(0,16) : (t.slice(0,10)+'T00:00'); } function recipientsFromApi(r){ if (!r) return { modo:'todos', etiquetas:[] }; if (r.mode==='all') return { modo:'todos', etiquetas:[] }; return { modo:'etiquetas', etiquetas: r.tags || [] }; } // Convierte destinatarios del front al contrato. Devuelve null si es "por etiqueta" // sin ninguna etiqueta (para poder OMITIR recipients en un PATCH y no pisar lo guardado). function recipientsToApi(dest){ if (!dest) return null; if (dest.modo==='todos') return { mode:'all' }; const tags = (dest.etiquetas||[]).filter(Boolean); if (!tags.length) return null; return { mode:'tags', tags }; } function netFrom(counters, status){ if (!counters) return 0; if (status==='sent' && typeof counters.sent==='number') return counters.sent; const r = counters.recipients||0, s = counters.suppressedFiltered||0; return Math.max(0, r - s); } // Campaña backend -> campaña que consume el front. // extra.html : cuerpo HTML (solo lo trae el detalle, no la lista) // extra.remitente: se adjunta desde config/sender (el backend no guarda remitente por campaña) function campFromApi(m, extra){ extra = extra || {}; const estado = ESTCAMP_FROM[m.status] || 'borrador'; const counters = m.counters || null; const out = { id: m.campaignId, // El backend NO tiene "nombre interno"; usamos el asunto como nombre visible en la lista. nombre: m.subject || '(sin asunto)', asunto: m.subject || '', destinatarios: recipientsFromApi(m.recipients), html: (extra.html!=null) ? extra.html : (m.html!=null ? m.html : ''), estado, programadaPara: m.scheduleAt || null, enviadaEn: (estado==='enviada' || estado==='fallida') ? isoMin(m.updatedAt) : null, totalDestinatarios: netFrom(counters, m.status), excluidos: counters ? (counters.suppressedFiltered||0) : 0, // El backend no persiste a qué direcciones se mandó prueba. pruebasEnviadas: [], remitente: extra.remitente || null, creadoEn: isoDate(m.createdAt) || TODAY, actualizadoEn: isoDate(m.updatedAt) || TODAY, counters, // Cantidad de destinatarios con error (para badge/alerta). >0 también en un // envío 'enviada' con fallo PARCIAL (sent>0 y failed>0), que si no pasa inadvertido. fallidos: counters ? (counters.failed || 0) : 0, }; if (estado==='fallida'){ out.error = failureReasonText(m.failureReason) || ('El envío falló.' + (counters && counters.failed ? ` ${counters.failed} destinatario${counters.failed===1?'':'s'} con error.` : '') + ' Reintentá el envío; el sistema no reenvía a quienes ya recibieron.'); out.failureReason = m.failureReason || null; } if (m.warnings) out.warnings = m.warnings; return out; } // El detalle de una campaña fallida trae failureReason (string). Lo traducimos a un // mensaje accionable para el operador; si no lo reconocemos, mostramos el texto crudo. const FALLA_ES = { throttling: 'Amazon limitó el ritmo de envío (throttling). Esperá unos minutos y reintentá.', rate_exceeded: 'Amazon limitó el ritmo de envío (throttling). Esperá unos minutos y reintentá.', quota_exceeded: 'Se alcanzó el límite diario de envíos de Amazon. Reintentá más tarde.', daily_quota_exceeded: 'Se alcanzó el límite diario de envíos de Amazon. Reintentá más tarde.', account_paused: 'El envío está pausado en la cuenta de Amazon. Contactá a soporte antes de reintentar.', account_suspended: 'La cuenta de envío de Amazon está suspendida. Contactá a soporte.', message_rejected: 'Amazon rechazó el contenido del correo. Revisá el HTML y reintentá.', sender_not_verified: 'El remitente no está verificado en Amazon. Verificalo y reintentá.', configuration_error: 'Error de configuración del envío. Avisá al equipo técnico antes de reintentar.', internal_error: 'Hubo un error interno durante el envío. Reintentá en unos minutos.', }; function failureReasonText(reason){ if (!reason) return null; const key = String(reason).trim().toLowerCase().replace(/[^a-z0-9]+/g,'_').replace(/^_+|_+$/g,''); if (FALLA_ES[key]) return FALLA_ES[key] + ' El sistema no reenvía a quienes ya recibieron.'; const r = String(reason).toLowerCase(); let base = null; if (r.includes('throttl') || r.includes('rate')) base = FALLA_ES.throttling; else if (r.includes('quota') || r.includes('limit')) base = FALLA_ES.quota_exceeded; else if (r.includes('paus')) base = FALLA_ES.account_paused; else if (r.includes('suspend')) base = FALLA_ES.account_suspended; else if (r.includes('reject')) base = FALLA_ES.message_rejected; else if (r.includes('verif')) base = FALLA_ES.sender_not_verified; if (base) return base + ' El sistema no reenvía a quienes ya recibieron.'; // Motivo desconocido: mostramos el texto crudo del backend, envuelto. return 'El envío falló: ' + String(reason).trim() + '. Reintentá el envío; el sistema no reenvía a quienes ya recibieron.'; } // Advertencia informativa (no bloqueante) -> texto legible para el operador. function warningText(w){ if (w && w.message) return w.message; if (w && w.code==='foreign_unsubscribe') return 'El HTML trae un link de baja de otro sistema (ej. Mailchimp). No suprime en Academia 8; igual se agrega el link de baja propio.'; return 'Advertencia en el contenido.'; } // caché liviana de la config de remitente (para adjuntar remitente a las campañas sin refetch) let _cfgCache = null; async function obtenerConfigRaw(){ const s = await req('GET','/admin/config/sender'); _cfgCache = { remitente: { nombre: s.fromName || 'Academia 8', email: s.fromAddress || ('cursos@'+DOMINIO_ENVIO) }, replyTo: s.replyTo || null, sandbox: !!s.sandbox, verificadas: s.verifiedIdentities || [], }; return _cfgCache; } // ================= API PÚBLICA ================= const CampAPI = { ESTADO_CONTACTO, ESTADO_CAMPANIA, TODAY, DOMINIO_ENVIO, MAX_PRUEBA, // ---------------- config / remitente ---------------- async obtenerConfig(){ return await obtenerConfigRaw(); }, async guardarRemitente(remitente){ const s = await req('PUT','/admin/config/sender', { fromName: remitente.nombre, fromAddress: remitente.email }); const out = { nombre: (s&&s.fromName)||remitente.nombre, email: (s&&s.fromAddress)||remitente.email }; if (_cfgCache) _cfgCache.remitente = out; return out; }, // ---------------- etiquetas ---------------- // El backend no tiene entidad "etiqueta": las etiquetas son strings que nacen al usarse. async listarEtiquetas(){ const r = await req('GET','/admin/tags'); return (r.tags||[]).map(t=>({ id: t.tag, nombre: t.tag, count: t.count })); }, // No pega a ningún endpoint: nace al usarse (import o campaña). Devuelve etiqueta local. async crearEtiqueta(nombre){ const n=(nombre||'').trim(); return { id:n, nombre:n }; }, // ---------------- contactos ---------------- // La lista del backend ahora trae tags (array) y status en cada ítem: una sola // request por página (se eliminó la hidratación N+1 con GET /{id}). async listarContactos({ etiqueta='todas', cursor=null, pageSize=25 }={}){ const qs = new URLSearchParams(); qs.set('limit', String(pageSize)); if (etiqueta && etiqueta!=='todas') qs.set('tag', etiqueta); if (cursor) qs.set('cursor', cursor); const res = await req('GET','/admin/contacts?'+qs.toString()); const items = (res.items||[]).map(it=>({ id: it.contactId, email: it.email, nombre: it.nombre||'', apellido: it.apellido||'', etiquetas: it.tags||[], estado: ESTC_FROM[it.status]||'activo', })); // prevCursor no existe en el backend: el componente mantiene la pila (ver ContactsView). return { items, nextCursor: res.nextCursor || null, prevCursor:null, count: items.length, pageSize }; }, async resumenContactos(){ const s = await req('GET','/admin/contacts/summary'); const activos = s.active||0; const baja = (s.unsubscribed||0) + (s.complained||0); const rebote = s.bounced||0; const total = (typeof s.total==='number') ? s.total : (activos+baja+rebote); return { total, activos, baja, rebote }; }, // Solo soporta cambio de etiquetas (único caso que usa el front). Diff local -> add/remove. async actualizarContacto(id, patch){ if (patch && patch.etiquetas){ const d = await req('GET','/admin/contacts/'+encodeURIComponent(id)); const old = d.tags || []; const next = patch.etiquetas || []; const add = next.filter(t=>!old.includes(t)); const remove = old.filter(t=>!next.includes(t)); if (add.length || remove.length){ const body = {}; if (add.length) body.add = add; if (remove.length) body.remove = remove; await req('PATCH','/admin/contacts/'+encodeURIComponent(id)+'/tags', body); } const d2 = await req('GET','/admin/contacts/'+encodeURIComponent(id)); return { id:d2.contactId, email:d2.email, nombre:d2.nombre||'', apellido:d2.apellido||'', etiquetas:d2.tags||[], estado: ESTC_FROM[d2.status]||'activo' }; } return { id, ...(patch||{}) }; }, async eliminarContacto(id){ await req('DELETE','/admin/contacts/'+encodeURIComponent(id)); return { ok:true }; }, // importar: rows JSON + tags del lote. El backend exige >=1 etiqueta. async importarContactos(filas, etiquetaIds){ const tags = (etiquetaIds||[]).filter(Boolean); if (!tags.length){ const e=new Error('Elegí al menos una etiqueta para el lote (el backend la exige para importar).'); e.code='needs_tag'; throw e; } const rows = (filas||[]).map(f=>({ email:f.email, nombre:f.nombre||'', apellido:f.apellido||'' })); const res = await req('POST','/admin/contacts/import', { tags, rows }); return { creados: res.created||0, actualizados: res.updated||0, skipped: res.skipped||[], totalRows: res.totalRows }; }, // ---------------- destinatarios ---------------- // Antes de guardar la campaña no hay endpoint para contar un set arbitrario: // - "todos": usamos el resumen (active ~ net). // - "por etiqueta": sumamos los counts de /admin/tags -> APROXIMADO (no dedup ni supresión). // El conteo EXACTO (net) sale de previewCampania(id), ya guardada la campaña. async contarDestinatarios(modo, etiquetaIds){ if (modo==='todos'){ const s = await req('GET','/admin/contacts/summary'); const activos = s.active||0; const total = (typeof s.total==='number') ? s.total : activos; return { total: activos, excluidos: total-activos, alcanzables: total, aprox:false }; } const t = await req('GET','/admin/tags'); const map = {}; (t.tags||[]).forEach(x=>map[x.tag]=x.count); const total = (etiquetaIds||[]).reduce((a,id)=> a + (map[id]||0), 0); return { total, excluidos:0, alcanzables: total, aprox:true }; }, async previewCampania(id){ const p = await req('GET','/admin/campaigns/'+encodeURIComponent(id)+'/preview'); return { total: (typeof p.net==='number')?p.net:(p.recipients||0), excluidos: p.suppressedFiltered||0, alcanzables: p.recipients||0, aprox:false }; }, // ---------------- campañas ---------------- // El front espera un array completo; el backend pagina. Recorremos todas las páginas. async listarCampanias(){ let out = [], cursor = null, guard = 0; do{ const qs = new URLSearchParams(); qs.set('limit','200'); if (cursor) qs.set('cursor', cursor); const res = await req('GET','/admin/campaigns?'+qs.toString()); out = out.concat((res.items||[]).map(m=>campFromApi(m))); cursor = res.nextCursor || null; guard++; } while (cursor && guard<50); return out; }, async obtenerCampania(id){ let remitente = null; try{ const cfg = _cfgCache || await obtenerConfigRaw(); remitente = cfg.remitente; }catch(e){} const m = await req('GET','/admin/campaigns/'+encodeURIComponent(id)); return campFromApi(m, { remitente }); }, async crearCampania(data){ const rec = recipientsToApi(data.destinatarios); if (!rec){ const e=new Error('Elegí al menos una etiqueta para poder guardar esta campaña.'); e.code='no_recipients'; throw e; } const subject = (data.asunto || data.nombre || '').trim() || '(sin asunto)'; const m = await req('POST','/admin/campaigns', { subject, html: data.html||'', recipients: rec }); return campFromApi(m); }, async actualizarCampania(id, data){ const body = {}; const subject = (data.asunto || data.nombre || '').trim(); if (subject) body.subject = subject; if ('html' in data) body.html = data.html || ''; const rec = recipientsToApi(data.destinatarios); if (rec) body.recipients = rec; // si es "etiqueta sin tags", se OMITE (no se toca en el server) const m = await req('PATCH','/admin/campaigns/'+encodeURIComponent(id), body); return campFromApi(m); }, async eliminarCampania(id){ await req('DELETE','/admin/campaigns/'+encodeURIComponent(id)); return { ok:true }; }, // No hay endpoint de duplicado: se crea una campaña nueva con el asunto+HTML+destinatarios del origen. async duplicarCampania(id){ const src = await req('GET','/admin/campaigns/'+encodeURIComponent(id)); const rec = (src.recipients && src.recipients.mode==='all') ? { mode:'all' } : { mode:'tags', tags:(src.recipients&&src.recipients.tags)||[] }; const body = { subject: (src.subject||'(sin asunto)')+' (copia)', html: src.html||'', recipients: rec }; const m = await req('POST','/admin/campaigns', body); return campFromApi(m); }, // ---------------- prueba / envío / programación ---------------- async enviarPrueba(idOrData, direcciones){ const id = (typeof idOrData==='string') ? idOrData : (idOrData && idOrData.id); const emails = (direcciones||[]).map(e=>e.trim()).filter(Boolean); if (!emails.length) throw new Error('Ingresá al menos una dirección'); if (emails.length>MAX_PRUEBA) throw new Error(`El máximo es ${MAX_PRUEBA} direcciones de prueba`); const res = await req('POST','/admin/campaigns/'+encodeURIComponent(id)+'/test', { emails }); const resultados = res.results || []; return { ok: resultados.every(r=>r.ok), enviadas: resultados.filter(r=>r.ok).map(r=>r.email), resultados }; }, async enviarAhora(id){ const res = await req('POST','/admin/campaigns/'+encodeURIComponent(id)+'/send', {}); return { id, estado: ESTCAMP_FROM[res && res.status] || 'enviando' }; }, async programarCampania(id, isoLocal){ // El front manda 'YYYY-MM-DDTHH:MM' (hora de Córdoba). El contrato pide segundos. const scheduleAt = /T\d\d:\d\d:\d\d$/.test(isoLocal) ? isoLocal : (isoLocal+':00'); const res = await req('POST','/admin/campaigns/'+encodeURIComponent(id)+'/send', { scheduleAt }); return { id, estado: ESTCAMP_FROM[res && res.status] || 'programada', programadaPara: (res && res.scheduleAt) || scheduleAt }; }, async cancelarProgramacion(id){ const res = await req('POST','/admin/campaigns/'+encodeURIComponent(id)+'/cancel', {}); return { id, estado: ESTCAMP_FROM[res && res.status] || 'borrador' }; }, // El fin del envío lo determina el backend. Polling suave del detalle mientras esté "enviando". async pollCampania(id, { onTick, intervalMs=3000, maxTries=20 }={}){ for (let i=0;i