Files
COMERCIAL-GTI/maquina-comercial-pessoal/src/db.mjs
T
GustavoandClaude Sonnet 5 417da56bd6 chore: import maquina-comercial-pessoal history via subtree
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

git-subtree-dir: maquina-comercial-pessoal
git-subtree-mainline: f5af038e19
git-subtree-split: a9ef4d776d
2026-09-28 15:50:17 -03:00

1267 lines
51 KiB
JavaScript

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)');
}