Files
mercado.clube67.com/backend/database/database.js
T

1163 lines
60 KiB
JavaScript

'use strict'
/**
* database.js — Camada de compatibilidade PostgreSQL
*
* Mantém a mesma API do módulo sql.js anterior (runQuery/getOne/getAll/saveDatabase)
* para que os controllers e plugins existentes não precisem ser alterados.
*
* As funções agora são ASSÍNCRONAS internamente mas retornam Promises,
* e o server.js chama initialize() com await antes de iniciar.
*/
const { execute, queryOne, query } = require('./postgres')
// Compatibilidade: controllers antigos usam runQuery/getOne/getAll de forma síncrona
// via require('../database/database'). Como o Express suporta async handlers,
// os controllers foram atualizados para async/await nos models.
// Este arquivo apenas exporta os helpers para código legado restante (plugins).
async function initialize() {
const row = await queryOne('SELECT NOW() as now')
console.log('✅ PostgreSQL conectado:', row.now)
// Fase 7: colunas idempotentes
await execute(`ALTER TABLE users ADD COLUMN IF NOT EXISTS whatsapp_optin BOOLEAN DEFAULT TRUE`)
await execute(`ALTER TABLE raffles ADD COLUMN IF NOT EXISTS last_hours_notif_sent TIMESTAMPTZ`)
await execute(`ALTER TABLE raffle_claims ADD COLUMN IF NOT EXISTS notified_at TIMESTAMPTZ`)
console.log('✅ Colunas Fase 7 verificadas')
// ── Fase 9B — Auth email + senha ──────────────────────────────────────────
await execute(`ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255)`)
await execute(`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_tenant ON users(email, tenant_id) WHERE email IS NOT NULL`)
await execute(`
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(128) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_prt_token ON password_reset_tokens(token)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id)`)
console.log('✅ Fase 9B — Auth email+senha verificada')
// ── Fase 9A — Multi-tenant ─────────────────────────────────────────────────
await execute(`
CREATE TABLE IF NOT EXISTS tenants (
id SERIAL PRIMARY KEY,
slug VARCHAR(80) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
domain VARCHAR(255),
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`
CREATE TABLE IF NOT EXISTS tenant_config (
tenant_id INT NOT NULL PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
primary_color VARCHAR(20) NOT NULL DEFAULT '#f59e0b',
secondary_color VARCHAR(20) NOT NULL DEFAULT '#10b981',
logo_url TEXT,
favicon_url TEXT,
contact_email VARCHAR(255),
contact_phone VARCHAR(30),
app_url VARCHAR(255),
asaas_key VARCHAR(255)
)
`)
// Tenant padrão — Alemão Conveniências
await execute(`
INSERT INTO tenants (id, slug, name, domain, active)
VALUES (1, 'alemao', 'Alemão Conveniências', 'alemaoconveniencias.local', true)
ON CONFLICT (id) DO NOTHING
`)
await execute(`
INSERT INTO tenant_config (tenant_id, primary_color, secondary_color, app_url)
VALUES (1, '#f59e0b', '#10b981', 'http://alemaoconveniencias.local:4001')
ON CONFLICT (tenant_id) DO NOTHING
`)
// tenant_id nas tabelas existentes
await execute(`ALTER TABLE users ADD COLUMN IF NOT EXISTS tenant_id INT REFERENCES tenants(id)`)
await execute(`ALTER TABLE raffles ADD COLUMN IF NOT EXISTS tenant_id INT REFERENCES tenants(id)`)
await execute(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS tenant_id INT REFERENCES tenants(id)`)
await execute(`ALTER TABLE tickets ADD COLUMN IF NOT EXISTS tenant_id INT REFERENCES tenants(id)`)
await execute(`ALTER TABLE promotions ADD COLUMN IF NOT EXISTS tenant_id INT REFERENCES tenants(id)`)
await execute(`ALTER TABLE whatsapp_logs ADD COLUMN IF NOT EXISTS tenant_id INT REFERENCES tenants(id)`)
// Popular registros existentes sem tenant_id
await execute(`UPDATE users SET tenant_id = 1 WHERE tenant_id IS NULL`)
await execute(`UPDATE raffles SET tenant_id = 1 WHERE tenant_id IS NULL`)
await execute(`UPDATE orders SET tenant_id = 1 WHERE tenant_id IS NULL`)
await execute(`UPDATE tickets SET tenant_id = 1 WHERE tenant_id IS NULL`)
await execute(`UPDATE promotions SET tenant_id = 1 WHERE tenant_id IS NULL`)
await execute(`UPDATE whatsapp_logs SET tenant_id = 1 WHERE tenant_id IS NULL`)
// Índices de performance
await execute(`CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_raffles_tenant ON raffles(tenant_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_orders_tenant ON orders(tenant_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_tickets_tenant ON tickets(tenant_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_promotions_tenant ON promotions(tenant_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_wa_logs_tenant ON whatsapp_logs(tenant_id)`)
// ── Fase 10A — Clube de Benefícios (tabelas com tenant_id desde o início) ──
await execute(`
CREATE TABLE IF NOT EXISTS benefit_clubs (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL REFERENCES tenants(id),
slug VARCHAR(80) NOT NULL,
name VARCHAR(255) NOT NULL,
specialty VARCHAR(80) NOT NULL DEFAULT 'outro',
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, slug)
)
`)
await execute(`
CREATE TABLE IF NOT EXISTS club_plans (
id SERIAL PRIMARY KEY,
club_id INT NOT NULL REFERENCES benefit_clubs(id),
name VARCHAR(255) NOT NULL,
description TEXT,
price_monthly NUMERIC(10,2) NOT NULL,
max_dependents INT NOT NULL DEFAULT 0,
features JSONB NOT NULL DEFAULT '[]',
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`
CREATE TABLE IF NOT EXISTS club_members (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL REFERENCES tenants(id),
club_id INT NOT NULL REFERENCES benefit_clubs(id),
plan_id INT NOT NULL REFERENCES club_plans(id),
user_cpf VARCHAR(14) NOT NULL,
user_nome VARCHAR(255) NOT NULL,
user_telefone VARCHAR(20),
user_email VARCHAR(255),
status VARCHAR(20) NOT NULL DEFAULT 'pending_payment',
asaas_subscription_id VARCHAR(255),
asaas_customer_id VARCHAR(255),
next_billing_at DATE,
started_at TIMESTAMPTZ,
canceled_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_club_members_cpf ON club_members(user_cpf)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_club_members_tenant ON club_members(tenant_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_club_members_status ON club_members(status)`)
await execute(`
CREATE TABLE IF NOT EXISTS club_dependents (
id SERIAL PRIMARY KEY,
member_id INT NOT NULL REFERENCES club_members(id) ON DELETE CASCADE,
nome VARCHAR(255) NOT NULL,
cpf VARCHAR(14),
nascimento DATE,
parentesco VARCHAR(80),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`
CREATE TABLE IF NOT EXISTS club_payments (
id SERIAL PRIMARY KEY,
member_id INT NOT NULL REFERENCES club_members(id),
plan_id INT NOT NULL REFERENCES club_plans(id),
amount NUMERIC(10,2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
asaas_payment_id VARCHAR(255),
reference_month VARCHAR(7),
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_club_payments_member ON club_payments(member_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_club_payments_status ON club_payments(status)`)
await execute(`
CREATE TABLE IF NOT EXISTS club_partners (
id SERIAL PRIMARY KEY,
club_id INT NOT NULL REFERENCES benefit_clubs(id),
tenant_id INT REFERENCES tenants(id),
name VARCHAR(255) NOT NULL,
specialty VARCHAR(255),
phone VARCHAR(20),
address TEXT,
discount_percent INT NOT NULL DEFAULT 0,
notes TEXT,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
// Migração: renomear colunas PT → EN (idempotente via DO $$)
await execute(`
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='club_partners' AND column_name='nome') THEN
ALTER TABLE club_partners RENAME COLUMN nome TO name;
ALTER TABLE club_partners RENAME COLUMN especialidade TO specialty;
ALTER TABLE club_partners RENAME COLUMN telefone TO phone;
ALTER TABLE club_partners RENAME COLUMN endereco TO address;
ALTER TABLE club_partners RENAME COLUMN desconto_percent TO discount_percent;
ALTER TABLE club_partners RENAME COLUMN ativo TO active;
END IF;
END $$
`)
await execute(`ALTER TABLE club_partners ADD COLUMN IF NOT EXISTS tenant_id INT REFERENCES tenants(id)`)
await execute(`ALTER TABLE club_partners ADD COLUMN IF NOT EXISTS notes TEXT`)
await execute(`CREATE INDEX IF NOT EXISTS idx_club_partners_club ON club_partners(club_id)`)
// Dados iniciais — clubes e planos do Alemão
await execute(`
INSERT INTO benefit_clubs (tenant_id, slug, name, specialty, active)
VALUES
(1, 'odonto', 'Clube Odonto', 'odonto', true),
(1, 'medico', 'Clube Médico', 'medico', true)
ON CONFLICT (tenant_id, slug) DO NOTHING
`)
const odonto = await queryOne(`SELECT id FROM benefit_clubs WHERE tenant_id=1 AND slug='odonto'`)
const medico = await queryOne(`SELECT id FROM benefit_clubs WHERE tenant_id=1 AND slug='medico'`)
if (odonto) {
const existing = await queryOne(`SELECT id FROM club_plans WHERE club_id = $1 LIMIT 1`, [odonto.id])
if (!existing) {
await execute(
`INSERT INTO club_plans (club_id, name, description, price_monthly, max_dependents, features) VALUES
($1, 'Individual', 'Plano para uma pessoa', 25.00, 0, $2),
($1, 'Família', 'Plano para até 4 dependentes', 80.00, 4, $3)`,
[
odonto.id,
JSON.stringify(['Limpezas e Prevenção','Descontos em Tratamentos','Extrações e Obturações','Sem Carência']),
JSON.stringify(['Até 4 Dependentes','Limpezas e Prevenção','Descontos em Tratamentos','Urgência e Emergência']),
]
)
}
}
if (medico) {
const existing = await queryOne(`SELECT id FROM club_plans WHERE club_id = $1 LIMIT 1`, [medico.id])
if (!existing) {
await execute(
`INSERT INTO club_plans (club_id, name, description, price_monthly, max_dependents, features) VALUES
($1, 'Individual', 'Plano médico individual', 35.00, 0, $2),
($1, 'Família', 'Plano médico familiar', 99.00, 4, $3)`,
[
medico.id,
JSON.stringify(['Consultas com Clínico Geral','Descontos em Exames','Pediatria','Sem Carência']),
JSON.stringify(['Até 4 Dependentes','Clínico Geral e Especialistas','Descontos em Exames','Urgência e Emergência']),
]
)
}
}
// ── Fase 10B — Condições, Regras e FAQ por clube ──────────────────────────
await execute(`ALTER TABLE benefit_clubs ADD COLUMN IF NOT EXISTS conditions TEXT`)
await execute(`ALTER TABLE benefit_clubs ADD COLUMN IF NOT EXISTS rules TEXT`)
await execute(`
CREATE TABLE IF NOT EXISTS club_faqs (
id SERIAL PRIMARY KEY,
club_id INT NOT NULL REFERENCES benefit_clubs(id) ON DELETE CASCADE,
tenant_id INT NOT NULL REFERENCES tenants(id),
question TEXT NOT NULL,
answer TEXT NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_club_faqs_club ON club_faqs(club_id)`)
// Seed das FAQs padrão por clube (apenas se ainda não houver nenhuma)
if (odonto) {
const faqExists = await queryOne(`SELECT id FROM club_faqs WHERE club_id=$1 LIMIT 1`, [odonto.id])
if (!faqExists) {
await execute(
`INSERT INTO club_faqs (club_id, tenant_id, question, answer, sort_order) VALUES
($1, 1, 'Como funciona o pagamento?', 'O pagamento é mensal via PIX, gerado automaticamente. Você recebe o link no WhatsApp antes do vencimento.', 0),
($1, 1, 'Posso cancelar a qualquer momento?', 'Sim. Acesse sua área do cliente e solicite o cancelamento. O plano permanece ativo até o fim do período pago.', 1),
($1, 1, 'Meus dependentes têm acesso?', 'Sim, dependendo do plano escolhido. O plano Família inclui cobertura para até 4 dependentes.', 2),
($1, 1, 'Como acessar as clínicas parceiras?', 'Apresente sua carteirinha digital (acessível pela área do cliente) na recepção da clínica parceira.', 3)`,
[odonto.id]
)
}
}
if (medico) {
const faqExists = await queryOne(`SELECT id FROM club_faqs WHERE club_id=$1 LIMIT 1`, [medico.id])
if (!faqExists) {
await execute(
`INSERT INTO club_faqs (club_id, tenant_id, question, answer, sort_order) VALUES
($1, 1, 'Como funciona o pagamento?', 'O pagamento é mensal via PIX, gerado automaticamente. Você recebe o link no WhatsApp antes do vencimento.', 0),
($1, 1, 'Posso cancelar a qualquer momento?', 'Sim. Acesse sua área do cliente e solicite o cancelamento. O plano permanece ativo até o fim do período pago.', 1),
($1, 1, 'Meus dependentes têm acesso?', 'Sim, dependendo do plano escolhido. O plano Família inclui cobertura para até 4 dependentes.', 2),
($1, 1, 'Como acessar as clínicas parceiras?', 'Apresente sua carteirinha digital (acessível pela área do cliente) na recepção da clínica parceira.', 3)`,
[medico.id]
)
}
}
console.log('✅ Fase 10B — club_faqs e colunas conditions/rules verificadas')
console.log('✅ Fase 9A — Multi-tenant e tabelas do Clube criadas/verificadas')
// ── Fase 11 — Banco de Talentos ──────────────────────────────────────────
await execute(`
CREATE TABLE IF NOT EXISTS jobs (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL REFERENCES tenants(id),
title TEXT NOT NULL,
description TEXT NOT NULL,
requirements TEXT,
benefits TEXT,
location TEXT,
work_mode TEXT NOT NULL DEFAULT 'presencial',
job_type TEXT NOT NULL DEFAULT 'clt',
category TEXT,
salary_min NUMERIC(10,2),
salary_max NUMERIC(10,2),
salary_hide BOOLEAN NOT NULL DEFAULT false,
slots INT NOT NULL DEFAULT 1,
active BOOLEAN NOT NULL DEFAULT true,
published_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_jobs_tenant ON jobs(tenant_id, active)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_jobs_published ON jobs(published_at DESC)`)
await execute(`
CREATE TABLE IF NOT EXISTS resumes (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL REFERENCES tenants(id),
user_cpf TEXT NOT NULL,
user_nome TEXT NOT NULL,
email TEXT,
phone TEXT,
linkedin TEXT,
portfolio TEXT,
about TEXT,
experience JSONB NOT NULL DEFAULT '[]',
education JSONB NOT NULL DEFAULT '[]',
skills JSONB NOT NULL DEFAULT '[]',
file_url TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, user_cpf)
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_resumes_cpf ON resumes(tenant_id, user_cpf)`)
await execute(`
CREATE TABLE IF NOT EXISTS job_applications (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL REFERENCES tenants(id),
job_id INT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
resume_id INT REFERENCES resumes(id),
applicant_nome TEXT NOT NULL,
applicant_email TEXT,
applicant_phone TEXT,
applicant_cpf TEXT NOT NULL,
cover_letter TEXT,
status TEXT NOT NULL DEFAULT 'novo',
admin_notes TEXT,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (job_id, applicant_cpf)
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_applications_job ON job_applications(job_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_applications_cpf ON job_applications(applicant_cpf)`)
console.log('✅ Fase 11 — Banco de Talentos verificado')
// ── Fase 9F — Admin tenant_id ────────────────────────────────────────────
await execute(`ALTER TABLE admins ADD COLUMN IF NOT EXISTS tenant_id INT REFERENCES tenants(id)`)
// Associa admins existentes sem tenant ao tenant padrão (alemao = 1)
await execute(`UPDATE admins SET tenant_id=1 WHERE tenant_id IS NULL`)
console.log('✅ Fase 9F — tenant_id em admins verificado')
// ── Admin ↔ Account link ──────────────────────────────────────────────────
// Motivo: o admin (tabela `admins`) e o colaborador (tabela `accounts`) são
// entidades separadas com JWTs distintos. Sem essa ligação, quando o admin
// está logado pela sessão admin, a Inbox Interna não sabe qual account usar
// como identidade — e exibe "login de colaborador necessário".
// Solução: um campo account_id opcional em admins. Quando preenchido, o
// endpoint /api/admin/me devolve o account_id e o frontend usa esse valor
// diretamente na Inbox Interna, sem exigir um segundo login.
await execute(`ALTER TABLE admins ADD COLUMN IF NOT EXISTS account_id INT REFERENCES accounts(id) ON DELETE SET NULL`)
console.log('✅ Admin ↔ Account link: coluna account_id adicionada em admins')
// ── Fase 9E — Super Admin ─────────────────────────────────────────────────
await execute(`
CREATE TABLE IF NOT EXISTS super_admins (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
// Seed super admin padrão se não existir
const bcrypt = require('bcryptjs')
const existingSA = await queryOne('SELECT id FROM super_admins LIMIT 1')
if (!existingSA) {
const hash = await bcrypt.hash(process.env.SUPER_ADMIN_PASSWORD || 'superadmin123', 10)
await execute(
`INSERT INTO super_admins (email, password_hash, name) VALUES ($1,$2,$3)`,
[process.env.SUPER_ADMIN_EMAIL || 'super@admin.local', hash, 'Super Admin']
)
console.log('✅ Super admin padrão criado — altere a senha em produção!')
}
console.log('✅ Fase 9E — Super Admin verificado')
// ── Plugin NewWhats — tabela de eventos de inbox ──────────────────────────
await execute(`
CREATE TABLE IF NOT EXISTS nw_event_logs (
id SERIAL PRIMARY KEY,
instance_id VARCHAR(128) NOT NULL DEFAULT '',
event VARCHAR(64) NOT NULL,
direction VARCHAR(8) NOT NULL DEFAULT 'in',
chat_id VARCHAR(128),
from_me BOOLEAN NOT NULL DEFAULT FALSE,
msg_type VARCHAR(32),
payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_nw_event_logs_instance ON nw_event_logs(instance_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_nw_event_logs_event ON nw_event_logs(event)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_nw_event_logs_created ON nw_event_logs(created_at DESC)`)
console.log('✅ Plugin NewWhats — tabela nw_event_logs verificada')
// ── Plugin NewWhats — respostas automáticas da IA ─────────────────────────
await execute(`
CREATE TABLE IF NOT EXISTS nw_auto_replies (
id SERIAL PRIMARY KEY,
instance_id VARCHAR(128) NOT NULL,
chat_id VARCHAR(256) NOT NULL,
conv_id VARCHAR(64),
user_msg TEXT,
reply TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'sent',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_nw_auto_chat ON nw_auto_replies(chat_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_nw_auto_instance ON nw_auto_replies(instance_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_nw_auto_created ON nw_auto_replies(created_at DESC)`)
console.log('✅ Plugin NewWhats — tabela nw_auto_replies verificada')
// Multi-tenant: adiciona tenant_id nas tabelas nw_ (retroativo com DEFAULT 1)
await execute(`ALTER TABLE nw_event_logs ADD COLUMN IF NOT EXISTS tenant_id INT NOT NULL DEFAULT 1`)
await execute(`ALTER TABLE nw_auto_replies ADD COLUMN IF NOT EXISTS tenant_id INT NOT NULL DEFAULT 1`)
await execute(`CREATE INDEX IF NOT EXISTS idx_nw_event_tenant ON nw_event_logs(tenant_id, created_at DESC)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_nw_auto_tenant ON nw_auto_replies(tenant_id, created_at DESC)`)
// ── Handoff local — pausa da IA por chat ──────────────────────────────────
await execute(`
CREATE TABLE IF NOT EXISTS nw_handoff (
chat_id TEXT NOT NULL,
tenant_id INT NOT NULL,
human_until TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (chat_id, tenant_id)
)
`)
console.log('✅ Plugin NewWhats — tabela nw_handoff verificada')
// ── Setor identificado por chat ────────────────────────────────────────────
await execute(`
CREATE TABLE IF NOT EXISTS nw_chat_sectors (
chat_id TEXT NOT NULL,
tenant_id INT NOT NULL,
sector_id INT REFERENCES sectors(id) ON DELETE SET NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (chat_id, tenant_id)
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_nw_chat_sectors_sector ON nw_chat_sectors(sector_id)`)
// ── Avaliação de atendimento pelo cliente ──────────────────────────────────
await execute(`
CREATE TABLE IF NOT EXISTS protocol_ratings (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL REFERENCES tenants(id),
thread_id VARCHAR(64) NOT NULL,
chat_id TEXT NOT NULL,
account_id INT REFERENCES accounts(id) ON DELETE SET NULL,
rating SMALLINT CHECK (rating BETWEEN 1 AND 5),
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','rated','expired')),
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
rated_at TIMESTAMPTZ
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_protocol_ratings_tenant ON protocol_ratings(tenant_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_protocol_ratings_chat ON protocol_ratings(chat_id)`)
// ── Flags de protocolo (reclamação, sugestão, elogio) ─────────────────────
await execute(`
CREATE TABLE IF NOT EXISTS protocol_flags (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL REFERENCES tenants(id),
thread_id VARCHAR(64) NOT NULL,
account_id INT REFERENCES accounts(id) ON DELETE SET NULL,
type VARCHAR(20) NOT NULL DEFAULT 'complaint'
CHECK (type IN ('complaint','suggestion','compliment')),
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_protocol_flags_tenant ON protocol_flags(tenant_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_protocol_flags_thread ON protocol_flags(thread_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_protocol_flags_type ON protocol_flags(type)`)
console.log('✅ Fase 4 — protocol_ratings, protocol_flags criadas/verificadas')
// ── Worker — tabela de falhas definitivas de envio ────────────────────────
await execute(`
CREATE TABLE IF NOT EXISTS notification_errors (
id SERIAL PRIMARY KEY,
job_id VARCHAR(64),
log_id INT,
phone VARCHAR(20),
type VARCHAR(64),
error_message TEXT,
payload JSONB,
tenant_id INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_notif_errors_tenant ON notification_errors(tenant_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_notif_errors_created ON notification_errors(created_at DESC)`)
console.log('✅ Worker — tabela notification_errors verificada')
// ── Fase 12 — Equipe Virtual ─────────────────────────────────────────────
// accounts: colaboradores internos com cargo e número pessoal
await execute(`
CREATE TABLE IF NOT EXISTS accounts (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL REFERENCES tenants(id),
name VARCHAR(255) NOT NULL,
email VARCHAR(255),
password_hash VARCHAR(255),
role VARCHAR(20) NOT NULL DEFAULT 'agent'
CHECK (role IN ('owner','manager','supervisor','secretary','agent','viewer')),
phone VARCHAR(30),
phone_connected BOOLEAN NOT NULL DEFAULT FALSE,
availability VARCHAR(10) NOT NULL DEFAULT 'offline'
CHECK (availability IN ('online','busy','offline')),
avatar_url TEXT,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_email ON accounts(tenant_id, email) WHERE email IS NOT NULL`)
await execute(`CREATE INDEX IF NOT EXISTS idx_accounts_tenant ON accounts(tenant_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_accounts_role ON accounts(tenant_id, role)`)
// sectors: setores operacionais da empresa
await execute(`
CREATE TABLE IF NOT EXISTS sectors (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL REFERENCES tenants(id),
name VARCHAR(255) NOT NULL,
description TEXT,
icon VARCHAR(80) NOT NULL DEFAULT 'Briefcase',
color VARCHAR(20) NOT NULL DEFAULT '#6366f1',
sla_minutes INT NOT NULL DEFAULT 60,
sort_order INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_sectors_tenant ON sectors(tenant_id, active)`)
// team_members: relacionamento conta ↔ setor
await execute(`
CREATE TABLE IF NOT EXISTS team_members (
id SERIAL PRIMARY KEY,
account_id INT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
sector_id INT NOT NULL REFERENCES sectors(id) ON DELETE CASCADE,
function VARCHAR(255),
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
notify_escalation BOOLEAN NOT NULL DEFAULT TRUE,
notify_human_api BOOLEAN NOT NULL DEFAULT TRUE,
on_duty BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (account_id, sector_id)
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_team_members_account ON team_members(account_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_team_members_sector ON team_members(sector_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_team_members_duty ON team_members(sector_id, on_duty)`)
// sector_brain_nodes: cérebro de conhecimento por setor
await execute(`
CREATE TABLE IF NOT EXISTS sector_brain_nodes (
id SERIAL PRIMARY KEY,
sector_id INT NOT NULL REFERENCES sectors(id) ON DELETE CASCADE,
type VARCHAR(20) NOT NULL DEFAULT 'knowledge'
CHECK (type IN ('knowledge','rules','escalation','persona')),
content TEXT NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_sector_brain_sector ON sector_brain_nodes(sector_id, active)`)
// internal_messages: inbox interna (equipe ↔ equipe, Human API, escalações)
await execute(`
CREATE TABLE IF NOT EXISTS internal_messages (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL REFERENCES tenants(id),
thread_id VARCHAR(64) NOT NULL,
from_account INT REFERENCES accounts(id) ON DELETE SET NULL,
to_account INT REFERENCES accounts(id) ON DELETE SET NULL,
sector_id INT REFERENCES sectors(id) ON DELETE SET NULL,
type VARCHAR(20) NOT NULL DEFAULT 'collaboration'
CHECK (type IN ('human_api','escalation','collaboration','system','help_request')),
content TEXT NOT NULL,
ref_id VARCHAR(64),
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_internal_msgs_tenant ON internal_messages(tenant_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_internal_msgs_thread ON internal_messages(thread_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_internal_msgs_to ON internal_messages(to_account, read_at)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_internal_msgs_sector ON internal_messages(sector_id)`)
// human_api_requests: consultas da IA para especialistas humanos
await execute(`
CREATE TABLE IF NOT EXISTS human_api_requests (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL REFERENCES tenants(id),
conversation_id VARCHAR(64),
sector_id INT REFERENCES sectors(id) ON DELETE SET NULL,
account_id INT REFERENCES accounts(id) ON DELETE SET NULL,
question TEXT NOT NULL,
answer TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','answered','timeout','ignored')),
delivery_mode VARCHAR(20) NOT NULL DEFAULT 'inbox_interna'
CHECK (delivery_mode IN ('whatsapp','inbox_interna')),
asked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
answered_at TIMESTAMPTZ,
timeout_at TIMESTAMPTZ
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_human_api_tenant ON human_api_requests(tenant_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_human_api_status ON human_api_requests(status)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_human_api_account ON human_api_requests(account_id)`)
// knowledge_learning: respostas humanas que podem virar base de conhecimento
await execute(`
CREATE TABLE IF NOT EXISTS knowledge_learning (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL REFERENCES tenants(id),
sector_id INT REFERENCES sectors(id) ON DELETE SET NULL,
account_id INT REFERENCES accounts(id) ON DELETE SET NULL,
question TEXT NOT NULL,
answer TEXT NOT NULL,
promoted BOOLEAN NOT NULL DEFAULT FALSE,
promoted_at TIMESTAMPTZ,
use_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_knowledge_tenant ON knowledge_learning(tenant_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_knowledge_sector ON knowledge_learning(sector_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_knowledge_promoted ON knowledge_learning(promoted)`)
// Adiciona chat_id em internal_messages (necessário para encerrar protocolo sem prompt)
await execute(`ALTER TABLE internal_messages ADD COLUMN IF NOT EXISTS chat_id VARCHAR(128)`)
// ── Direct Messages (DMs entre colaboradores) ──────────────────────────────────
// Fase 13: Comunicação interna entre colaboradores (não vinculada a escalação/protocol)
await execute(`
CREATE TABLE IF NOT EXISTS direct_messages (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL REFERENCES tenants(id),
from_account INT NOT NULL REFERENCES accounts(id) ON DELETE SET NULL,
to_account INT NOT NULL REFERENCES accounts(id) ON DELETE SET NULL,
content TEXT NOT NULL,
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_dm_tenant ON direct_messages(tenant_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_dm_from_to ON direct_messages(from_account, to_account)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_dm_read_status ON direct_messages(to_account, read_at)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_dm_created ON direct_messages(created_at DESC)`)
console.log('✅ Fase 12 — Equipe Virtual: accounts, sectors, team_members, sector_brain_nodes, internal_messages, human_api_requests, knowledge_learning')
console.log('✅ Fase 13 — Direct Messages: DMs entre colaboradores')
// ── Group Chats por Setor ─────────────────────────────────────────────────
await execute(`
CREATE TABLE IF NOT EXISTS group_chats (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL REFERENCES tenants(id),
sector_id INT NOT NULL REFERENCES sectors(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, sector_id)
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_group_chats_tenant ON group_chats(tenant_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_group_chats_sector ON group_chats(sector_id)`)
await execute(`
CREATE TABLE IF NOT EXISTS group_messages (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL REFERENCES tenants(id),
group_chat_id INT NOT NULL REFERENCES group_chats(id) ON DELETE CASCADE,
from_account INT REFERENCES accounts(id) ON DELETE SET NULL,
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_gm_group ON group_messages(group_chat_id, created_at DESC)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_gm_tenant ON group_messages(tenant_id)`)
// Seed: cria group_chat para cada setor que ainda não tem
await execute(`
INSERT INTO group_chats (tenant_id, sector_id, name)
SELECT s.tenant_id, s.id, s.name
FROM sectors s
WHERE s.active = TRUE
AND NOT EXISTS (
SELECT 1 FROM group_chats g WHERE g.sector_id = s.id
)
`)
console.log('✅ Fase 14 — Group Chats: group_chats, group_messages (seed por setor)')
// ── Broadcasts ────────────────────────────────────────────────────────────
await execute(`
CREATE TABLE IF NOT EXISTS broadcasts (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL REFERENCES tenants(id),
from_account INT REFERENCES accounts(id) ON DELETE SET NULL,
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_broadcasts_tenant ON broadcasts(tenant_id, created_at DESC)`)
console.log('✅ Fase 15 — Broadcasts: tabela broadcasts')
// ── Leitura: grupos e broadcasts ──────────────────────────────────────────
await execute(`
CREATE TABLE IF NOT EXISTS group_last_read (
account_id INT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
group_chat_id INT NOT NULL REFERENCES group_chats(id) ON DELETE CASCADE,
last_read_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (account_id, group_chat_id)
)
`)
await execute(`
CREATE TABLE IF NOT EXISTS broadcast_last_seen (
account_id INT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
tenant_id INT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (account_id, tenant_id)
)
`)
console.log('✅ Fase 16 — Leitura: group_last_read, broadcast_last_seen')
// ── Descontos por quantidade em sorteios ─────────────────────────────────
await execute(`
CREATE TABLE IF NOT EXISTS raffle_discounts (
id SERIAL PRIMARY KEY,
raffle_id INT NOT NULL REFERENCES raffles(id) ON DELETE CASCADE,
min_qty INT NOT NULL CHECK (min_qty >= 2),
discount_pct NUMERIC(5,2) NOT NULL CHECK (discount_pct > 0 AND discount_pct <= 100),
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (raffle_id, min_qty)
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_raffle_discounts_raffle ON raffle_discounts(raffle_id) WHERE active = TRUE`)
console.log('✅ raffle_discounts — descontos por quantidade verificados')
// ── Promoções Relâmpago ───────────────────────────────────────────────────
await execute(`
CREATE TABLE IF NOT EXISTS raffle_flash_promos (
id SERIAL PRIMARY KEY,
raffle_id INT NOT NULL REFERENCES raffles(id) ON DELETE CASCADE,
discount_pct NUMERIC(5,2) NOT NULL CHECK (discount_pct > 0 AND discount_pct <= 100),
label VARCHAR(255) NOT NULL DEFAULT 'Promoção Relâmpago',
starts_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ends_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_flash_promos_raffle ON raffle_flash_promos(raffle_id, ends_at)`)
console.log('✅ raffle_flash_promos — promoções relâmpago verificadas')
// ── Módulo Cotação Online ──────────────────────────────────────────────────
await execute(`
CREATE TABLE IF NOT EXISTS clientes (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL DEFAULT 1,
tipo VARCHAR(2) NOT NULL CHECK (tipo IN ('PF','PJ')),
nome VARCHAR(255) NOT NULL,
cpf_cnpj VARCHAR(20) NOT NULL,
email VARCHAR(255),
status VARCHAR(10) NOT NULL DEFAULT 'ativo' CHECK (status IN ('ativo','inativo')),
user_id INT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_clientes_tenant ON clientes(tenant_id)`)
await execute(`CREATE UNIQUE INDEX IF NOT EXISTS idx_clientes_cpf_tenant ON clientes(cpf_cnpj, tenant_id)`)
await execute(`
CREATE TABLE IF NOT EXISTS cliente_enderecos (
id SERIAL PRIMARY KEY,
cliente_id INT NOT NULL REFERENCES clientes(id) ON DELETE CASCADE,
tipo VARCHAR(20) NOT NULL DEFAULT 'entrega' CHECK (tipo IN ('entrega','cobranca','retirada','matriz','filial')),
cep VARCHAR(10),
rua VARCHAR(255),
numero VARCHAR(20),
complemento VARCHAR(100),
bairro VARCHAR(100),
cidade VARCHAR(100),
estado VARCHAR(2),
referencia VARCHAR(255),
principal BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_enderecos_cliente ON cliente_enderecos(cliente_id)`)
await execute(`
CREATE TABLE IF NOT EXISTS cliente_telefones (
id SERIAL PRIMARY KEY,
cliente_id INT NOT NULL REFERENCES clientes(id) ON DELETE CASCADE,
tipo VARCHAR(20) NOT NULL DEFAULT 'celular' CHECK (tipo IN ('comercial','celular','whatsapp','financeiro','compras')),
numero VARCHAR(20) NOT NULL,
ramal VARCHAR(10),
aceita_whatsapp BOOLEAN NOT NULL DEFAULT FALSE,
principal BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_telefones_cliente ON cliente_telefones(cliente_id)`)
await execute(`
CREATE TABLE IF NOT EXISTS cliente_responsaveis (
id SERIAL PRIMARY KEY,
cliente_id INT NOT NULL REFERENCES clientes(id) ON DELETE CASCADE,
nome VARCHAR(255) NOT NULL,
cargo VARCHAR(100),
setor VARCHAR(100),
telefone_id INT REFERENCES cliente_telefones(id) ON DELETE SET NULL,
email VARCHAR(255),
permissao VARCHAR(100),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_responsaveis_cliente ON cliente_responsaveis(cliente_id)`)
await execute(`
CREATE TABLE IF NOT EXISTS produtos (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL DEFAULT 1,
sku VARCHAR(100),
nome VARCHAR(255) NOT NULL,
descricao TEXT,
unidade VARCHAR(20) DEFAULT 'un',
preco NUMERIC(10,2) NOT NULL DEFAULT 0,
preco_promocional NUMERIC(10,2) DEFAULT NULL,
estoque NUMERIC(10,3) NOT NULL DEFAULT 0,
categoria VARCHAR(100),
imagem TEXT,
ativo BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_produtos_tenant ON produtos(tenant_id)`)
await execute(`CREATE UNIQUE INDEX IF NOT EXISTS idx_produtos_sku_tenant ON produtos(sku, tenant_id) WHERE sku IS NOT NULL`)
await execute(`
CREATE TABLE IF NOT EXISTS cotacoes (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL DEFAULT 1,
cliente_id INT REFERENCES clientes(id) ON DELETE SET NULL,
user_id INT REFERENCES users(id) ON DELETE SET NULL,
status VARCHAR(20) NOT NULL DEFAULT 'rascunho' CHECK (status IN ('rascunho','salva','finalizada','cancelada')),
forma_pagto VARCHAR(20) CHECK (forma_pagto IN ('pix','cartao','faturado')),
total NUMERIC(10,2) NOT NULL DEFAULT 0,
desconto NUMERIC(10,2) NOT NULL DEFAULT 0,
observacao TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_cotacoes_tenant ON cotacoes(tenant_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_cotacoes_cliente ON cotacoes(cliente_id)`)
await execute(`
CREATE TABLE IF NOT EXISTS cotacao_itens (
id SERIAL PRIMARY KEY,
cotacao_id INT NOT NULL REFERENCES cotacoes(id) ON DELETE CASCADE,
produto_id INT REFERENCES produtos(id) ON DELETE SET NULL,
nome_produto VARCHAR(255) NOT NULL,
sku VARCHAR(100),
quantidade NUMERIC(10,3) NOT NULL DEFAULT 1,
preco_unitario NUMERIC(10,2) NOT NULL,
preco_promocional NUMERIC(10,2),
desconto_pct NUMERIC(5,2) DEFAULT 0,
subtotal NUMERIC(10,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_cotacao_itens_cotacao ON cotacao_itens(cotacao_id)`)
await execute(`
CREATE TABLE IF NOT EXISTS pedidos (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL DEFAULT 1,
cotacao_id INT REFERENCES cotacoes(id) ON DELETE SET NULL,
cliente_id INT REFERENCES clientes(id) ON DELETE SET NULL,
user_id INT REFERENCES users(id) ON DELETE SET NULL,
endereco_id INT REFERENCES cliente_enderecos(id) ON DELETE SET NULL,
status VARCHAR(20) NOT NULL DEFAULT 'aguardando' CHECK (status IN ('aguardando','separacao','embalagem','auditoria','fiscal','expedicao','entregue','cancelado')),
forma_pagto VARCHAR(20) CHECK (forma_pagto IN ('pix','cartao','faturado')),
tipo_entrega VARCHAR(20) NOT NULL DEFAULT 'retirada' CHECK (tipo_entrega IN ('retirada','entrega')),
total NUMERIC(10,2) NOT NULL DEFAULT 0,
desconto NUMERIC(10,2) NOT NULL DEFAULT 0,
observacao TEXT,
equipe_id INT,
responsavel_entrega TEXT,
entregue_em TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_pedidos_tenant ON pedidos(tenant_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_pedidos_status ON pedidos(tenant_id, status)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_pedidos_cliente ON pedidos(cliente_id)`)
// Histórico de status — registra cada transição com timestamp
await execute(`
CREATE TABLE IF NOT EXISTS pedido_status_log (
id SERIAL PRIMARY KEY,
pedido_id INT NOT NULL REFERENCES pedidos(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL,
account_id INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_pedido_status_log ON pedido_status_log(pedido_id, created_at)`)
// Seed retroativo: garante pelo menos a entrada inicial para pedidos existentes
await execute(`
INSERT INTO pedido_status_log (pedido_id, status, created_at)
SELECT id, 'aguardando', created_at FROM pedidos
WHERE NOT EXISTS (
SELECT 1 FROM pedido_status_log l WHERE l.pedido_id = pedidos.id
)
`)
await execute(`
CREATE TABLE IF NOT EXISTS pedido_itens (
id SERIAL PRIMARY KEY,
pedido_id INT NOT NULL REFERENCES pedidos(id) ON DELETE CASCADE,
produto_id INT REFERENCES produtos(id) ON DELETE SET NULL,
nome_produto VARCHAR(255) NOT NULL,
sku VARCHAR(100),
quantidade NUMERIC(10,3) NOT NULL DEFAULT 1,
preco_unitario NUMERIC(10,2) NOT NULL,
preco_promocional NUMERIC(10,2) DEFAULT NULL,
desconto_pct NUMERIC(5,2) DEFAULT 0,
subtotal NUMERIC(10,2) NOT NULL,
separado BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_pedido_itens_pedido ON pedido_itens(pedido_id)`)
// promotions: garantir tenant_id e ativo BOOLEAN (produto_id removido — promoções são marketing independente)
await execute(`ALTER TABLE promotions ADD COLUMN IF NOT EXISTS tenant_id INT`)
await execute(`ALTER TABLE promotions ALTER COLUMN ativo DROP DEFAULT`)
await execute(`ALTER TABLE promotions ALTER COLUMN ativo TYPE BOOLEAN USING (CASE WHEN ativo::text IN ('1','true') THEN TRUE ELSE FALSE END)`)
await execute(`ALTER TABLE promotions ALTER COLUMN ativo SET DEFAULT TRUE`)
// novos campos de produto e pedido_itens
await execute(`ALTER TABLE produtos ADD COLUMN IF NOT EXISTS preco_promocional NUMERIC(10,2) DEFAULT NULL`)
await execute(`ALTER TABLE pedido_itens ADD COLUMN IF NOT EXISTS preco_promocional NUMERIC(10,2) DEFAULT NULL`)
// EMB-02 — registro de embalador e timestamp
await execute(`ALTER TABLE pedidos ADD COLUMN IF NOT EXISTS embalador TEXT`)
await execute(`ALTER TABLE pedidos ADD COLUMN IF NOT EXISTS embalado_em TIMESTAMPTZ`)
// WPP-10 — número de protocolo por pedido (PED-YYYYMMDD-ID)
await execute(`ALTER TABLE pedidos ADD COLUMN IF NOT EXISTS protocolo VARCHAR(30)`)
// PROMO-10 — promoções relâmpago para cotações (reutiliza padrão raffle_flash_promos)
await execute(`
CREATE TABLE IF NOT EXISTS cotacao_flash_promos (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL DEFAULT 1,
produto_id INT REFERENCES produtos(id) ON DELETE SET NULL,
discount_pct NUMERIC(5,2) NOT NULL,
label VARCHAR(120) NOT NULL DEFAULT 'Promoção Relâmpago',
starts_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ends_at TIMESTAMPTZ NOT NULL,
ativo BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_cfp_tenant ON cotacao_flash_promos(tenant_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_cfp_ativo ON cotacao_flash_promos(tenant_id, ativo) WHERE ativo = TRUE`)
// WPP-09 — log de mensagens de reativação enviadas
await execute(`
CREATE TABLE IF NOT EXISTS wpp_reativacao_log (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL DEFAULT 1,
cliente_id INT REFERENCES clientes(id) ON DELETE SET NULL,
chat_id TEXT NOT NULL,
mensagem TEXT,
enviada_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_wpp_reat_tenant ON wpp_reativacao_log(tenant_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_wpp_reat_cliente ON wpp_reativacao_log(cliente_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_wpp_reat_chat ON wpp_reativacao_log(chat_id)`)
// INB-01/02/03 — Inbox interno por setor vinculado ao pedido
await execute(`
CREATE TABLE IF NOT EXISTS mensagens_internas (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL DEFAULT 1,
pedido_id INT REFERENCES pedidos(id) ON DELETE CASCADE,
setor VARCHAR(30) NOT NULL DEFAULT 'geral'
CHECK (setor IN ('geral','atendimento','cotacao','separacao','embalagem','auditoria','fiscal','expedicao','financeiro','suporte')),
remetente VARCHAR(120) NOT NULL,
mensagem TEXT NOT NULL,
lida BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_msg_int_tenant ON mensagens_internas(tenant_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_msg_int_pedido ON mensagens_internas(pedido_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_msg_int_setor ON mensagens_internas(tenant_id, setor)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_msg_int_nao_lida ON mensagens_internas(tenant_id, lida) WHERE lida = FALSE`)
// Migração: coluna chat_id para vincular alertas do Monitor IA ao chat WhatsApp
await execute(`ALTER TABLE mensagens_internas ADD COLUMN IF NOT EXISTS chat_id VARCHAR(120)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_msg_int_chat_id ON mensagens_internas(chat_id) WHERE chat_id IS NOT NULL`)
// Observação por item do carrinho (variedades, tamanhos, etc.)
await execute(`ALTER TABLE cotacao_itens ADD COLUMN IF NOT EXISTS observacao VARCHAR(500)`)
await execute(`ALTER TABLE pedido_itens ADD COLUMN IF NOT EXISTS observacao VARCHAR(500)`)
// chat_id em human_api_requests — permite Monitor IA suprimir re-alertas
await execute(`ALTER TABLE human_api_requests ADD COLUMN IF NOT EXISTS chat_id VARCHAR(120)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_har_chat_id ON human_api_requests(chat_id) WHERE chat_id IS NOT NULL`)
// Alertas de item pelo separador (falta, marca diferente, substituição + foto)
await execute(`ALTER TABLE pedido_itens ADD COLUMN IF NOT EXISTS alerta_tipo VARCHAR(30)`)
await execute(`ALTER TABLE pedido_itens ADD COLUMN IF NOT EXISTS alerta_obs VARCHAR(500)`)
await execute(`ALTER TABLE pedido_itens ADD COLUMN IF NOT EXISTS alerta_foto VARCHAR(500)`)
await execute(`ALTER TABLE pedido_itens ADD COLUMN IF NOT EXISTS alerta_confirmado BOOLEAN DEFAULT FALSE`)
await execute(`ALTER TABLE pedido_itens ADD COLUMN IF NOT EXISTS alerta_em TIMESTAMPTZ`)
// ── FUT-02: Pedidos Recorrentes (recompra automática PJ) ─────────────────────
await execute(`
CREATE TABLE IF NOT EXISTS pedidos_recorrentes (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL DEFAULT 1,
user_id INT REFERENCES users(id) ON DELETE CASCADE,
cliente_id INT REFERENCES clientes(id) ON DELETE SET NULL,
nome VARCHAR(200) NOT NULL DEFAULT 'Pedido Recorrente',
itens JSONB NOT NULL DEFAULT '[]',
frequencia VARCHAR(20) NOT NULL DEFAULT 'mensal',
proximo_em DATE NOT NULL,
ativo BOOLEAN NOT NULL DEFAULT TRUE,
pausado BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`)
await execute(`CREATE INDEX IF NOT EXISTS idx_rec_tenant ON pedidos_recorrentes(tenant_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_rec_user ON pedidos_recorrentes(user_id)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_rec_prox ON pedidos_recorrentes(proximo_em) WHERE ativo = TRUE AND pausado = FALSE`)
console.log('✅ Módulo Cotação Online — tabelas verificadas')
// ── Colaboradores Bridge — vínculo WA-Inbox + FK de embalador ────────────
await execute(`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS nw_team_id TEXT`)
await execute(`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS nw_sector_id TEXT`)
await execute(`ALTER TABLE pedidos ADD COLUMN IF NOT EXISTS embalador_id BIGINT REFERENCES accounts(id) ON DELETE SET NULL`)
await execute(`ALTER TABLE pedidos ADD COLUMN IF NOT EXISTS expedidor_id BIGINT REFERENCES accounts(id) ON DELETE SET NULL`)
await execute(`CREATE INDEX IF NOT EXISTS idx_pedidos_embalador ON pedidos(embalador_id) WHERE embalador_id IS NOT NULL`)
await execute(`CREATE INDEX IF NOT EXISTS idx_pedidos_expedidor ON pedidos(expedidor_id) WHERE expedidor_id IS NOT NULL`)
// Migração best-effort: texto livre → FK para registros já existentes
await execute(`
UPDATE pedidos p SET embalador_id = a.id
FROM accounts a
WHERE a.tenant_id = p.tenant_id
AND LOWER(TRIM(a.name)) = LOWER(TRIM(p.embalador))
AND p.embalador IS NOT NULL
AND p.embalador_id IS NULL
`)
console.log('✅ Colaboradores Bridge — nw_team_id, embalador_id, expedidor_id verificados')
// ── Cotação Funções Operacionais — separador/auditor/fiscal ──────────────
// Cada etapa do fluxo (separação, embalagem, auditoria, fiscal, expedição) é
// atendida pelos colaboradores cujo setor (sectors.name via team_members)
// bate com o nome da função. Sem tabela de mapeamento — o setor já é a fonte
// da verdade, gerenciado em /admin-v2/equipe.
await execute(`ALTER TABLE pedidos ADD COLUMN IF NOT EXISTS separador_id BIGINT REFERENCES accounts(id) ON DELETE SET NULL`)
await execute(`ALTER TABLE pedidos ADD COLUMN IF NOT EXISTS auditor_id BIGINT REFERENCES accounts(id) ON DELETE SET NULL`)
await execute(`ALTER TABLE pedidos ADD COLUMN IF NOT EXISTS fiscal_id BIGINT REFERENCES accounts(id) ON DELETE SET NULL`)
await execute(`CREATE INDEX IF NOT EXISTS idx_pedidos_separador ON pedidos(separador_id) WHERE separador_id IS NOT NULL`)
await execute(`CREATE INDEX IF NOT EXISTS idx_pedidos_auditor ON pedidos(auditor_id) WHERE auditor_id IS NOT NULL`)
await execute(`CREATE INDEX IF NOT EXISTS idx_pedidos_fiscal ON pedidos(fiscal_id) WHERE fiscal_id IS NOT NULL`)
console.log('✅ Cotação Funções — separador_id, auditor_id, fiscal_id verificados')
// ── Cupom fiscal + saída do entregador ────────────────────────────────────
// Sem integração SEFAZ: registro manual do número/chave de acesso do cupom
// emitido externamente (PDV/ECF). Permite emitir em qualquer fase pós-pagto;
// a coluna emitido_por mantém quem clicou no botão para auditoria.
// expedidor_saida_em é setado automaticamente quando o pedido entra em
// 'expedicao' — é o "horário de saída do entregador".
await execute(`ALTER TABLE pedidos ADD COLUMN IF NOT EXISTS cupom_numero VARCHAR(60)`)
await execute(`ALTER TABLE pedidos ADD COLUMN IF NOT EXISTS cupom_chave VARCHAR(64)`)
await execute(`ALTER TABLE pedidos ADD COLUMN IF NOT EXISTS cupom_emitido_em TIMESTAMPTZ`)
await execute(`ALTER TABLE pedidos ADD COLUMN IF NOT EXISTS cupom_emitido_por BIGINT REFERENCES accounts(id) ON DELETE SET NULL`)
await execute(`ALTER TABLE pedidos ADD COLUMN IF NOT EXISTS expedidor_saida_em TIMESTAMPTZ`)
console.log('✅ Cotação Fiscal — cupom_numero, cupom_chave, cupom_emitido_em, cupom_emitido_por, expedidor_saida_em verificados')
// ── Captive Portal Integration ─────────────────────────────────────────────
// Armazena o session_id gerado pelo sistema-local para rastrear qual usuário
// confirmou a landing page e liberou a internet.
await execute(`ALTER TABLE users ADD COLUMN IF NOT EXISTS portal_session_id VARCHAR(64)`)
await execute(`CREATE INDEX IF NOT EXISTS idx_users_portal_session ON users(portal_session_id) WHERE portal_session_id IS NOT NULL`)
console.log('✅ Captive Portal — coluna portal_session_id verificada')
}
// saveDatabase é no-op no PostgreSQL (era necessário apenas no sql.js)
function saveDatabase() {}
module.exports = {
initialize,
saveDatabase,
// Para compatibilidade com plugins que ainda usam a API síncrona do sql.js:
runQuery: (...args) => { throw new Error('Use execute() do postgres.js diretamente') },
getOne: (...args) => { throw new Error('Use queryOne() do postgres.js diretamente') },
getAll: (...args) => { throw new Error('Use query() do postgres.js diretamente') },
}