Add campaign-store: CRUD, opt-out and weekly limit escalation
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user