diff --git a/src/outreach/campaign-store.mjs b/src/outreach/campaign-store.mjs new file mode 100644 index 0000000..b70e839 --- /dev/null +++ b/src/outreach/campaign-store.mjs @@ -0,0 +1,101 @@ +import { transaction } from '../db.mjs'; + +export function createCampaign(db, { + nome, produto, segmentos, variantes, limiteDiarioInicial = 10, limiteDiarioTeto = 80 +}) { + return transaction(db, () => { + const result = db.prepare( + `INSERT INTO outreach_campaigns + (nome, produto, segmentos_json, limite_diario_atual, limite_diario_teto) + VALUES (?, ?, ?, ?, ?)` + ).run(nome, produto, JSON.stringify(segmentos || []), limiteDiarioInicial, limiteDiarioTeto); + const campaignId = Number(result.lastInsertRowid); + const insertVariant = db.prepare( + `INSERT INTO outreach_campaign_variants (campaign_id, code, mensagem) VALUES (?, ?, ?)` + ); + for (const variante of variantes) { + insertVariant.run(campaignId, variante.code, variante.mensagem); + } + return getCampaign(db, campaignId); + }); +} + +export function getCampaign(db, id) { + const row = db.prepare('SELECT * FROM outreach_campaigns WHERE id = ?').get(id); + if (!row) return null; + return { + ...row, + segmentos: JSON.parse(row.segmentos_json || '[]'), + variants: listVariants(db, id) + }; +} + +export function listCampaigns(db) { + return db.prepare('SELECT * FROM outreach_campaigns ORDER BY id DESC').all() + .map((row) => ({ ...row, segmentos: JSON.parse(row.segmentos_json || '[]') })); +} + +export function listVariants(db, campaignId) { + return db.prepare( + 'SELECT * FROM outreach_campaign_variants WHERE campaign_id = ? AND ativo = 1 ORDER BY code' + ).all(campaignId); +} + +// status: 'rascunho' -> 'rodando' -> 'pausado' <-> 'rodando' -> 'concluido'. +// pause_reason so fica setado quando o novo status e 'pausado'. +export function setCampaignStatus(db, campaignId, status, pauseReason = null) { + db.prepare( + `UPDATE outreach_campaigns SET status = ?, pause_reason = ?, updated_at = datetime('now') WHERE id = ?` + ).run(status, status === 'pausado' ? pauseReason : null, campaignId); + return getCampaign(db, campaignId); +} + +// So avanca campanha que estiver de fato pausada — retomar uma campanha em +// rascunho, por exemplo, deveria usar setCampaignStatus(db, id, 'rodando'). +export function resumeCampaign(db, campaignId) { + db.prepare( + `UPDATE outreach_campaigns SET status = 'rodando', pause_reason = NULL, updated_at = datetime('now') + WHERE id = ? AND status = 'pausado'` + ).run(campaignId); + return getCampaign(db, campaignId); +} + +// Escalonamento semanal: dobra o limite diario a cada 7 dias corridos rodando +// sem pausa desde semana_iniciada_em, ate o teto da campanha. So campanhas com +// status='rodando' avancam — uma pausada fica congelada ate o operador retomar. +export function escalateWeeklyLimits(db, now = new Date()) { + const campaigns = db.prepare( + `SELECT id, limite_diario_atual, limite_diario_teto, semana_iniciada_em + FROM outreach_campaigns WHERE status = 'rodando'` + ).all(); + let escaladas = 0; + for (const campaign of campaigns) { + if (!campaign.semana_iniciada_em) continue; + const dias = (now.getTime() - new Date(campaign.semana_iniciada_em.replace(' ', 'T') + 'Z').getTime()) / 86400000; + if (dias < 7) continue; + const novoLimite = Math.min(campaign.limite_diario_teto, campaign.limite_diario_atual * 2); + if (novoLimite === campaign.limite_diario_atual) continue; + db.prepare( + `UPDATE outreach_campaigns + SET limite_diario_atual = ?, semana_iniciada_em = datetime('now'), updated_at = datetime('now') + WHERE id = ?` + ).run(novoLimite, campaign.id); + escaladas += 1; + } + return escaladas; +} + +export function isOptedOut(db, numero) { + return Boolean(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get(numero)); +} + +export function addOptOut(db, numero, motivo = null) { + db.prepare( + `INSERT INTO outreach_opt_out (numero, motivo) VALUES (?, ?) + ON CONFLICT(numero) DO UPDATE SET motivo = excluded.motivo` + ).run(numero, motivo); +} + +export function removeOptOut(db, numero) { + db.prepare('DELETE FROM outreach_opt_out WHERE numero = ?').run(numero); +} diff --git a/tests/campaign-store.test.mjs b/tests/campaign-store.test.mjs new file mode 100644 index 0000000..c160b5f --- /dev/null +++ b/tests/campaign-store.test.mjs @@ -0,0 +1,98 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb } from '../src/db.mjs'; +import { + createCampaign, getCampaign, listCampaigns, setCampaignStatus, resumeCampaign, + escalateWeeklyLimits, isOptedOut, addOptOut, removeOptOut +} from '../src/outreach/campaign-store.mjs'; + +function campanhaBase(overrides = {}) { + return { + nome: 'ERP oficinas', + produto: 'ERP', + segmentos: ['oficinas_mecanicas', 'autopecas'], + variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }, { code: 'B', mensagem: 'Oi {empresa}' }], + ...overrides + }; +} + +test('createCampaign grava campanha, segmentos e variantes', () => { + const db = openDb(':memory:'); + const campanha = createCampaign(db, campanhaBase()); + assert.equal(campanha.status, 'rascunho'); + assert.equal(campanha.produto, 'ERP'); + assert.deepEqual(campanha.segmentos, ['oficinas_mecanicas', 'autopecas']); + assert.equal(campanha.variants.length, 2); + assert.equal(campanha.variants[0].code, 'A'); + db.close(); +}); + +test('listCampaigns retorna mais recente primeiro', () => { + const db = openDb(':memory:'); + createCampaign(db, campanhaBase({ nome: 'Primeira' })); + createCampaign(db, campanhaBase({ nome: 'Segunda' })); + const campanhas = listCampaigns(db); + assert.equal(campanhas.length, 2); + assert.equal(campanhas[0].nome, 'Segunda'); + db.close(); +}); + +test('setCampaignStatus pausa e resumeCampaign retoma limpando o motivo', () => { + const db = openDb(':memory:'); + const campanha = createCampaign(db, campanhaBase()); + setCampaignStatus(db, campanha.id, 'rodando'); + const pausada = setCampaignStatus(db, campanha.id, 'pausado', 'high_failure_rate'); + assert.equal(pausada.status, 'pausado'); + assert.equal(pausada.pause_reason, 'high_failure_rate'); + const retomada = resumeCampaign(db, campanha.id); + assert.equal(retomada.status, 'rodando'); + assert.equal(retomada.pause_reason, null); + db.close(); +}); + +test('resumeCampaign nao faz nada se a campanha nao estiver pausada', () => { + const db = openDb(':memory:'); + const campanha = createCampaign(db, campanhaBase()); + const resultado = resumeCampaign(db, campanha.id); + assert.equal(resultado.status, 'rascunho'); + db.close(); +}); + +test('escalateWeeklyLimits dobra o limite apos 7 dias rodando, respeitando o teto', () => { + const db = openDb(':memory:'); + const campanha = createCampaign(db, campanhaBase({ limiteDiarioInicial: 10, limiteDiarioTeto: 15 })); + setCampaignStatus(db, campanha.id, 'rodando'); + db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-8 days') WHERE id = ?`).run(campanha.id); + + const escaladas = escalateWeeklyLimits(db); + assert.equal(escaladas, 1); + const atualizada = getCampaign(db, campanha.id); + assert.equal(atualizada.limite_diario_atual, 15); // dobraria pra 20, mas o teto e 15 + db.close(); +}); + +test('escalateWeeklyLimits nao avanca campanha pausada nem antes de 7 dias', () => { + const db = openDb(':memory:'); + const rodando = createCampaign(db, campanhaBase({ nome: 'Rodando ha pouco' })); + setCampaignStatus(db, rodando.id, 'rodando'); + db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-2 days') WHERE id = ?`).run(rodando.id); + + const pausada = createCampaign(db, campanhaBase({ nome: 'Pausada' })); + setCampaignStatus(db, pausada.id, 'rodando'); + setCampaignStatus(db, pausada.id, 'pausado', 'manual'); + db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-10 days') WHERE id = ?`).run(pausada.id); + + const escaladas = escalateWeeklyLimits(db); + assert.equal(escaladas, 0); + db.close(); +}); + +test('opt-out: addOptOut bloqueia, removeOptOut libera', () => { + const db = openDb(':memory:'); + assert.equal(isOptedOut(db, '5511999990001'), false); + addOptOut(db, '5511999990001', 'pediu pra parar'); + assert.equal(isOptedOut(db, '5511999990001'), true); + removeOptOut(db, '5511999990001'); + assert.equal(isOptedOut(db, '5511999990001'), false); + db.close(); +});