import { DatabaseSync } from 'node:sqlite'; import fs from 'node:fs'; import path from 'node:path'; import { paths } from './env.mjs'; import { segmentLabel } from './scoring.mjs'; import { cleanText } from './lead-engine.mjs'; import { hashPassword } from './auth.mjs'; import { canonicalPhoneKey, canonicalPhoneVariants, equivalentPhoneNumbers, normalizePhone } from './phone.mjs'; const MIGRATIONS = [ // v1 — esquema inicial ` CREATE TABLE settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE municipios ( codigo_ibge TEXT PRIMARY KEY, codigo_tom TEXT, nome TEXT NOT NULL, nome_clean TEXT NOT NULL, uf TEXT NOT NULL ); CREATE INDEX idx_municipios_uf ON municipios(uf, nome_clean); CREATE INDEX idx_municipios_tom ON municipios(codigo_tom); CREATE TABLE cnaes ( codigo TEXT PRIMARY KEY, descricao TEXT NOT NULL ); CREATE TABLE segments ( id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, label TEXT NOT NULL, interest TEXT, queries_json TEXT NOT NULL DEFAULT '[]', cnae_codes_json TEXT NOT NULL DEFAULT '[]', overpass_tags_json TEXT NOT NULL DEFAULT '[]', pains_json TEXT NOT NULL DEFAULT '[]', active INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE geo_cache ( key TEXT PRIMARY KEY, south REAL, west REAL, north REAL, east REAL, provider TEXT NOT NULL, resolved_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE capture_runs ( id INTEGER PRIMARY KEY, job_id TEXT UNIQUE, params_json TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', progress_json TEXT, raw_count INTEGER NOT NULL DEFAULT 0, new_count INTEGER NOT NULL DEFAULT 0, dup_count INTEGER NOT NULL DEFAULT 0, error TEXT, pid INTEGER, started_at TEXT, finished_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE extractions ( id INTEGER PRIMARY KEY, job_id TEXT UNIQUE, uf TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', csv_path TEXT, total_rows INTEGER, with_phone INTEGER, municipios_count INTEGER, progress_json TEXT, error TEXT, pid INTEGER, started_at TEXT, finished_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE leads ( id INTEGER PRIMARY KEY, lead_uid TEXT, dedup_key TEXT NOT NULL UNIQUE, empresa TEXT NOT NULL, legal_name TEXT DEFAULT '', whatsapp TEXT DEFAULT '', ddd TEXT DEFAULT '', email TEXT DEFAULT '', cidade TEXT DEFAULT '', uf TEXT DEFAULT '', segmento TEXT DEFAULT '', segmento_label TEXT DEFAULT '', interesse TEXT DEFAULT '', cnpj TEXT DEFAULT '', cnae TEXT DEFAULT '', endereco TEXT DEFAULT '', website TEXT DEFAULT '', pains_json TEXT NOT NULL DEFAULT '[]', origem TEXT NOT NULL DEFAULT '', telefone_valido INTEGER NOT NULL DEFAULT 0, cnpj_valido INTEGER NOT NULL DEFAULT 0, empresa_valida INTEGER NOT NULL DEFAULT 0, score INTEGER NOT NULL DEFAULT 0, classificacao TEXT NOT NULL DEFAULT 'ruim', motivo_classificacao TEXT DEFAULT '', capture_run_id INTEGER REFERENCES capture_runs(id), captured_at TEXT, exported_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT ); CREATE INDEX idx_leads_uf_cidade ON leads(uf, cidade); CREATE INDEX idx_leads_class_score ON leads(classificacao, score DESC); CREATE INDEX idx_leads_segmento ON leads(segmento); CREATE INDEX idx_leads_whatsapp ON leads(whatsapp); CREATE INDEX idx_leads_cnpj ON leads(cnpj); CREATE INDEX idx_leads_exported ON leads(exported_at); CREATE INDEX idx_leads_origem ON leads(origem); CREATE TABLE exports ( id INTEGER PRIMARY KEY, job_id TEXT UNIQUE, tipo TEXT NOT NULL, filtro_json TEXT NOT NULL DEFAULT '{}', params_json TEXT NOT NULL DEFAULT '{}', arquivos_json TEXT NOT NULL DEFAULT '[]', total_leads INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'pending', error TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), finished_at TEXT ); CREATE TABLE export_leads ( export_id INTEGER NOT NULL REFERENCES exports(id) ON DELETE CASCADE, lead_id INTEGER NOT NULL REFERENCES leads(id), PRIMARY KEY (export_id, lead_id) ) WITHOUT ROWID; CREATE INDEX idx_export_leads_lead ON export_leads(lead_id); `, // v2 — score de qualidade dos dados + score comercial (aderencia de nicho, // website, e-mail) + score geral (60% comercial + 40% qualidade), prioridade // comercial (1-4, derivada da classificacao) e produto recomendado por nicho. ` ALTER TABLE segments ADD COLUMN affinity TEXT NOT NULL DEFAULT 'media'; ALTER TABLE segments ADD COLUMN products_json TEXT NOT NULL DEFAULT '[]'; ALTER TABLE leads ADD COLUMN score_qualidade INTEGER NOT NULL DEFAULT 0; ALTER TABLE leads ADD COLUMN score_comercial INTEGER NOT NULL DEFAULT 0; ALTER TABLE leads ADD COLUMN prioridade INTEGER NOT NULL DEFAULT 4; ALTER TABLE leads ADD COLUMN produto_principal TEXT DEFAULT ''; ALTER TABLE leads ADD COLUMN produtos_json TEXT NOT NULL DEFAULT '[]'; CREATE INDEX idx_leads_prioridade ON leads(prioridade, score DESC); CREATE INDEX idx_leads_produto ON leads(produto_principal); `, // v3 — login: usuarios do app (senha com hash, papel admin/comercial). ` CREATE TABLE users ( id INTEGER PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'comercial', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); `, // v4 — automacao de envio WhatsApp legada: um template de mensagem por // produto/servico, e o historico de disparos (o que foi mandado, pra quem, // quando) usado tanto pra nao repetir envio quanto pra decidir quando tentar // o proximo produto recomendado. ` CREATE TABLE message_templates ( id INTEGER PRIMARY KEY, produto TEXT NOT NULL UNIQUE, mensagem TEXT NOT NULL, ativo INTEGER NOT NULL DEFAULT 1, updated_at TEXT ); CREATE TABLE outreach_sends ( id INTEGER PRIMARY KEY, lead_id INTEGER NOT NULL REFERENCES leads(id), produto TEXT NOT NULL, mensagem_enviada TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'enviado', erro TEXT, zpro_ticket_id TEXT, sent_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_outreach_lead_produto ON outreach_sends(lead_id, produto); CREATE INDEX idx_outreach_sent_at ON outreach_sends(sent_at); `, // v5 — canal de envio por template: 'nao_oficial' (texto livre, bulkIndividual, // conexao local) ou 'oficial' (integracao legada, exige template // ja aprovado no Meta Business Manager — nao da pra criar/aprovar por aqui). ` ALTER TABLE message_templates ADD COLUMN canal TEXT NOT NULL DEFAULT 'nao_oficial'; ALTER TABLE message_templates ADD COLUMN template_waba_nome TEXT NOT NULL DEFAULT ''; ALTER TABLE message_templates ADD COLUMN template_waba_idioma TEXT NOT NULL DEFAULT 'pt_BR'; ALTER TABLE outreach_sends ADD COLUMN canal TEXT NOT NULL DEFAULT 'nao_oficial'; `, // v6 — envio manual avulso (mandar mensagem pra um numero especifico, sem // estar ligado a um lead da base). lead_id precisa virar opcional (SQLite // nao remove NOT NULL com ALTER TABLE direto, por isso o rebuild da tabela) // e numero/origem guardam pra quem foi e se foi automatico ou manual. ` CREATE TABLE outreach_sends_v6 ( id INTEGER PRIMARY KEY, lead_id INTEGER REFERENCES leads(id), produto TEXT NOT NULL DEFAULT '', numero TEXT, mensagem_enviada TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'enviado', erro TEXT, zpro_ticket_id TEXT, canal TEXT NOT NULL DEFAULT 'nao_oficial', origem TEXT NOT NULL DEFAULT 'automatico', sent_at TEXT NOT NULL DEFAULT (datetime('now')) ); INSERT INTO outreach_sends_v6 (id, lead_id, produto, mensagem_enviada, status, erro, zpro_ticket_id, canal, sent_at) SELECT id, lead_id, produto, mensagem_enviada, status, erro, zpro_ticket_id, canal, sent_at FROM outreach_sends; DROP TABLE outreach_sends; ALTER TABLE outreach_sends_v6 RENAME TO outreach_sends; CREATE INDEX idx_outreach_lead_produto ON outreach_sends(lead_id, produto); CREATE INDEX idx_outreach_sent_at ON outreach_sends(sent_at); `, // v7 — variaveis dos templates legados automaticos por produto. Cada item pode // usar placeholders resolvidos por lead: {empresa}, {produto}, {cidade}, etc. ` ALTER TABLE message_templates ADD COLUMN template_waba_variaveis_json TEXT NOT NULL DEFAULT '[]'; `, // v8 - esquema pessoal: opt-out persistente e identificador agnostico do provedor. ` CREATE TABLE outreach_opt_outs ( numero TEXT PRIMARY KEY, motivo TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE outreach_sends_v8 ( id INTEGER PRIMARY KEY, lead_id INTEGER REFERENCES leads(id), produto TEXT NOT NULL DEFAULT '', numero TEXT, mensagem_enviada TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'enviado', erro TEXT, provider_message_id TEXT, canal TEXT NOT NULL DEFAULT 'local', origem TEXT NOT NULL DEFAULT 'automatico', sent_at TEXT NOT NULL DEFAULT (datetime('now')) ); INSERT INTO outreach_sends_v8 (id, lead_id, produto, numero, mensagem_enviada, status, erro, provider_message_id, canal, origem, sent_at) SELECT id, lead_id, produto, numero, mensagem_enviada, status, erro, zpro_ticket_id, 'local', origem, sent_at FROM outreach_sends; DROP TABLE outreach_sends; ALTER TABLE outreach_sends_v8 RENAME TO outreach_sends; CREATE INDEX idx_outreach_lead_produto ON outreach_sends(lead_id, produto); CREATE INDEX idx_outreach_sent_at ON outreach_sends(sent_at); DELETE FROM settings WHERE key LIKE 'zpro.%' OR key LIKE 'gchat.%'; `, // v9 - remove metadados corporativos legados e torna a reserva automatica unica. ` CREATE TABLE message_templates_v9 ( id INTEGER PRIMARY KEY, produto TEXT NOT NULL UNIQUE, mensagem TEXT NOT NULL, ativo INTEGER NOT NULL DEFAULT 1, updated_at TEXT ); INSERT INTO message_templates_v9 (id, produto, mensagem, ativo, updated_at) SELECT id, produto, mensagem, ativo, updated_at FROM message_templates; DROP TABLE message_templates; ALTER TABLE message_templates_v9 RENAME TO message_templates; UPDATE outreach_sends SET status = 'erro', erro = COALESCE(erro, 'duplicidade legada desativada') WHERE origem = 'automatico' AND status IN ('pendente', 'enviado') AND id NOT IN ( SELECT MAX(id) FROM outreach_sends WHERE origem = 'automatico' AND status IN ('pendente', 'enviado') GROUP BY lead_id, produto ); CREATE UNIQUE INDEX idx_outreach_automatico_unico ON outreach_sends(lead_id, produto) WHERE origem = 'automatico' AND status IN ('pendente', 'enviado') AND lead_id IS NOT NULL; `, // v10 - envios sem confirmacao apos encerramento permanecem deduplicados. ` UPDATE outreach_sends SET status = 'desconhecido', erro = CASE WHEN erro IS NULL OR erro = '' THEN 'processo encerrou sem confirmacao de entrega' ELSE erro || '; processo encerrou sem confirmacao de entrega' END WHERE status = 'pendente'; DROP INDEX idx_outreach_automatico_unico; CREATE UNIQUE INDEX idx_outreach_automatico_unico ON outreach_sends(lead_id, produto) WHERE origem = 'automatico' AND status IN ('pendente', 'enviado', 'desconhecido') AND lead_id IS NOT NULL; `, // v11 - leases persistentes para distinguir jobs vivos de orfaos apos restart. ` ALTER TABLE capture_runs ADD COLUMN owner_token TEXT; ALTER TABLE capture_runs ADD COLUMN heartbeat_at TEXT; ALTER TABLE extractions ADD COLUMN owner_token TEXT; ALTER TABLE extractions ADD COLUMN heartbeat_at TEXT; ALTER TABLE exports ADD COLUMN pid INTEGER; ALTER TABLE exports ADD COLUMN owner_token TEXT; ALTER TABLE exports ADD COLUMN heartbeat_at TEXT; CREATE INDEX idx_capture_runs_status ON capture_runs(status); CREATE INDEX idx_extractions_status ON extractions(status); CREATE INDEX idx_exports_status ON exports(status); `, // v12 - exclusao mutua atomica por tipo de job pesado. ` UPDATE capture_runs SET status = 'error', error = COALESCE(error, 'job ativo duplicado normalizado na migracao v12'), finished_at = COALESCE(finished_at, datetime('now')) WHERE status IN ('pending', 'running') AND id <> (SELECT id FROM capture_runs WHERE status IN ('pending', 'running') ORDER BY id DESC LIMIT 1); UPDATE extractions SET status = 'error', error = COALESCE(error, 'job ativo duplicado normalizado na migracao v12'), finished_at = COALESCE(finished_at, datetime('now')) WHERE status IN ('pending', 'running') AND id <> (SELECT id FROM extractions WHERE status IN ('pending', 'running') ORDER BY id DESC LIMIT 1); UPDATE exports SET status = 'error', error = COALESCE(error, 'job ativo duplicado normalizado na migracao v12'), finished_at = COALESCE(finished_at, datetime('now')) WHERE status IN ('pending', 'running') AND id <> (SELECT id FROM exports WHERE status IN ('pending', 'running') ORDER BY id DESC LIMIT 1); CREATE UNIQUE INDEX idx_capture_runs_single_active ON capture_runs((1)) WHERE status IN ('pending', 'running'); CREATE UNIQUE INDEX idx_extractions_single_active ON extractions((1)) WHERE status IN ('pending', 'running'); CREATE UNIQUE INDEX idx_exports_single_active ON exports((1)) WHERE status IN ('pending', 'running'); `, // v13 - separa aceite local dos ACKs reais do WhatsApp. ` ALTER TABLE outreach_sends ADD COLUMN accepted_at TEXT; ALTER TABLE outreach_sends ADD COLUMN server_ack_at TEXT; ALTER TABLE outreach_sends ADD COLUMN delivered_at TEXT; ALTER TABLE outreach_sends ADD COLUMN read_at TEXT; UPDATE outreach_sends SET status = 'desconhecido', erro = CASE WHEN erro IS NULL OR erro = '' THEN 'registro anterior sem confirmacao do servidor WhatsApp' ELSE erro || '; registro anterior sem confirmacao do servidor WhatsApp' END WHERE status = 'enviado'; DROP INDEX idx_outreach_automatico_unico; CREATE UNIQUE INDEX idx_outreach_automatico_unico ON outreach_sends(lead_id, produto) WHERE origem = 'automatico' AND status IN ('pendente', 'aceito', 'enviado', 'entregue', 'lido', 'desconhecido') AND lead_id IS NOT NULL; `, // v14 - cache esparso de verificacao de numeros no WhatsApp. ` CREATE TABLE whatsapp_number_verifications ( numero TEXT PRIMARY KEY, status TEXT NOT NULL CHECK (status IN ('confirmado','nao_cadastrado','erro_temporario')), numero_resolvido TEXT, variante TEXT, erro TEXT, checked_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_whatsapp_verification_status ON whatsapp_number_verifications(status, checked_at DESC); `, // v15 - campanhas supervisionadas, fila auditavel e funil comercial manual. ` CREATE TABLE outreach_campaigns ( id INTEGER PRIMARY KEY, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, produto TEXT NOT NULL CHECK (produto IN ('GTI Vendas','G-Delivery')), status TEXT NOT NULL DEFAULT 'paused' CHECK (status IN ('draft','paused','ready','running','completed')), pause_reason TEXT, week_number INTEGER NOT NULL DEFAULT 1 CHECK (week_number BETWEEN 1 AND 4), approved_daily_limit INTEGER NOT NULL DEFAULT 10 CHECK (approved_daily_limit IN (10,12,15,18)), segments_json TEXT NOT NULL, windows_json TEXT NOT NULL DEFAULT '[{"start":"09:30","end":"11:30"},{"start":"14:00","end":"17:00"}]', interval_min_ms INTEGER NOT NULL DEFAULT 720000, interval_max_ms INTEGER NOT NULL DEFAULT 1500000, hourly_limit INTEGER NOT NULL DEFAULT 4, verification_multiplier REAL NOT NULL DEFAULT 1.5, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT ); CREATE TABLE outreach_campaign_variants ( id INTEGER PRIMARY KEY, campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id) ON DELETE CASCADE, code TEXT NOT NULL CHECK (code IN ('A','B','C')), mensagem TEXT NOT NULL, ativo INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(campaign_id, code), UNIQUE(id, campaign_id) ); CREATE TABLE outreach_campaign_queue ( id INTEGER PRIMARY KEY, campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id) ON DELETE CASCADE, lead_id INTEGER NOT NULL REFERENCES leads(id) ON DELETE CASCADE, variant_id INTEGER NOT NULL, numero TEXT NOT NULL, segmento TEXT NOT NULL, rendered_message TEXT NOT NULL, selection_reason TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('preview','approved','scheduled','reserved','sent','cancelled','unknown','error')), scheduled_at TEXT, outreach_send_id INTEGER REFERENCES outreach_sends(id), cancel_reason TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT, FOREIGN KEY (variant_id, campaign_id) REFERENCES outreach_campaign_variants(id, campaign_id) ); CREATE TRIGGER outreach_campaign_queue_payload_immutable BEFORE UPDATE OF variant_id, rendered_message, scheduled_at ON outreach_campaign_queue WHEN (NEW.status <> 'preview' AND (NEW.variant_id <> OLD.variant_id OR NEW.rendered_message IS NOT OLD.rendered_message)) OR (OLD.status <> 'preview' AND NEW.scheduled_at IS NOT OLD.scheduled_at) BEGIN SELECT RAISE(ABORT, 'variant_id, rendered_message e scheduled_at sao imutaveis apos preview'); END; CREATE TRIGGER outreach_campaign_queue_status_transition BEFORE UPDATE OF status ON outreach_campaign_queue WHEN NEW.status <> OLD.status AND NOT ( (OLD.status = 'preview' AND NEW.status IN ('approved','scheduled','cancelled')) OR (OLD.status = 'approved' AND NEW.status IN ('scheduled','cancelled')) OR (OLD.status = 'scheduled' AND NEW.status IN ('reserved','cancelled')) OR (OLD.status = 'reserved' AND NEW.status IN ('sent','unknown','error')) ) BEGIN SELECT RAISE(ABORT, 'transicao de status da fila invalida'); END; CREATE UNIQUE INDEX idx_campaign_queue_active_lead ON outreach_campaign_queue(campaign_id, lead_id) WHERE status IN ('preview','approved','scheduled','reserved','sent','unknown'); CREATE INDEX idx_campaign_queue_due ON outreach_campaign_queue(status, scheduled_at); CREATE TABLE outreach_inbound_replies ( id INTEGER PRIMARY KEY, provider_message_id TEXT NOT NULL UNIQUE, lead_id INTEGER REFERENCES leads(id) ON DELETE SET NULL, numero TEXT NOT NULL, received_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE outreach_lead_states ( lead_id INTEGER PRIMARY KEY REFERENCES leads(id) ON DELETE CASCADE, stage TEXT NOT NULL, first_reply_at TEXT, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE outreach_stage_events ( id INTEGER PRIMARY KEY, lead_id INTEGER NOT NULL REFERENCES leads(id) ON DELETE CASCADE, campaign_id INTEGER REFERENCES outreach_campaigns(id) ON DELETE SET NULL, stage TEXT NOT NULL, note TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE outreach_appointments ( id INTEGER PRIMARY KEY, lead_id INTEGER NOT NULL REFERENCES leads(id) ON DELETE CASCADE, campaign_id INTEGER REFERENCES outreach_campaigns(id) ON DELETE SET NULL, kind TEXT NOT NULL CHECK (kind IN ('demonstracao','teste_g_delivery')), participant_name TEXT, company_name TEXT, preferred_day TEXT, preferred_time TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); `, // v16 - limites imutaveis, dedupe por produto e indices da fila supervisionada. ` UPDATE outreach_campaigns SET verification_multiplier = 1.5 WHERE verification_multiplier <> 1.5; CREATE TRIGGER outreach_campaigns_verification_multiplier_insert BEFORE INSERT ON outreach_campaigns WHEN NEW.verification_multiplier <> 1.5 BEGIN SELECT RAISE(ABORT, 'verification_multiplier deve ser 1.5'); END; CREATE TRIGGER outreach_campaigns_verification_multiplier_update BEFORE UPDATE OF verification_multiplier ON outreach_campaigns WHEN NEW.verification_multiplier <> 1.5 BEGIN SELECT RAISE(ABORT, 'verification_multiplier deve ser 1.5'); END; CREATE UNIQUE INDEX IF NOT EXISTS idx_campaign_variants_id_campaign ON outreach_campaign_variants(id, campaign_id); DROP INDEX idx_campaign_queue_active_lead; DROP INDEX idx_campaign_queue_due; ALTER TABLE outreach_campaign_queue RENAME TO outreach_campaign_queue_v15; CREATE TABLE outreach_campaign_queue ( id INTEGER PRIMARY KEY, campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id) ON DELETE CASCADE, lead_id INTEGER NOT NULL REFERENCES leads(id) ON DELETE CASCADE, variant_id INTEGER NOT NULL, produto TEXT NOT NULL CHECK (produto IN ('GTI Vendas','G-Delivery')), numero TEXT NOT NULL, segmento TEXT NOT NULL, rendered_message TEXT NOT NULL, selection_reason TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('preview','approved','scheduled','reserved','sent','cancelled','unknown','error')), scheduled_at TEXT, outreach_send_id INTEGER REFERENCES outreach_sends(id), cancel_reason TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT, FOREIGN KEY (variant_id, campaign_id) REFERENCES outreach_campaign_variants(id, campaign_id) ); INSERT INTO outreach_campaign_queue (id, campaign_id, lead_id, variant_id, produto, numero, segmento, rendered_message, selection_reason, status, scheduled_at, outreach_send_id, cancel_reason, created_at, updated_at) SELECT old.id, old.campaign_id, old.lead_id, old.variant_id, campaigns.produto, old.numero, old.segmento, old.rendered_message, old.selection_reason, CASE WHEN old.status IN ('preview','approved','scheduled','reserved','sent','unknown') AND EXISTS ( SELECT 1 FROM outreach_campaign_queue_v15 earlier JOIN outreach_campaigns earlier_campaign ON earlier_campaign.id = earlier.campaign_id WHERE earlier.id < old.id AND earlier.lead_id = old.lead_id AND earlier_campaign.produto = campaigns.produto AND earlier.status IN ('preview','approved','scheduled','reserved','sent','unknown') ) THEN 'cancelled' ELSE old.status END, old.scheduled_at, old.outreach_send_id, CASE WHEN old.status IN ('preview','approved','scheduled','reserved','sent','unknown') AND EXISTS ( SELECT 1 FROM outreach_campaign_queue_v15 earlier JOIN outreach_campaigns earlier_campaign ON earlier_campaign.id = earlier.campaign_id WHERE earlier.id < old.id AND earlier.lead_id = old.lead_id AND earlier_campaign.produto = campaigns.produto AND earlier.status IN ('preview','approved','scheduled','reserved','sent','unknown') ) THEN 'deduplicated_product_migration' ELSE old.cancel_reason END, old.created_at, old.updated_at FROM outreach_campaign_queue_v15 old JOIN outreach_campaigns campaigns ON campaigns.id = old.campaign_id; DROP TABLE outreach_campaign_queue_v15; CREATE TRIGGER outreach_campaign_queue_payload_immutable BEFORE UPDATE OF variant_id, rendered_message, scheduled_at ON outreach_campaign_queue WHEN OLD.status <> 'preview' AND (NEW.variant_id <> OLD.variant_id OR NEW.rendered_message IS NOT OLD.rendered_message OR NEW.scheduled_at IS NOT OLD.scheduled_at) BEGIN SELECT RAISE(ABORT, 'variant_id, rendered_message e scheduled_at sao imutaveis apos preview'); END; CREATE TRIGGER outreach_campaign_queue_status_transition BEFORE UPDATE OF status ON outreach_campaign_queue WHEN NEW.status <> OLD.status AND NOT ( (OLD.status = 'preview' AND NEW.status IN ('approved','scheduled','cancelled')) OR (OLD.status = 'approved' AND NEW.status IN ('scheduled','cancelled')) OR (OLD.status = 'scheduled' AND NEW.status IN ('reserved','cancelled')) OR (OLD.status = 'reserved' AND NEW.status IN ('sent','unknown','error')) ) BEGIN SELECT RAISE(ABORT, 'transicao de status da fila invalida'); END; CREATE UNIQUE INDEX idx_campaign_queue_active_lead ON outreach_campaign_queue(campaign_id, lead_id) WHERE status IN ('preview','approved','scheduled','reserved','sent','unknown'); CREATE UNIQUE INDEX idx_campaign_queue_active_product_lead ON outreach_campaign_queue(produto, lead_id) WHERE status IN ('preview','approved','scheduled','reserved','sent','unknown'); CREATE INDEX idx_campaign_queue_due ON outreach_campaign_queue(status, scheduled_at); CREATE INDEX idx_outreach_inbound_lead ON outreach_inbound_replies(lead_id); CREATE INDEX idx_outreach_inbound_numero ON outreach_inbound_replies(numero); CREATE INDEX idx_leads_campaign_order ON leads(segmento, prioridade, score DESC, id ASC); `, // v17 - corrige o trigger imutavel em bases que ja aplicaram a v16. ` DROP TRIGGER outreach_campaign_queue_payload_immutable; CREATE TRIGGER outreach_campaign_queue_payload_immutable BEFORE UPDATE OF variant_id, rendered_message, scheduled_at ON outreach_campaign_queue WHEN (NEW.status <> 'preview' AND (NEW.variant_id <> OLD.variant_id OR NEW.rendered_message IS NOT OLD.rendered_message)) OR (OLD.status <> 'preview' AND NEW.scheduled_at IS NOT OLD.scheduled_at) BEGIN SELECT RAISE(ABORT, 'variant_id, rendered_message e scheduled_at sao imutaveis apos preview'); END; `, // v18 - permite reconciliar resultados terminais da fila com o historico do provedor. ` DROP TRIGGER outreach_campaign_queue_status_transition; CREATE TRIGGER outreach_campaign_queue_status_transition BEFORE UPDATE OF status ON outreach_campaign_queue WHEN NEW.status <> OLD.status AND NOT ( (OLD.status = 'preview' AND NEW.status IN ('approved','scheduled','cancelled')) OR (OLD.status = 'approved' AND NEW.status IN ('scheduled','cancelled')) OR (OLD.status = 'scheduled' AND NEW.status IN ('reserved','cancelled')) OR (OLD.status = 'reserved' AND NEW.status IN ('sent','unknown','error')) OR (OLD.status IN ('sent','unknown','error') AND NEW.status IN ('sent','unknown','error')) ) BEGIN SELECT RAISE(ABORT, 'transicao de status da fila invalida'); END; `, // v19 - numeros compartilhados por mais de um lead (ex.: contabilidade que // registra o proprio telefone para varios CNPJs de clientes) podem ser // liberados manualmente apos revisao, revertendo o bloqueio automatico. ` CREATE TABLE outreach_shared_number_overrides ( numero TEXT PRIMARY KEY, motivo TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); ` ]; export function openDb(dbPath = paths.db) { if (dbPath !== ':memory:') { fs.mkdirSync(path.dirname(dbPath), { recursive: true }); } const db = new DatabaseSync(dbPath); db.exec('PRAGMA journal_mode = WAL;'); db.exec('PRAGMA busy_timeout = 5000;'); db.exec('PRAGMA foreign_keys = ON;'); migrate(db); return db; } export function migrate(db) { db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')) );`); const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((row) => row.version)); for (let version = 1; version <= MIGRATIONS.length; version += 1) { if (applied.has(version)) continue; db.exec('BEGIN'); try { db.exec(MIGRATIONS[version - 1]); db.prepare('INSERT INTO schema_migrations (version) VALUES (?)').run(version); db.exec('COMMIT'); } catch (error) { db.exec('ROLLBACK'); throw error; } } } export function transaction(db, fn) { db.exec('BEGIN'); try { const result = fn(); db.exec('COMMIT'); return result; } catch (error) { db.exec('ROLLBACK'); throw error; } } export function isSqliteConstraintError(error) { return error?.errcode === 19 || (Number.isInteger(error?.errcode) && (error.errcode & 0xff) === 19) || /constraint/i.test(String(error?.code || '')) || /constraint/i.test(String(error?.message || '')); } const LEAD_COLUMNS = [ 'lead_uid', 'dedup_key', 'empresa', 'legal_name', 'whatsapp', 'ddd', 'email', 'cidade', 'uf', 'segmento', 'segmento_label', 'interesse', 'cnpj', 'cnae', 'endereco', 'website', 'pains_json', 'origem', 'telefone_valido', 'cnpj_valido', 'empresa_valida', 'score', 'classificacao', 'motivo_classificacao', 'score_qualidade', 'score_comercial', 'prioridade', 'produto_principal', 'produtos_json', 'capture_run_id', 'captured_at', 'exported_at' ]; // INSERT com dedup embutida: conflitos na dedup_key sao ignorados // (lead ja existe de captura/importacao anterior). export function createLeadInserter(db) { const placeholders = LEAD_COLUMNS.map((c) => `:${c}`).join(', '); const stmt = db.prepare( `INSERT INTO leads (${LEAD_COLUMNS.join(', ')}) VALUES (${placeholders}) ON CONFLICT(dedup_key) DO NOTHING` ); return (lead) => { const params = {}; for (const column of LEAD_COLUMNS) { const value = lead[column]; params[column] = value === undefined || value === null ? null : value; } if (params.empresa === null) params.empresa = ''; if (params.score_qualidade === null) params.score_qualidade = 0; if (params.score_comercial === null) params.score_comercial = 0; if (params.prioridade === null) params.prioridade = 4; if (params.produto_principal === null) params.produto_principal = ''; if (params.produtos_json === null) params.produtos_json = '[]'; if (params.dedup_key === null) throw new Error('lead sem dedup_key'); const result = stmt.run(params); return result.changes > 0 ? 'inserted' : 'duplicate'; }; } export function getSetting(db, key, fallback = null) { const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key); return row ? row.value : fallback; } export function setSetting(db, key, value) { db.prepare( 'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value' ).run(key, String(value)); } const DEFAULT_SETTINGS = { 'meta_diaria': '100', 'outreach.ativo': 'false', 'outreach.mensagensPorDia': '10', 'outreach.intervaloMinMs': '5000', 'outreach.intervaloMaxMs': '12000', 'outreach.diasEntreProdutos': '5', 'outreach.tentarProximoProduto': 'false', 'outreach.horaInicio': '08:00', 'outreach.horaFim': '18:00' }; export function seedSettings(db, extra = {}) { const entries = { ...DEFAULT_SETTINGS, ...extra }; transaction(db, () => { const stmt = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'); for (const [key, value] of Object.entries(entries)) { stmt.run(key, String(value)); } }); } // Upsert (nao so INSERT OR IGNORE): reaplicar o config.json com afinidade/produtos // ajustados deve atualizar os 57 nichos ja existentes, nao so inserir os novos. export function seedSegments(db, segments) { transaction(db, () => { const stmt = db.prepare( `INSERT INTO segments (name, label, interest, queries_json, cnae_codes_json, overpass_tags_json, pains_json, affinity, products_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(name) DO UPDATE SET label = excluded.label, interest = excluded.interest, queries_json = excluded.queries_json, cnae_codes_json = excluded.cnae_codes_json, overpass_tags_json = excluded.overpass_tags_json, pains_json = excluded.pains_json, affinity = excluded.affinity, products_json = excluded.products_json` ); for (const segment of segments) { stmt.run( segment.name, segmentLabel(segment.name), segment.interest || '', JSON.stringify(segment.queries || []), JSON.stringify(segment.cnaeCodes || []), JSON.stringify(segment.overpassTags || []), JSON.stringify(segment.pains || []), segment.affinity || 'media', JSON.stringify(segment.products || []) ); } }); } export function seedMunicipios(db, rows) { transaction(db, () => { const stmt = db.prepare( 'INSERT OR IGNORE INTO municipios (codigo_ibge, nome, nome_clean, uf) VALUES (?, ?, ?, ?)' ); for (const row of rows) { stmt.run(String(row.codigo_ibge), row.nome, cleanText(row.nome), row.uf); } }); } export function seedCnaes(db, rows) { transaction(db, () => { const stmt = db.prepare('INSERT OR IGNORE INTO cnaes (codigo, descricao) VALUES (?, ?)'); for (const row of rows) { stmt.run(String(row.codigo), row.descricao); } }); } export function seedGeoCache(db, cityAreas) { transaction(db, () => { const stmt = db.prepare( `INSERT OR IGNORE INTO geo_cache (key, south, west, north, east, provider) VALUES (?, ?, ?, ?, ?, 'config-legacy')` ); for (const area of cityAreas || []) { const [south, west, north, east] = area.bbox; stmt.run(`bbox:${area.state}:${cleanText(area.name)}`, south, west, north, east); } }); } // Zera a base comercial (leads, exportacoes, capturas e extracoes) mantendo // tabelas de referencia (segments, settings, municipios, cnaes) intactas. // Usado pelo botao "Limpar toda a base" da tela de Configuracoes. export function resetLeadsBase(db) { transaction(db, () => { db.exec('DELETE FROM export_leads'); db.exec('DELETE FROM exports'); db.exec('DELETE FROM outreach_campaign_queue'); db.exec('DELETE FROM outreach_inbound_replies'); db.exec('DELETE FROM outreach_appointments'); db.exec('DELETE FROM outreach_stage_events'); db.exec('DELETE FROM outreach_lead_states'); db.exec('DELETE FROM outreach_sends'); db.exec('DELETE FROM leads'); db.exec('DELETE FROM capture_runs'); db.exec('DELETE FROM extractions'); db.exec('DELETE FROM geo_cache'); }); db.exec('VACUUM'); } export function createUser(db, { username, passwordHash, role = 'comercial' }) { db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)').run(username, passwordHash, role); } export function findUserByUsername(db, username) { return db.prepare('SELECT * FROM users WHERE username = ?').get(username); } export function listUsers(db) { return db.prepare('SELECT id, username, role, created_at FROM users ORDER BY id').all(); } export function updateUserPassword(db, userId, passwordHash) { return db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, userId).changes > 0; } // Primeiro acesso: sem usuarios cadastrados, cria admin/admin — deve ser // trocado ou complementado com outros usuarios pela tela de Usuarios. export function ensureDefaultAdmin(db) { if (countRows(db, 'users') > 0) return; createUser(db, { username: 'admin', passwordHash: hashPassword('admin'), role: 'admin' }); } function countRows(db, table) { return db.prepare(`SELECT COUNT(*) AS total FROM ${table}`).get().total; } const MENSAGEM_PADRAO = 'Ola! Vi que a {empresa} pode se beneficiar de {produto}. Podemos conversar?'; // Um template por produto/servico (INSERT OR IGNORE: nao sobrescreve texto ja // editado pelo usuario na tela de Configuracoes ao reaplicar em boots seguintes). export function seedMessageTemplates(db, produtos) { transaction(db, () => { const stmt = db.prepare('INSERT OR IGNORE INTO message_templates (produto, mensagem) VALUES (?, ?)'); for (const produto of produtos) { if (!produto) continue; stmt.run(produto, MENSAGEM_PADRAO.replace('{produto}', produto)); } }); } export function listMessageTemplates(db) { return db.prepare('SELECT id, produto, mensagem, ativo, updated_at FROM message_templates ORDER BY produto').all(); } export function updateMessageTemplate(db, produto, { mensagem, ativo }) { const row = db.prepare('SELECT id, produto, mensagem, ativo, updated_at FROM message_templates WHERE produto = ?').get(produto); if (!row) return null; const novaMensagem = mensagem !== undefined ? mensagem : row.mensagem; const novoAtivo = ativo !== undefined ? (ativo ? 1 : 0) : row.ativo; db.prepare( `UPDATE message_templates SET mensagem = ?, ativo = ?, updated_at = datetime('now') WHERE produto = ?` ).run(novaMensagem, novoAtivo, produto); return db.prepare('SELECT id, produto, mensagem, ativo, updated_at FROM message_templates WHERE produto = ?').get(produto); } // Usa canonicalPhoneVariants (e nao equivalentPhoneNumbers): a relacao // precisa ser simetrica, senao um opt-out registrado numa forma do numero // (ex.: com o nono digito) pode nao bater contra a outra forma do mesmo // numero (sem o nono digito) quando o local nao comeca com 6-9 — deixando o // bloqueio de opt-out furavel dependendo de qual forma foi usada em cada // ponta. Mesmo raciocinio de leadsSharingNumber/addSharedNumberOverride. export function isOptedOut(db, numero) { const numbers = canonicalPhoneVariants(numero); if (!numbers.length) return false; const placeholders = numbers.map(() => '?').join(', '); return Boolean(db.prepare(`SELECT 1 FROM outreach_opt_outs WHERE numero IN (${placeholders})`).get(...numbers)); } export function listOptOuts(db) { return db.prepare('SELECT numero, motivo, created_at FROM outreach_opt_outs ORDER BY created_at DESC, numero').all(); } export function addOptOut(db, numero, motivo = '') { const numbers = canonicalPhoneVariants(numero); if (!numbers.length) throw new Error('numero obrigatorio'); return transaction(db, () => { const placeholders = numbers.map(() => '?').join(', '); db.prepare(`DELETE FROM outreach_opt_outs WHERE numero IN (${placeholders})`).run(...numbers); db.prepare('INSERT INTO outreach_opt_outs (numero, motivo) VALUES (?, ?)') .run(numbers[0], String(motivo || '').trim()); return db.prepare('SELECT numero, motivo, created_at FROM outreach_opt_outs WHERE numero = ?').get(numbers[0]); }); } export function removeOptOut(db, numero) { const numbers = canonicalPhoneVariants(numero); if (!numbers.length) return false; return transaction(db, () => { const placeholders = numbers.map(() => '?').join(', '); return db.prepare(`DELETE FROM outreach_opt_outs WHERE numero IN (${placeholders})`).run(...numbers).changes > 0; }); } // Deteccao de numero compartilhado usa canonicalPhoneVariants (e nao // equivalentPhoneNumbers): a relacao precisa ser simetrica para que os dois // lados de um par equivalente sejam bloqueados e aparecam no mesmo grupo de // revisao. export function leadsSharingNumber(db, numero) { const numbers = canonicalPhoneVariants(numero); if (!numbers.length) return []; const placeholders = numbers.map(() => '?').join(', '); return db.prepare(`SELECT id, empresa, cnpj FROM leads WHERE whatsapp IN (${placeholders})`).all(...numbers); } export function isNumberSharedAcrossLeads(db, numero) { return leadsSharingNumber(db, numero).length >= 2; } export function getSharedNumberOverride(db, numero) { const numbers = canonicalPhoneVariants(numero); if (!numbers.length) return null; const placeholders = numbers.map(() => '?').join(', '); return db.prepare( `SELECT numero, motivo, created_at FROM outreach_shared_number_overrides WHERE numero IN (${placeholders})` ).get(...numbers) || null; } export function isSharedNumberOverridden(db, numero) { return Boolean(getSharedNumberOverride(db, numero)); } export function addSharedNumberOverride(db, numero, motivo = '') { const numbers = canonicalPhoneVariants(numero); if (!numbers.length) throw new Error('numero obrigatorio'); return transaction(db, () => { const placeholders = numbers.map(() => '?').join(', '); db.prepare(`DELETE FROM outreach_shared_number_overrides WHERE numero IN (${placeholders})`).run(...numbers); db.prepare('INSERT INTO outreach_shared_number_overrides (numero, motivo) VALUES (?, ?)') .run(numbers[0], String(motivo || '').trim()); return db.prepare( 'SELECT numero, motivo, created_at FROM outreach_shared_number_overrides WHERE numero = ?' ).get(numbers[0]); }); } export function removeSharedNumberOverride(db, numero) { const numbers = canonicalPhoneVariants(numero); if (!numbers.length) return false; return transaction(db, () => { const placeholders = numbers.map(() => '?').join(', '); return db.prepare(`DELETE FROM outreach_shared_number_overrides WHERE numero IN (${placeholders})`).run(...numbers).changes > 0; }); } export function listSharedNumberGroups(db) { const rows = db.prepare("SELECT id, empresa, cnpj, whatsapp FROM leads WHERE whatsapp <> ''").all(); const groups = new Map(); for (const row of rows) { // Agrupar pela chave canonica (e nao pelo conjunto de equivalentes) mantem // as duas formas do mesmo numero no mesmo balde mesmo quando a expansao de // equivalentPhoneNumbers e assimetrica. const key = canonicalPhoneKey(row.whatsapp); if (!key) continue; if (!groups.has(key)) groups.set(key, { numero: normalizePhone(row.whatsapp), leads: [] }); groups.get(key).leads.push({ id: row.id, empresa: row.empresa, cnpj: row.cnpj }); } return [...groups.values()] .filter((group) => group.leads.length >= 2) .map((group) => { const override = getSharedNumberOverride(db, group.numero); return { ...group, liberado: Boolean(override), motivo: override?.motivo || null }; }) .sort((a, b) => b.leads.length - a.leads.length || a.numero.localeCompare(b.numero)); } export function saveWhatsAppVerification(db, result) { const numero = normalizePhone(result?.numero); if (!numero) throw new Error('numero obrigatorio'); db.prepare( `INSERT OR REPLACE INTO whatsapp_number_verifications (numero, status, numero_resolvido, variante, erro, checked_at) VALUES (?, ?, ?, ?, ?, datetime('now'))` ).run( numero, result.status, result.numeroResolvido || result.resolvedNumber ? normalizePhone(result.numeroResolvido || result.resolvedNumber) : null, result.variante || result.variant || null, result.erro ? String(result.erro.message || result.erro) : null ); return db.prepare( `SELECT numero, status, numero_resolvido, variante, erro, checked_at FROM whatsapp_number_verifications WHERE numero = ?` ).get(numero); } export function getWhatsAppVerification(db, numero) { const normalized = normalizePhone(numero); const numbers = equivalentPhoneNumbers(normalized); if (numbers.length) { const placeholders = numbers.map(() => '?').join(', '); const row = db.prepare( `SELECT numero, status, numero_resolvido, variante, erro, checked_at FROM whatsapp_number_verifications WHERE numero IN (${placeholders}) ORDER BY checked_at DESC, rowid DESC LIMIT 1` ).get(...numbers); if (row) return row; } return { numero: normalized, status: 'nao_consultado', numero_resolvido: null, variante: null, erro: null, checked_at: null }; } export function listConfirmedWhatsAppNumbers(db) { const rows = db.prepare( `SELECT numero, status FROM whatsapp_number_verifications ORDER BY checked_at DESC, rowid DESC` ).all(); const resolvedGroups = new Set(); const confirmedNumbers = []; for (const row of rows) { const equivalents = equivalentPhoneNumbers(row.numero); const group = equivalents.slice().sort().join(':'); if (!group || resolvedGroups.has(group)) continue; resolvedGroups.add(group); if (row.status === 'confirmado') confirmedNumbers.push(...equivalents); } return [...new Set(confirmedNumbers)]; } export const OUTREACH_DAILY_LIMIT_STATUSES = Object.freeze([ 'pendente', 'aceito', 'enviado', 'entregue', 'lido', 'desconhecido' ]); const OUTREACH_DAILY_LIMIT_STATUS_SQL = OUTREACH_DAILY_LIMIT_STATUSES .map((status) => `'${status}'`) .join(','); export function countDailyOutreachUsage(db, { origem = 'manual' } = {}) { return db.prepare( `SELECT COUNT(*) total FROM outreach_sends WHERE origem = ? AND status IN (${OUTREACH_DAILY_LIMIT_STATUS_SQL}) AND date(sent_at, 'localtime') = date('now', 'localtime')` ).get(origem).total; } export function reserveOutreachSend(db, { leadId = null, produto = '', numero, mensagem, origem = 'manual' }) { return transaction(db, () => { if (origem !== 'manual') { const error = new Error('envios automaticos exigem fila supervisionada'); error.statusCode = 409; throw error; } const normalized = normalizePhone(numero); if (isOptedOut(db, normalized)) { const error = new Error('numero bloqueado por opt-out'); error.statusCode = 409; throw error; } if (isNumberSharedAcrossLeads(db, normalized) && !isSharedNumberOverridden(db, normalized)) { const error = new Error('numero compartilhado por multiplos leads (revise em Configuracoes antes de enviar)'); error.statusCode = 409; throw error; } const limit = Number(getSetting(db, 'outreach.mensagensPorDia', '10')) || 0; const used = countDailyOutreachUsage(db, { origem: 'manual' }); if (used >= limit) { const error = new Error(`limite diario atingido (${used}/${limit})`); error.statusCode = 429; error.limit = { limite: limit, enviadosHoje: used, restante: 0 }; throw error; } const inserted = db.prepare( `INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, canal, origem) VALUES (?, ?, ?, ?, 'pendente', 'local', ?)` ).run(leadId, produto, normalized, mensagem, origem); return Number(inserted.lastInsertRowid); }); } export function finishOutreachSend(db, sendId, { messageId = null, error = null, unknown = false } = {}) { if (!sendId) return false; const status = unknown ? 'desconhecido' : error ? 'erro' : 'aceito'; return db.prepare( `UPDATE outreach_sends SET status = ?, erro = ?, provider_message_id = ?, accepted_at = CASE WHEN ? = 1 THEN datetime('now') ELSE accepted_at END WHERE id = ?` ).run(status, error ? String(error.message || error) : null, messageId, status === 'aceito' ? 1 : 0, sendId).changes > 0; } export function updateOutreachMessageStatus(db, { messageId, status }) { const targetRank = { enviado: 2, entregue: 3, lido: 4 }[status]; if (!messageId || (!targetRank && status !== 'erro')) return false; const row = db.prepare('SELECT id, status FROM outreach_sends WHERE provider_message_id = ?').get(messageId); if (!row) return false; if (status === 'erro') { if (['entregue', 'lido'].includes(row.status)) return false; return db.prepare("UPDATE outreach_sends SET status = 'erro', erro = COALESCE(erro, 'WhatsApp rejeitou a mensagem') WHERE id = ?") .run(row.id).changes > 0; } const currentRank = { pendente: 0, aceito: 1, enviado: 2, entregue: 3, lido: 4 }[row.status] ?? -1; if (targetRank <= currentRank) return false; const timestamps = { enviado: "server_ack_at = COALESCE(server_ack_at, datetime('now'))", entregue: "server_ack_at = COALESCE(server_ack_at, datetime('now')), delivered_at = COALESCE(delivered_at, datetime('now'))", lido: "server_ack_at = COALESCE(server_ack_at, datetime('now')), delivered_at = COALESCE(delivered_at, datetime('now')), read_at = COALESCE(read_at, datetime('now'))" }[status]; return db.prepare(`UPDATE outreach_sends SET status = ?, ${timestamps}, erro = NULL WHERE id = ?`) .run(status, row.id).changes > 0; } export function recoverPendingOutreach(db) { return db.prepare( `UPDATE outreach_sends SET status = 'desconhecido', erro = CASE WHEN erro IS NULL OR erro = '' THEN 'processo encerrou sem confirmacao de entrega' ELSE erro || '; processo encerrou sem confirmacao de entrega' END WHERE status = 'pendente' OR (status = 'aceito' AND server_ack_at IS NULL)` ).run().changes; } // Popula tabelas de referencia na primeira execucao. Tolerante a arquivos de // seed ausentes (loga e segue) para o servidor nunca falhar no boot por isso. export async function ensureSeeds(db, { log = () => {} } = {}) { const fsp = await import('node:fs/promises'); // seedSegments faz upsert (nao so insere): sempre reaplicado no boot para que // ajustes de afinidade/produtos no config.json cheguem ao banco so reiniciando. try { const config = JSON.parse(await fsp.readFile(paths.config, 'utf8')); seedSegments(db, config.segments || []); if (countRows(db, 'geo_cache') === 0) seedGeoCache(db, config.cityAreas || []); const produtos = [...new Set( (config.segments || []).flatMap((s) => (s.products || []).map((p) => p.nome)).filter(Boolean) )]; seedMessageTemplates(db, produtos); log(`seeds: ${countRows(db, 'segments')} segmentos, ${countRows(db, 'geo_cache')} bboxes, ${countRows(db, 'message_templates')} templates`); } catch (error) { log(`seeds: config.json indisponivel (${error.message})`); } if (countRows(db, 'municipios') === 0) { try { const seedPath = path.join(paths.seeds, 'municipios-ibge.json'); const rows = JSON.parse(await fsp.readFile(seedPath, 'utf8')); seedMunicipios(db, rows); log(`seeds: ${countRows(db, 'municipios')} municipios (IBGE)`); } catch (error) { log(`seeds: municipios-ibge.json indisponivel (${error.message})`); } } if (countRows(db, 'cnaes') === 0) { try { const seedPath = path.join(paths.seeds, 'cnaes.json'); const rows = JSON.parse(await fsp.readFile(seedPath, 'utf8')); seedCnaes(db, rows); log(`seeds: ${countRows(db, 'cnaes')} CNAEs`); } catch (error) { log(`seeds: cnaes.json indisponivel (${error.message})`); } } let templates = {}; try { const templatesPath = path.join(paths.root, 'whatsapp', 'templates-mensagens.json'); templates = { 'templates.mensagens': await fsp.readFile(templatesPath, 'utf8') }; } catch { // pasta whatsapp/ ja removida: template ja deve estar no banco } seedSettings(db, templates); const criouAdmin = countRows(db, 'users') === 0; ensureDefaultAdmin(db); if (criouAdmin) log('seeds: usuario admin/admin criado (primeiro acesso)'); }