Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> git-subtree-dir: maquina-comercial-pessoal git-subtree-mainline:f5af038e19git-subtree-split:a9ef4d776d
1267 lines
51 KiB
JavaScript
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)');
|
|
}
|