chore(ops): migrate clube67 to newwhats.clube67.com directory
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.db = void 0;
|
||||
exports.ensurePluginTables = ensurePluginTables;
|
||||
const knex_1 = __importDefault(require("knex"));
|
||||
const env_1 = require("../config/env");
|
||||
const logger_1 = require("../config/logger");
|
||||
/**
|
||||
* Instância Knex compartilhada entre todos os plugins.
|
||||
* Usa o mesmo DATABASE_URL do Prisma — mesmo banco, mesma conexão pool.
|
||||
* Prisma cuida das tabelas core; Knex cuida das tabelas dinâmicas dos plugins.
|
||||
*/
|
||||
function createKnex() {
|
||||
// DATABASE_URL formato: postgresql://user:pass@host:port/db
|
||||
const connection = env_1.env.DATABASE_URL;
|
||||
const instance = (0, knex_1.default)({
|
||||
client: 'pg',
|
||||
connection,
|
||||
pool: { min: 1, max: 5 },
|
||||
acquireConnectionTimeout: 10000,
|
||||
});
|
||||
instance.on('query-error', (err, query) => {
|
||||
logger_1.logger.error({ err, sql: query.sql }, '[Knex] Query error');
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
exports.db = createKnex();
|
||||
/** Garante que a tabela de log do janitor existe (usada por UnifiedStorageProvider) */
|
||||
async function ensurePluginTables(knexInstance) {
|
||||
const hasJanitorLogs = await knexInstance.schema.hasTable('janitor_logs');
|
||||
if (!hasJanitorLogs) {
|
||||
await knexInstance.schema.createTable('janitor_logs', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('filename').notNullable();
|
||||
t.string('category').notNullable();
|
||||
t.string('status').notNullable();
|
||||
t.text('message').nullable();
|
||||
t.timestamps(true, true);
|
||||
});
|
||||
logger_1.logger.info('[Knex] Tabela janitor_logs criada');
|
||||
}
|
||||
const hasNanobanaUsage = await knexInstance.schema.hasTable('nanobana_usage');
|
||||
if (!hasNanobanaUsage) {
|
||||
await knexInstance.schema.createTable('nanobana_usage', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('partner_id').notNullable();
|
||||
t.string('type').notNullable();
|
||||
t.timestamps(true, true);
|
||||
});
|
||||
logger_1.logger.info('[Knex] Tabela nanobana_usage criada');
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=db.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"db.js","sourceRoot":"","sources":["db.ts"],"names":[],"mappings":";;;;;;AA8BA,gDAwBC;AAtDD,gDAAiC;AACjC,uCAAmC;AACnC,6CAAuD;AAEvD;;;;GAIG;AACH,SAAS,UAAU;IACjB,4DAA4D;IAC5D,MAAM,UAAU,GAAG,SAAG,CAAC,YAAY,CAAA;IAEnC,MAAM,QAAQ,GAAG,IAAA,cAAI,EAAC;QACpB,MAAM,EAAE,IAAI;QACZ,UAAU;QACV,IAAI,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE;QACxB,wBAAwB,EAAE,KAAK;KAChC,CAAC,CAAA;IAEF,QAAQ,CAAC,EAAE,CAAC,aAAa,EAAE,CAAC,GAAU,EAAE,KAAsB,EAAE,EAAE;QAChE,eAAU,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,GAAG,EAAE,KAAK,CAAC,GAAG,EAAE,EAAE,oBAAoB,CAAC,CAAA;IACjE,CAAC,CAAC,CAAA;IAEF,OAAO,QAAQ,CAAA;AACjB,CAAC;AAEY,QAAA,EAAE,GAAG,UAAU,EAAE,CAAA;AAE9B,uFAAuF;AAChF,KAAK,UAAU,kBAAkB,CAAC,YAAkB;IACzD,MAAM,cAAc,GAAG,MAAM,YAAY,CAAC,MAAM,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAA;IACzE,IAAI,CAAC,cAAc,EAAE,CAAC;QACpB,MAAM,YAAY,CAAC,MAAM,CAAC,WAAW,CAAC,cAAc,EAAE,CAAC,CAAC,EAAE,EAAE;YAC1D,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,CAAA;YAC5B,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,WAAW,EAAE,CAAA;YAClC,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,WAAW,EAAE,CAAA;YAClC,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,WAAW,EAAE,CAAA;YAChC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,QAAQ,EAAE,CAAA;YAC5B,CAAC,CAAC,UAAU,CAAC,IAAI,EAAE,IAAI,CAAC,CAAA;QAC1B,CAAC,CAAC,CAAA;QACF,eAAU,CAAC,IAAI,CAAC,mCAAmC,CAAC,CAAA;IACtD,CAAC;IAED,MAAM,gBAAgB,GAAG,MAAM,YAAY,CAAC,MAAM,CAAC,QAAQ,CAAC,gBAAgB,CAAC,CAAA;IAC7E,IAAI,CAAC,gBAAgB,EAAE,CAAC;QACtB,MAAM,YAAY,CAAC,MAAM,CAAC,WAAW,CAAC,gBAAgB,EAAE,CAAC,CAAC,EAAE,EAAE;YAC5D,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,CAAA;YAC5B,CAAC,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC,WAAW,EAAE,CAAA;YACpC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,WAAW,EAAE,CAAA;YAC9B,CAAC,CAAC,UAAU,CAAC,IAAI,EAAE,IAAI,CAAC,CAAA;QAC1B,CAAC,CAAC,CAAA;QACF,eAAU,CAAC,IAAI,CAAC,qCAAqC,CAAC,CAAA;IACxD,CAAC;AACH,CAAC"}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.loadPlugins = loadPlugins;
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const logger_1 = require("../config/logger");
|
||||
const PLUGINS_DIR = process.env.NODE_ENV === 'production'
|
||||
? '/app/plugins'
|
||||
: path_1.default.resolve(__dirname, '../../../plugins');
|
||||
/**
|
||||
* Ordena plugins por dependências usando topological sort (Kahn's algorithm).
|
||||
* Plugins sem dependências vêm primeiro; core plugins antes dos business plugins.
|
||||
*/
|
||||
function topoSort(plugins) {
|
||||
const byName = new Map(plugins.map((p) => [p.manifest.name, p]));
|
||||
const inDegree = new Map();
|
||||
const dependents = new Map();
|
||||
for (const p of plugins) {
|
||||
inDegree.set(p.manifest.name, 0);
|
||||
dependents.set(p.manifest.name, []);
|
||||
}
|
||||
for (const p of plugins) {
|
||||
for (const dep of p.manifest.dependencies) {
|
||||
if (!byName.has(dep)) {
|
||||
logger_1.logger.warn({ plugin: p.manifest.name, dep }, '[PluginLoader] Dependência não encontrada');
|
||||
continue;
|
||||
}
|
||||
dependents.get(dep).push(p.manifest.name);
|
||||
inDegree.set(p.manifest.name, (inDegree.get(p.manifest.name) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const queue = plugins
|
||||
.filter((p) => inDegree.get(p.manifest.name) === 0)
|
||||
.sort((a, b) => (a.manifest.category === 'core' ? -1 : 1) - (b.manifest.category === 'core' ? -1 : 1));
|
||||
const sorted = [];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
sorted.push(current);
|
||||
for (const depName of dependents.get(current.manifest.name) ?? []) {
|
||||
const newDegree = (inDegree.get(depName) ?? 1) - 1;
|
||||
inDegree.set(depName, newDegree);
|
||||
if (newDegree === 0) {
|
||||
queue.push(byName.get(depName));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sorted.length !== plugins.length) {
|
||||
logger_1.logger.error('[PluginLoader] Dependência circular detectada — carregando na ordem original');
|
||||
return plugins;
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
/**
|
||||
* Escaneia o diretório /plugins, lê cada manifest.json e importa index.ts/js.
|
||||
* Retorna plugins ordenados por dependência.
|
||||
*/
|
||||
async function loadPlugins() {
|
||||
if (!fs_1.default.existsSync(PLUGINS_DIR)) {
|
||||
logger_1.logger.warn({ dir: PLUGINS_DIR }, '[PluginLoader] Diretório plugins/ não encontrado');
|
||||
return [];
|
||||
}
|
||||
const entries = fs_1.default.readdirSync(PLUGINS_DIR, { withFileTypes: true });
|
||||
const pluginDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
const loaded = [];
|
||||
for (const dirName of pluginDirs) {
|
||||
const pluginDir = path_1.default.join(PLUGINS_DIR, dirName);
|
||||
const manifestPath = path_1.default.join(pluginDir, 'manifest.json');
|
||||
const indexTs = path_1.default.join(pluginDir, 'index.ts');
|
||||
const indexJs = path_1.default.join(pluginDir, 'index.js');
|
||||
if (!fs_1.default.existsSync(manifestPath)) {
|
||||
logger_1.logger.warn({ dir: dirName }, '[PluginLoader] manifest.json não encontrado — pulando');
|
||||
continue;
|
||||
}
|
||||
// Prefere .js (produção compilada); fallback para .ts (tsx watch em dev)
|
||||
const entryPoint = fs_1.default.existsSync(indexJs) ? indexJs : fs_1.default.existsSync(indexTs) ? indexTs : null;
|
||||
if (!entryPoint) {
|
||||
logger_1.logger.warn({ dir: dirName }, '[PluginLoader] index.ts/js não encontrado — pulando');
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const manifest = JSON.parse(fs_1.default.readFileSync(manifestPath, 'utf-8'));
|
||||
const mod = await Promise.resolve(`${entryPoint}`).then(s => __importStar(require(s)));
|
||||
const instance = mod.default ?? mod;
|
||||
if (typeof instance.activate !== 'function') {
|
||||
logger_1.logger.warn({ dir: dirName }, '[PluginLoader] Plugin sem método activate() — pulando');
|
||||
continue;
|
||||
}
|
||||
loaded.push({ instance, manifest, dir: pluginDir });
|
||||
logger_1.logger.info({ name: manifest.name, version: manifest.version }, '[PluginLoader] Plugin carregado');
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, dir: dirName }, '[PluginLoader] Falha ao carregar plugin');
|
||||
}
|
||||
}
|
||||
return topoSort(loaded);
|
||||
}
|
||||
//# sourceMappingURL=plugin-loader.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"plugin-loader.js","sourceRoot":"","sources":["plugin-loader.ts"],"names":[],"mappings":"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"}
|
||||
@@ -0,0 +1,113 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.pluginRegistry = void 0;
|
||||
const logger_1 = require("../config/logger");
|
||||
/**
|
||||
* Registry central dos plugins.
|
||||
* Gerencia o lifecycle (activate/deactivate) e expõe metadados para a API admin.
|
||||
*/
|
||||
class PluginRegistry {
|
||||
constructor() {
|
||||
this.entries = new Map();
|
||||
}
|
||||
/** Deve ser chamado uma vez no bootstrap com o contexto completo */
|
||||
setContext(ctx) {
|
||||
this.ctx = ctx;
|
||||
}
|
||||
/** Registra plugins carregados (sem ativar ainda) */
|
||||
register(plugins) {
|
||||
for (const { instance, manifest, dir } of plugins) {
|
||||
this.entries.set(manifest.name, { instance, manifest, active: false, dir });
|
||||
}
|
||||
}
|
||||
/** Ativa todos os plugins marcados como enabled no manifest.
|
||||
* Plugins com dependências faltando são pulados com warning (não crasham o servidor). */
|
||||
async activateAll() {
|
||||
for (const [name, entry] of this.entries) {
|
||||
if (!entry.manifest.enabled)
|
||||
continue;
|
||||
try {
|
||||
await this.activate(name);
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.warn({ name, err: err.message }, '[PluginRegistry] Plugin pulado na inicialização');
|
||||
}
|
||||
}
|
||||
}
|
||||
async activate(name) {
|
||||
const entry = this.entries.get(name);
|
||||
if (!entry)
|
||||
throw new Error(`Plugin "${name}" não encontrado`);
|
||||
if (entry.active)
|
||||
return;
|
||||
// Verifica dependências
|
||||
for (const dep of entry.manifest.dependencies) {
|
||||
const depEntry = this.entries.get(dep);
|
||||
if (!depEntry?.active) {
|
||||
throw new Error(`Dependência "${dep}" do plugin "${name}" não está ativa`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await entry.instance.activate(this.ctx);
|
||||
entry.active = true;
|
||||
entry.manifest.enabled = true;
|
||||
logger_1.logger.info({ name }, '[PluginRegistry] Plugin ativado');
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, name }, '[PluginRegistry] Falha ao ativar plugin');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
async deactivate(name) {
|
||||
const entry = this.entries.get(name);
|
||||
if (!entry)
|
||||
throw new Error(`Plugin "${name}" não encontrado`);
|
||||
if (!entry.active)
|
||||
return;
|
||||
if (!entry.manifest.canDisable)
|
||||
throw new Error(`Plugin "${name}" não pode ser desativado`);
|
||||
// Verifica se algum plugin ativo depende deste
|
||||
for (const [depName, depEntry] of this.entries) {
|
||||
if (depEntry.active && depEntry.manifest.dependencies.includes(name)) {
|
||||
throw new Error(`Plugin "${depName}" depende de "${name}". Desative-o primeiro.`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await entry.instance.deactivate(this.ctx);
|
||||
entry.active = false;
|
||||
entry.manifest.enabled = false;
|
||||
logger_1.logger.info({ name }, '[PluginRegistry] Plugin desativado');
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, name }, '[PluginRegistry] Falha ao desativar plugin');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
/** Retorna lista de todos os plugins com status para a API admin */
|
||||
list() {
|
||||
return Array.from(this.entries.entries()).map(([name, entry]) => ({ name, ...entry }));
|
||||
}
|
||||
getEntry(name) {
|
||||
return this.entries.get(name);
|
||||
}
|
||||
/**
|
||||
* Força reativação de um plugin sem passar pelo deactivate.
|
||||
* Útil quando canDisable=false mas a config foi atualizada e precisa ser relida.
|
||||
*/
|
||||
async reactivate(name) {
|
||||
const entry = this.entries.get(name);
|
||||
if (!entry)
|
||||
throw new Error(`Plugin "${name}" não encontrado`);
|
||||
entry.active = false;
|
||||
await this.activate(name);
|
||||
logger_1.logger.info({ name }, '[PluginRegistry] Plugin reativado com nova config');
|
||||
}
|
||||
/** Retorna menu items de todos os plugins ativos */
|
||||
getActiveMenuItems() {
|
||||
return Array.from(this.entries.values())
|
||||
.filter((e) => e.active && e.manifest.frontend?.menuItems?.length)
|
||||
.map((e) => ({ pluginName: e.manifest.name, items: e.manifest.frontend }));
|
||||
}
|
||||
}
|
||||
exports.pluginRegistry = new PluginRegistry();
|
||||
//# sourceMappingURL=plugin-registry.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"plugin-registry.js","sourceRoot":"","sources":["plugin-registry.ts"],"names":[],"mappings":"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"}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildSocketServer = buildSocketServer;
|
||||
const socket_io_1 = require("socket.io");
|
||||
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
||||
const env_1 = require("../../config/env");
|
||||
const logger_1 = require("../../config/logger");
|
||||
const dragonfly_1 = require("../cache/dragonfly");
|
||||
// TTL em segundos: 70 = 2× o heartbeat do frontend (30s) + margem
|
||||
const PRESENCE_TTL = 70;
|
||||
function buildSocketServer(httpServer) {
|
||||
const io = new socket_io_1.Server(httpServer, {
|
||||
cors: {
|
||||
origin: env_1.env.FRONTEND_URL,
|
||||
credentials: true,
|
||||
},
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
// Autenticação por token JWT no handshake
|
||||
io.use((socket, next) => {
|
||||
const token = socket.handshake.auth?.token;
|
||||
if (!token) {
|
||||
next(new Error('Token não fornecido'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = jsonwebtoken_1.default.verify(token, env_1.env.JWT_SECRET);
|
||||
socket.data.userId = payload.sub;
|
||||
socket.data.role = payload.role;
|
||||
next();
|
||||
}
|
||||
catch {
|
||||
next(new Error('Token inválido'));
|
||||
}
|
||||
});
|
||||
io.on('connection', (socket) => {
|
||||
const userId = socket.data.userId;
|
||||
logger_1.logger.debug({ userId, socketId: socket.id }, 'Socket conectado');
|
||||
// Cliente entra na sala do seu tenant automaticamente
|
||||
socket.join(`tenant:${userId}`);
|
||||
// Registra presença do usuário no Dragonfly com TTL
|
||||
dragonfly_1.dragonfly.set(`presence:user:${userId}`, socket.id, PRESENCE_TTL).catch(() => { });
|
||||
// Cliente pode entrar em salas específicas de instância ou chat
|
||||
socket.on('join:instance', (instanceId) => {
|
||||
socket.join(`instance:${instanceId}`);
|
||||
});
|
||||
socket.on('join:chat', (chatId) => {
|
||||
socket.join(`chat:${chatId}`);
|
||||
});
|
||||
socket.on('leave:chat', (chatId) => {
|
||||
socket.leave(`chat:${chatId}`);
|
||||
});
|
||||
// Heartbeat do frontend a cada 30s — renova TTL de presença
|
||||
socket.on('user:heartbeat', () => {
|
||||
dragonfly_1.dragonfly.set(`presence:user:${userId}`, socket.id, PRESENCE_TTL).catch(() => { });
|
||||
});
|
||||
socket.on('disconnect', (reason) => {
|
||||
logger_1.logger.debug({ userId, reason }, 'Socket desconectado');
|
||||
// Remove presença imediatamente ao desconectar
|
||||
dragonfly_1.dragonfly.del(`presence:user:${userId}`).catch(() => { });
|
||||
});
|
||||
});
|
||||
return io;
|
||||
}
|
||||
//# sourceMappingURL=socketServer.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"socketServer.js","sourceRoot":"","sources":["socketServer.ts"],"names":[],"mappings":"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"}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.liberarSlotAgenda = liberarSlotAgenda;
|
||||
exports.notificarConflito = notificarConflito;
|
||||
exports.decrementarScore = decrementarScore;
|
||||
exports.bloquearAgendamentosAutomaticos = bloquearAgendamentosAutomaticos;
|
||||
exports.buscarScoreContato = buscarScoreContato;
|
||||
/**
|
||||
* Temporal Activities — operações com efeitos colaterais (banco, APIs, I/O).
|
||||
* Activities são executadas pelo Worker e podem ser re-tentadas automaticamente.
|
||||
*/
|
||||
const prisma_1 = require("../database/prisma");
|
||||
const logger_1 = require("../../config/logger");
|
||||
// ─── Conflito de Agenda: liberar slot após timeout ────────────────────────────
|
||||
async function liberarSlotAgenda(input) {
|
||||
logger_1.logger.info(input, '[Temporal] Slot liberado por timeout — notificando solicitante');
|
||||
// Aqui você envia mensagem via WhatsApp avisando o solicitante
|
||||
// Implementação real acoplada ao WhatsAppConnectionManager via NATS
|
||||
// Por ora, marca no banco como disponível
|
||||
await prisma_1.prisma.protocol.updateMany({
|
||||
where: {
|
||||
contactId: input.solicitanteId,
|
||||
status: 'WAITING_CLIENT',
|
||||
},
|
||||
data: { status: 'OPEN' },
|
||||
});
|
||||
}
|
||||
async function notificarConflito(input) {
|
||||
logger_1.logger.info(input, '[Temporal] Notificando pessoa B sobre solicitação de slot');
|
||||
// Dispara mensagem via NATS → WhatsApp handler
|
||||
}
|
||||
// ─── Gestão de Reputação ──────────────────────────────────────────────────────
|
||||
async function decrementarScore(input) {
|
||||
const contact = await prisma_1.prisma.contact.findUnique({
|
||||
where: { id: input.contactId },
|
||||
});
|
||||
if (!contact)
|
||||
throw new Error(`Contato ${input.contactId} não encontrado`);
|
||||
const novoScore = Math.max(0, contact.scoreReputacao - input.pontos);
|
||||
const restrito = novoScore <= 20;
|
||||
await prisma_1.prisma.contact.update({
|
||||
where: { id: input.contactId },
|
||||
data: {
|
||||
scoreReputacao: novoScore,
|
||||
flagRestricao: restrito,
|
||||
},
|
||||
});
|
||||
logger_1.logger.info({ contactId: input.contactId, novoScore, restrito, motivo: input.motivo }, '[Temporal] Score de reputação atualizado');
|
||||
return { novoScore, restrito };
|
||||
}
|
||||
async function bloquearAgendamentosAutomaticos(input) {
|
||||
await prisma_1.prisma.contact.update({
|
||||
where: { id: input.contactId },
|
||||
data: { flagRestricao: true },
|
||||
});
|
||||
logger_1.logger.warn({ contactId: input.contactId }, '[Temporal] Contato marcado como RESTRITO — agendamentos automáticos bloqueados');
|
||||
}
|
||||
async function buscarScoreContato(contactId) {
|
||||
const c = await prisma_1.prisma.contact.findUnique({
|
||||
where: { id: contactId },
|
||||
select: { scoreReputacao: true, flagRestricao: true },
|
||||
});
|
||||
return { score: c?.scoreReputacao ?? 100, restrito: c?.flagRestricao ?? false };
|
||||
}
|
||||
//# sourceMappingURL=activities.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"activities.js","sourceRoot":"","sources":["activities.ts"],"names":[],"mappings":";;AASA,8CAiBC;AAED,8CAOC;AAID,4CAyBC;AAED,0EASC;AAED,gDASC;AAtFD;;;GAGG;AACH,+CAA2C;AAC3C,gDAA4C;AAE5C,iFAAiF;AAE1E,KAAK,UAAU,iBAAiB,CAAC,KAIvC;IACC,eAAM,CAAC,IAAI,CAAC,KAAK,EAAE,gEAAgE,CAAC,CAAA;IAEpF,+DAA+D;IAC/D,oEAAoE;IACpE,0CAA0C;IAC1C,MAAM,eAAM,CAAC,QAAQ,CAAC,UAAU,CAAC;QAC/B,KAAK,EAAE;YACL,SAAS,EAAE,KAAK,CAAC,aAAa;YAC9B,MAAM,EAAE,gBAAgB;SACzB;QACD,IAAI,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE;KACzB,CAAC,CAAA;AACJ,CAAC;AAEM,KAAK,UAAU,iBAAiB,CAAC,KAIvC;IACC,eAAM,CAAC,IAAI,CAAC,KAAK,EAAE,2DAA2D,CAAC,CAAA;IAC/E,+CAA+C;AACjD,CAAC;AAED,iFAAiF;AAE1E,KAAK,UAAU,gBAAgB,CAAC,KAItC;IACC,MAAM,OAAO,GAAG,MAAM,eAAM,CAAC,OAAO,CAAC,UAAU,CAAC;QAC9C,KAAK,EAAE,EAAE,EAAE,EAAE,KAAK,CAAC,SAAS,EAAE;KAC/B,CAAC,CAAA;IACF,IAAI,CAAC,OAAO;QAAE,MAAM,IAAI,KAAK,CAAC,WAAW,KAAK,CAAC,SAAS,iBAAiB,CAAC,CAAA;IAE1E,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,CAAC,cAAc,GAAG,KAAK,CAAC,MAAM,CAAC,CAAA;IACpE,MAAM,QAAQ,GAAG,SAAS,IAAI,EAAE,CAAA;IAEhC,MAAM,eAAM,CAAC,OAAO,CAAC,MAAM,CAAC;QAC1B,KAAK,EAAE,EAAE,EAAE,EAAE,KAAK,CAAC,SAAS,EAAE;QAC9B,IAAI,EAAE;YACJ,cAAc,EAAE,SAAS;YACzB,aAAa,EAAE,QAAQ;SACxB;KACF,CAAC,CAAA;IAEF,eAAM,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,EACnF,0CAA0C,CAAC,CAAA;IAE7C,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAA;AAChC,CAAC;AAEM,KAAK,UAAU,+BAA+B,CAAC,KAErD;IACC,MAAM,eAAM,CAAC,OAAO,CAAC,MAAM,CAAC;QAC1B,KAAK,EAAE,EAAE,EAAE,EAAE,KAAK,CAAC,SAAS,EAAE;QAC9B,IAAI,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE;KAC9B,CAAC,CAAA;IACF,eAAM,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,EAAE,EACxC,gFAAgF,CAAC,CAAA;AACrF,CAAC;AAEM,KAAK,UAAU,kBAAkB,CAAC,SAAiB;IAIxD,MAAM,CAAC,GAAG,MAAM,eAAM,CAAC,OAAO,CAAC,UAAU,CAAC;QACxC,KAAK,EAAE,EAAE,EAAE,EAAE,SAAS,EAAE;QACxB,MAAM,EAAE,EAAE,cAAc,EAAE,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE;KACtD,CAAC,CAAA;IACF,OAAO,EAAE,KAAK,EAAE,CAAC,EAAE,cAAc,IAAI,GAAG,EAAE,QAAQ,EAAE,CAAC,EAAE,aAAa,IAAI,KAAK,EAAE,CAAA;AACjF,CAAC"}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.getTemporalClient = getTemporalClient;
|
||||
const client_1 = require("@temporalio/client");
|
||||
const env_1 = require("../../config/env");
|
||||
const logger_1 = require("../../config/logger");
|
||||
let _client = null;
|
||||
async function getTemporalClient() {
|
||||
if (_client)
|
||||
return _client;
|
||||
const connection = await client_1.Connection.connect({ address: env_1.env.TEMPORAL_ADDRESS });
|
||||
_client = new client_1.Client({
|
||||
connection,
|
||||
namespace: env_1.env.TEMPORAL_NAMESPACE,
|
||||
});
|
||||
logger_1.logger.info({ address: env_1.env.TEMPORAL_ADDRESS }, 'Temporal Client conectado');
|
||||
return _client;
|
||||
}
|
||||
//# sourceMappingURL=temporalClient.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"temporalClient.js","sourceRoot":"","sources":["temporalClient.ts"],"names":[],"mappings":";;AAMA,8CAYC;AAlBD,+CAAuD;AACvD,0CAAsC;AACtC,gDAA4C;AAE5C,IAAI,OAAO,GAAkB,IAAI,CAAA;AAE1B,KAAK,UAAU,iBAAiB;IACrC,IAAI,OAAO;QAAE,OAAO,OAAO,CAAA;IAE3B,MAAM,UAAU,GAAG,MAAM,mBAAU,CAAC,OAAO,CAAC,EAAE,OAAO,EAAE,SAAG,CAAC,gBAAgB,EAAE,CAAC,CAAA;IAE9E,OAAO,GAAG,IAAI,eAAM,CAAC;QACnB,UAAU;QACV,SAAS,EAAE,SAAG,CAAC,kBAAkB;KAClC,CAAC,CAAA;IAEF,eAAM,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,SAAG,CAAC,gBAAgB,EAAE,EAAE,2BAA2B,CAAC,CAAA;IAC3E,OAAO,OAAO,CAAA;AAChB,CAAC"}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
/**
|
||||
* Worker do Temporal — roda como processo separado.
|
||||
* Uso: npx tsx src/infra/temporal/temporalWorker.ts
|
||||
*/
|
||||
const worker_1 = require("@temporalio/worker");
|
||||
const env_1 = require("../../config/env");
|
||||
const logger_1 = require("../../config/logger");
|
||||
const activities = __importStar(require("./activities"));
|
||||
async function runWorker() {
|
||||
const worker = await worker_1.Worker.create({
|
||||
workflowsPath: require.resolve('./workflows'),
|
||||
activities,
|
||||
taskQueue: env_1.env.TEMPORAL_TASK_QUEUE,
|
||||
namespace: env_1.env.TEMPORAL_NAMESPACE,
|
||||
});
|
||||
logger_1.logger.info({ taskQueue: env_1.env.TEMPORAL_TASK_QUEUE }, '🕰 Temporal Worker iniciado');
|
||||
await worker.run();
|
||||
}
|
||||
runWorker().catch((err) => {
|
||||
logger_1.logger.error(err, 'Falha fatal no Temporal Worker');
|
||||
process.exit(1);
|
||||
});
|
||||
//# sourceMappingURL=temporalWorker.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"temporalWorker.js","sourceRoot":"","sources":["temporalWorker.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;;;GAGG;AACH,+CAA2C;AAC3C,0CAAsC;AACtC,gDAA4C;AAC5C,yDAA0C;AAE1C,KAAK,UAAU,SAAS;IACtB,MAAM,MAAM,GAAG,MAAM,eAAM,CAAC,MAAM,CAAC;QACjC,aAAa,EAAE,OAAO,CAAC,OAAO,CAAC,aAAa,CAAC;QAC7C,UAAU;QACV,SAAS,EAAE,SAAG,CAAC,mBAAmB;QAClC,SAAS,EAAE,SAAG,CAAC,kBAAkB;KAClC,CAAC,CAAA;IAEF,eAAM,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,SAAG,CAAC,mBAAmB,EAAE,EAAE,8BAA8B,CAAC,CAAA;IACnF,MAAM,MAAM,CAAC,GAAG,EAAE,CAAA;AACpB,CAAC;AAED,SAAS,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;IACxB,eAAM,CAAC,KAAK,CAAC,GAAG,EAAE,gCAAgC,CAAC,CAAA;IACnD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;AACjB,CAAC,CAAC,CAAA"}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.confirmarSlotSignal = void 0;
|
||||
exports.conflitoAgendaWorkflow = conflitoAgendaWorkflow;
|
||||
/**
|
||||
* Workflow: Conflito de Agenda (1 Hora)
|
||||
*
|
||||
* Regra de negócio:
|
||||
* - Pessoa A solicita o slot que Pessoa B já tem PENDENTE.
|
||||
* - Sistema marca o conflito como WAITING_B e aguarda 1 hora.
|
||||
* - Se B confirmar dentro de 1h → slot permanece com B, A é notificada.
|
||||
* - Se B não responder em 1h → slot é liberado e transferido para A.
|
||||
*/
|
||||
const workflow_1 = require("@temporalio/workflow");
|
||||
const { liberarSlotAgenda, notificarConflito, } = (0, workflow_1.proxyActivities)({
|
||||
startToCloseTimeout: '30 seconds',
|
||||
retry: { maximumAttempts: 3 },
|
||||
});
|
||||
// Signal enviado quando B confirma o slot
|
||||
exports.confirmarSlotSignal = (0, workflow_1.defineSignal)('confirmarSlot');
|
||||
async function conflitoAgendaWorkflow(input) {
|
||||
let bConfirmou = false;
|
||||
// Registra o handler do signal de confirmação de B
|
||||
(0, workflow_1.setHandler)(exports.confirmarSlotSignal, ({ confirmado }) => {
|
||||
bConfirmou = confirmado;
|
||||
workflow_1.log.info('Signal recebido de B', { confirmado });
|
||||
});
|
||||
// Notifica B sobre a disputa
|
||||
await notificarConflito({
|
||||
chatIdB: input.chatIdDetentor,
|
||||
solicitanteNome: input.solicitanteId,
|
||||
tituloSlot: input.tituloSlot,
|
||||
});
|
||||
workflow_1.log.info('Aguardando confirmação de B por 1 hora', { tituloSlot: input.tituloSlot });
|
||||
// Aguarda signal de B OU timeout de 1 hora
|
||||
const bRespondeu = await (0, workflow_1.condition)(() => bConfirmou !== false, '1 hour');
|
||||
if (bRespondeu && bConfirmou) {
|
||||
workflow_1.log.info('B confirmou o slot — slot mantido com B');
|
||||
return { resultado: 'MANTIDO_B' };
|
||||
}
|
||||
// B não respondeu ou recusou → libera para A
|
||||
workflow_1.log.info('B não confirmou em 1 hora — liberando slot para A');
|
||||
await liberarSlotAgenda({
|
||||
solicitanteId: input.solicitanteId,
|
||||
tituloSlot: input.tituloSlot,
|
||||
chatIdSolicitante: input.chatIdSolicitante,
|
||||
});
|
||||
return { resultado: 'LIBERADO_A' };
|
||||
}
|
||||
//# sourceMappingURL=conflitoAgendaWorkflow.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"conflitoAgendaWorkflow.js","sourceRoot":"","sources":["conflitoAgendaWorkflow.ts"],"names":[],"mappings":";;;AAsCA,wDAqCC;AA3ED;;;;;;;;GAQG;AACH,mDAO6B;AAG7B,MAAM,EACJ,iBAAiB,EACjB,iBAAiB,GAClB,GAAG,IAAA,0BAAe,EAAc;IAC/B,mBAAmB,EAAE,YAAY;IACjC,KAAK,EAAE,EAAE,eAAe,EAAE,CAAC,EAAE;CAC9B,CAAC,CAAA;AAEF,0CAA0C;AAC7B,QAAA,mBAAmB,GAAG,IAAA,uBAAY,EAA4B,eAAe,CAAC,CAAA;AAUpF,KAAK,UAAU,sBAAsB,CAAC,KAA0B;IAGrE,IAAI,UAAU,GAAG,KAAK,CAAA;IAEtB,mDAAmD;IACnD,IAAA,qBAAU,EAAC,2BAAmB,EAAE,CAAC,EAAE,UAAU,EAAE,EAAE,EAAE;QACjD,UAAU,GAAG,UAAU,CAAA;QACvB,cAAG,CAAC,IAAI,CAAC,sBAAsB,EAAE,EAAE,UAAU,EAAE,CAAC,CAAA;IAClD,CAAC,CAAC,CAAA;IAEF,6BAA6B;IAC7B,MAAM,iBAAiB,CAAC;QACtB,OAAO,EAAE,KAAK,CAAC,cAAc;QAC7B,eAAe,EAAE,KAAK,CAAC,aAAa;QACpC,UAAU,EAAE,KAAK,CAAC,UAAU;KAC7B,CAAC,CAAA;IAEF,cAAG,CAAC,IAAI,CAAC,wCAAwC,EAAE,EAAE,UAAU,EAAE,KAAK,CAAC,UAAU,EAAE,CAAC,CAAA;IAEpF,2CAA2C;IAC3C,MAAM,UAAU,GAAG,MAAM,IAAA,oBAAS,EAAC,GAAG,EAAE,CAAC,UAAU,KAAK,KAAK,EAAE,QAAQ,CAAC,CAAA;IAExE,IAAI,UAAU,IAAI,UAAU,EAAE,CAAC;QAC7B,cAAG,CAAC,IAAI,CAAC,yCAAyC,CAAC,CAAA;QACnD,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,CAAA;IACnC,CAAC;IAED,6CAA6C;IAC7C,cAAG,CAAC,IAAI,CAAC,mDAAmD,CAAC,CAAA;IAC7D,MAAM,iBAAiB,CAAC;QACtB,aAAa,EAAE,KAAK,CAAC,aAAa;QAClC,UAAU,EAAE,KAAK,CAAC,UAAU;QAC5B,iBAAiB,EAAE,KAAK,CAAC,iBAAiB;KAC3C,CAAC,CAAA;IAEF,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,CAAA;AACpC,CAAC"}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.encerrarMonitoramentoSignal = exports.registrarInfracaoSignal = exports.reputacaoWorkflow = exports.confirmarSlotSignal = exports.conflitoAgendaWorkflow = void 0;
|
||||
var conflitoAgendaWorkflow_1 = require("./conflitoAgendaWorkflow");
|
||||
Object.defineProperty(exports, "conflitoAgendaWorkflow", { enumerable: true, get: function () { return conflitoAgendaWorkflow_1.conflitoAgendaWorkflow; } });
|
||||
Object.defineProperty(exports, "confirmarSlotSignal", { enumerable: true, get: function () { return conflitoAgendaWorkflow_1.confirmarSlotSignal; } });
|
||||
var reputacaoWorkflow_1 = require("./reputacaoWorkflow");
|
||||
Object.defineProperty(exports, "reputacaoWorkflow", { enumerable: true, get: function () { return reputacaoWorkflow_1.reputacaoWorkflow; } });
|
||||
Object.defineProperty(exports, "registrarInfracaoSignal", { enumerable: true, get: function () { return reputacaoWorkflow_1.registrarInfracaoSignal; } });
|
||||
Object.defineProperty(exports, "encerrarMonitoramentoSignal", { enumerable: true, get: function () { return reputacaoWorkflow_1.encerrarMonitoramentoSignal; } });
|
||||
//# sourceMappingURL=index.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;AAAA,mEAAsF;AAA7E,gIAAA,sBAAsB,OAAA;AAAE,6HAAA,mBAAmB,OAAA;AACpD,yDAA6G;AAApG,sHAAA,iBAAiB,OAAA;AAAE,4HAAA,uBAAuB,OAAA;AAAE,gIAAA,2BAA2B,OAAA"}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.encerrarMonitoramentoSignal = exports.registrarInfracaoSignal = void 0;
|
||||
exports.reputacaoWorkflow = reputacaoWorkflow;
|
||||
/**
|
||||
* Workflow: Gestão de Reputação de Contato
|
||||
*
|
||||
* Regra de negócio:
|
||||
* - Cada falta/infração reduz o score_reputacao do contato.
|
||||
* - Score ≤ 20 → contato entra em flag_restricao = true.
|
||||
* - Agendamentos automáticos são bloqueados para contatos RESTRITOS.
|
||||
* - Score é recuperável manualmente pelo agente (fora deste workflow).
|
||||
*/
|
||||
const workflow_1 = require("@temporalio/workflow");
|
||||
const { decrementarScore, bloquearAgendamentosAutomaticos, buscarScoreContato, } = (0, workflow_1.proxyActivities)({
|
||||
startToCloseTimeout: '30 seconds',
|
||||
retry: { maximumAttempts: 3 },
|
||||
});
|
||||
// Signal para registrar uma nova infração
|
||||
exports.registrarInfracaoSignal = (0, workflow_1.defineSignal)('registrarInfracao');
|
||||
// Signal para encerrar o monitoramento (contato reabilitado manualmente)
|
||||
exports.encerrarMonitoramentoSignal = (0, workflow_1.defineSignal)('encerrarMonitoramento');
|
||||
async function reputacaoWorkflow(input) {
|
||||
let encerrado = false;
|
||||
const infracoesPendentes = [];
|
||||
(0, workflow_1.setHandler)(exports.registrarInfracaoSignal, (payload) => {
|
||||
workflow_1.log.info('Infração registrada via signal', payload);
|
||||
infracoesPendentes.push(payload);
|
||||
});
|
||||
(0, workflow_1.setHandler)(exports.encerrarMonitoramentoSignal, () => {
|
||||
encerrado = true;
|
||||
});
|
||||
// Loop durável — o Temporal mantém o estado mesmo após reinícios
|
||||
while (!encerrado) {
|
||||
// Aguarda próxima infração ou encerramento
|
||||
await (0, workflow_1.condition)(() => infracoesPendentes.length > 0 || encerrado);
|
||||
if (encerrado)
|
||||
break;
|
||||
const infracao = infracoesPendentes.shift();
|
||||
const { novoScore, restrito } = await decrementarScore({
|
||||
contactId: input.contactId,
|
||||
motivo: infracao.motivo,
|
||||
pontos: infracao.pontos,
|
||||
});
|
||||
workflow_1.log.info('Score atualizado', { novoScore, restrito, contactId: input.contactId });
|
||||
if (restrito) {
|
||||
await bloquearAgendamentosAutomaticos({ contactId: input.contactId });
|
||||
workflow_1.log.warn('Contato RESTRITO — agendamentos automáticos bloqueados', {
|
||||
contactId: input.contactId,
|
||||
scoreAtual: novoScore,
|
||||
});
|
||||
// Continua monitorando (restrição é revertida manualmente pelo agente)
|
||||
}
|
||||
}
|
||||
workflow_1.log.info('Monitoramento de reputação encerrado', { contactId: input.contactId });
|
||||
}
|
||||
//# sourceMappingURL=reputacaoWorkflow.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"reputacaoWorkflow.js","sourceRoot":"","sources":["reputacaoWorkflow.ts"],"names":[],"mappings":";;;AAyCA,8CAyCC;AAlFD;;;;;;;;GAQG;AACH,mDAM6B;AAG7B,MAAM,EACJ,gBAAgB,EAChB,+BAA+B,EAC/B,kBAAkB,GACnB,GAAG,IAAA,0BAAe,EAAc;IAC/B,mBAAmB,EAAE,YAAY;IACjC,KAAK,EAAE,EAAE,eAAe,EAAE,CAAC,EAAE;CAC9B,CAAC,CAAA;AAEF,0CAA0C;AAC7B,QAAA,uBAAuB,GAAG,IAAA,uBAAY,EAG/C,mBAAmB,CAAC,CAAA;AAExB,yEAAyE;AAC5D,QAAA,2BAA2B,GAAG,IAAA,uBAAY,EAAC,uBAAuB,CAAC,CAAA;AAOzE,KAAK,UAAU,iBAAiB,CAAC,KAAqB;IAC3D,IAAI,SAAS,GAAG,KAAK,CAAA;IACrB,MAAM,kBAAkB,GAA8C,EAAE,CAAA;IAExE,IAAA,qBAAU,EAAC,+BAAuB,EAAE,CAAC,OAAO,EAAE,EAAE;QAC9C,cAAG,CAAC,IAAI,CAAC,gCAAgC,EAAE,OAAO,CAAC,CAAA;QACnD,kBAAkB,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;IAClC,CAAC,CAAC,CAAA;IAEF,IAAA,qBAAU,EAAC,mCAA2B,EAAE,GAAG,EAAE;QAC3C,SAAS,GAAG,IAAI,CAAA;IAClB,CAAC,CAAC,CAAA;IAEF,iEAAiE;IACjE,OAAO,CAAC,SAAS,EAAE,CAAC;QAClB,2CAA2C;QAC3C,MAAM,IAAA,oBAAS,EAAC,GAAG,EAAE,CAAC,kBAAkB,CAAC,MAAM,GAAG,CAAC,IAAI,SAAS,CAAC,CAAA;QAEjE,IAAI,SAAS;YAAE,MAAK;QAEpB,MAAM,QAAQ,GAAG,kBAAkB,CAAC,KAAK,EAAG,CAAA;QAE5C,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,MAAM,gBAAgB,CAAC;YACrD,SAAS,EAAE,KAAK,CAAC,SAAS;YAC1B,MAAM,EAAE,QAAQ,CAAC,MAAM;YACvB,MAAM,EAAE,QAAQ,CAAC,MAAM;SACxB,CAAC,CAAA;QAEF,cAAG,CAAC,IAAI,CAAC,kBAAkB,EAAE,EAAE,SAAS,EAAE,QAAQ,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,EAAE,CAAC,CAAA;QAEjF,IAAI,QAAQ,EAAE,CAAC;YACb,MAAM,+BAA+B,CAAC,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,EAAE,CAAC,CAAA;YACrE,cAAG,CAAC,IAAI,CAAC,wDAAwD,EAAE;gBACjE,SAAS,EAAE,KAAK,CAAC,SAAS;gBAC1B,UAAU,EAAE,SAAS;aACtB,CAAC,CAAA;YACF,uEAAuE;QACzE,CAAC;IACH,CAAC;IAED,cAAG,CAAC,IAAI,CAAC,sCAAsC,EAAE,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,EAAE,CAAC,CAAA;AAClF,CAAC"}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildApiKeyRoutes = buildApiKeyRoutes;
|
||||
const express_1 = require("express");
|
||||
const zod_1 = require("zod");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const crypto_1 = __importDefault(require("crypto"));
|
||||
function buildApiKeyRoutes() {
|
||||
const router = (0, express_1.Router)();
|
||||
// GET /api/api-keys
|
||||
router.get('/', async (req, res) => {
|
||||
const keys = await prisma_1.prisma.apiKey.findMany({
|
||||
where: { tenantId: req.tenantId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
key: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
});
|
||||
// Mask key: show only last 8 chars
|
||||
const masked = keys.map((k) => ({
|
||||
...k,
|
||||
keyPreview: `••••••••${k.key.slice(-8)}`,
|
||||
key: undefined,
|
||||
}));
|
||||
res.json(masked);
|
||||
});
|
||||
// POST /api/api-keys — cria nova chave
|
||||
router.post('/', async (req, res) => {
|
||||
const schema = zod_1.z.object({
|
||||
name: zod_1.z.string().min(1).max(80),
|
||||
expiresAt: zod_1.z.string().datetime().optional(),
|
||||
});
|
||||
let body;
|
||||
try {
|
||||
body = schema.parse(req.body);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(400).json({ error: err.errors ?? err.message });
|
||||
return;
|
||||
}
|
||||
const key = `nw_${crypto_1.default.randomBytes(24).toString('hex')}`;
|
||||
const created = await prisma_1.prisma.apiKey.create({
|
||||
data: {
|
||||
tenantId: req.tenantId,
|
||||
name: body.name,
|
||||
key,
|
||||
expiresAt: body.expiresAt ? new Date(body.expiresAt) : undefined,
|
||||
},
|
||||
});
|
||||
// Retorna a chave completa apenas uma vez
|
||||
res.status(201).json({
|
||||
id: created.id,
|
||||
name: created.name,
|
||||
key: created.key, // plaintext — única vez
|
||||
isActive: created.isActive,
|
||||
createdAt: created.createdAt,
|
||||
expiresAt: created.expiresAt,
|
||||
});
|
||||
});
|
||||
// PATCH /api/api-keys/:id — ativar/desativar
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const id = req.params['id'];
|
||||
const schema = zod_1.z.object({ isActive: zod_1.z.boolean() });
|
||||
let body;
|
||||
try {
|
||||
body = schema.parse(req.body);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(400).json({ error: err.errors ?? err.message });
|
||||
return;
|
||||
}
|
||||
const existing = await prisma_1.prisma.apiKey.findFirst({ where: { id, tenantId: req.tenantId } });
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Chave não encontrada' });
|
||||
return;
|
||||
}
|
||||
const updated = await prisma_1.prisma.apiKey.update({ where: { id }, data: { isActive: body.isActive } });
|
||||
res.json({ id: updated.id, name: updated.name, isActive: updated.isActive });
|
||||
});
|
||||
// DELETE /api/api-keys/:id
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const id = req.params['id'];
|
||||
const existing = await prisma_1.prisma.apiKey.findFirst({ where: { id, tenantId: req.tenantId } });
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Chave não encontrada' });
|
||||
return;
|
||||
}
|
||||
await prisma_1.prisma.apiKey.delete({ where: { id } });
|
||||
res.status(204).send();
|
||||
});
|
||||
return router;
|
||||
}
|
||||
//# sourceMappingURL=apikey.routes.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"apikey.routes.js","sourceRoot":"","sources":["apikey.routes.ts"],"names":[],"mappings":"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"}
|
||||
@@ -0,0 +1,386 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.adminRouter = void 0;
|
||||
/**
|
||||
* Rotas exclusivas do ADMIN (dono do SaaS).
|
||||
* Prefixo: /api/admin/
|
||||
*/
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const promises_1 = __importDefault(require("fs/promises"));
|
||||
const express_1 = require("express");
|
||||
const zod_1 = require("zod");
|
||||
const bcryptjs_1 = __importDefault(require("bcryptjs"));
|
||||
const multer_1 = __importDefault(require("multer"));
|
||||
const systeminformation_1 = __importDefault(require("systeminformation"));
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const dragonfly_1 = require("../../infra/cache/dragonfly");
|
||||
const plugins_routes_1 = require("../plugins/plugins.routes");
|
||||
const SETTINGS_KEY = 'system:settings';
|
||||
const DEFAULT_SETTINGS = {
|
||||
systemName: 'NewWhats',
|
||||
timezone: 'America/Sao_Paulo',
|
||||
logoUrl: null,
|
||||
faviconUrl: null,
|
||||
accentColor: '#3b82f6',
|
||||
sounds: { newMessage: true, notification: true, connectionStatus: true },
|
||||
smtp: { host: '', port: 587, user: '', password: '', from: '', secure: false },
|
||||
emailTemplates: { welcome: '', passwordReset: '', trialExpiring: '' },
|
||||
registration: { mode: 'open', defaultPlanId: null },
|
||||
maintenance: { enabled: false, message: 'Sistema em manutenção. Voltamos em breve.' },
|
||||
webhook: { url: '', secret: '' },
|
||||
};
|
||||
async function getSettings() {
|
||||
const stored = await dragonfly_1.dragonfly.getJson(SETTINGS_KEY);
|
||||
return { ...DEFAULT_SETTINGS, ...stored };
|
||||
}
|
||||
const uploadAsset = (0, multer_1.default)({
|
||||
storage: multer_1.default.memoryStorage(),
|
||||
limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/'))
|
||||
cb(null, true);
|
||||
else
|
||||
cb(new Error('Apenas imagens são permitidas'));
|
||||
},
|
||||
});
|
||||
exports.adminRouter = (0, express_1.Router)();
|
||||
// ── Plugins ───────────────────────────────────────────────────────────────────
|
||||
exports.adminRouter.use('/plugins', plugins_routes_1.pluginsRouter);
|
||||
// ── Plans ─────────────────────────────────────────────────────────────────────
|
||||
exports.adminRouter.get('/plans', async (_req, res) => {
|
||||
try {
|
||||
const plans = await prisma_1.prisma.plan.findMany({
|
||||
select: { id: true, name: true }
|
||||
});
|
||||
res.json(plans);
|
||||
}
|
||||
catch {
|
||||
res.status(500).json({ error: 'Erro ao listar planos' });
|
||||
}
|
||||
});
|
||||
// ── Settings ──────────────────────────────────────────────────────────────────
|
||||
exports.adminRouter.get('/settings', async (_req, res) => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
// Nunca expõe a senha SMTP ao frontend
|
||||
const { smtp, ...rest } = settings;
|
||||
res.json({ ...rest, smtp: { ...smtp, password: smtp.password ? '••••••••' : '' } });
|
||||
}
|
||||
catch {
|
||||
res.status(500).json({ error: 'Erro ao buscar configurações' });
|
||||
}
|
||||
});
|
||||
exports.adminRouter.patch('/settings', async (req, res) => {
|
||||
try {
|
||||
const current = await getSettings();
|
||||
const body = req.body;
|
||||
// Se o frontend devolver o placeholder de senha, mantém a senha atual
|
||||
if (body.smtp?.password === '••••••••') {
|
||||
body.smtp = { ...body.smtp, password: current.smtp.password };
|
||||
}
|
||||
const updated = { ...current, ...body };
|
||||
await dragonfly_1.dragonfly.setJson(SETTINGS_KEY, updated, 0); // TTL 0 = sem expiração
|
||||
res.json({ ok: true });
|
||||
}
|
||||
catch {
|
||||
res.status(500).json({ error: 'Erro ao salvar configurações' });
|
||||
}
|
||||
});
|
||||
// Upload de logo
|
||||
exports.adminRouter.post('/settings/logo', uploadAsset.single('file'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: 'Arquivo não enviado' });
|
||||
return;
|
||||
}
|
||||
const ext = req.file.originalname.split('.').pop() ?? 'png';
|
||||
const dest = path_1.default.resolve('./media/system/logo.' + ext);
|
||||
await promises_1.default.writeFile(dest, req.file.buffer);
|
||||
const url = `/media/system/logo.${ext}`;
|
||||
const settings = await getSettings();
|
||||
await dragonfly_1.dragonfly.setJson(SETTINGS_KEY, { ...settings, logoUrl: url }, 0);
|
||||
res.json({ url });
|
||||
}
|
||||
catch {
|
||||
res.status(500).json({ error: 'Erro ao salvar logo' });
|
||||
}
|
||||
});
|
||||
// Upload de favicon
|
||||
exports.adminRouter.post('/settings/favicon', uploadAsset.single('file'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: 'Arquivo não enviado' });
|
||||
return;
|
||||
}
|
||||
const ext = req.file.originalname.split('.').pop() ?? 'ico';
|
||||
const dest = path_1.default.resolve('./media/system/favicon.' + ext);
|
||||
await promises_1.default.writeFile(dest, req.file.buffer);
|
||||
const url = `/media/system/favicon.${ext}`;
|
||||
const settings = await getSettings();
|
||||
await dragonfly_1.dragonfly.setJson(SETTINGS_KEY, { ...settings, faviconUrl: url }, 0);
|
||||
res.json({ url });
|
||||
}
|
||||
catch {
|
||||
res.status(500).json({ error: 'Erro ao salvar favicon' });
|
||||
}
|
||||
});
|
||||
// Endpoint público para o frontend ler as settings de branding (sem autenticação)
|
||||
// Usado pelo _app.tsx para aplicar nome/logo/favicon sem precisar do token
|
||||
exports.adminRouter.get('/settings/public', async (_req, res) => {
|
||||
try {
|
||||
const { systemName, logoUrl, faviconUrl, accentColor, maintenance } = await getSettings();
|
||||
res.json({ systemName, logoUrl, faviconUrl, accentColor, maintenance });
|
||||
}
|
||||
catch {
|
||||
res.status(500).json({ error: 'Erro' });
|
||||
}
|
||||
});
|
||||
// ── Métricas do sistema ───────────────────────────────────────────────────────
|
||||
exports.adminRouter.get('/metrics', async (_req, res) => {
|
||||
try {
|
||||
const [cpuLoad, mem, disk] = await Promise.all([
|
||||
systeminformation_1.default.currentLoad(),
|
||||
systeminformation_1.default.mem(),
|
||||
systeminformation_1.default.fsSize(),
|
||||
]);
|
||||
const rootDisk = disk.find((d) => d.mount === '/') ?? disk[0];
|
||||
const [connectedInstances, totalUsers] = await Promise.all([
|
||||
prisma_1.prisma.instance.count({ where: { status: 'CONNECTED' } }),
|
||||
prisma_1.prisma.user.count(),
|
||||
]);
|
||||
res.json({
|
||||
cpu: { percent: Math.round(cpuLoad.currentLoad) },
|
||||
ram: {
|
||||
total: mem.total,
|
||||
used: mem.used,
|
||||
percent: Math.round((mem.used / mem.total) * 100),
|
||||
},
|
||||
disk: {
|
||||
total: rootDisk?.size ?? 0,
|
||||
used: rootDisk?.used ?? 0,
|
||||
percent: Math.round(rootDisk?.use ?? 0),
|
||||
},
|
||||
uptime: Math.floor(process.uptime()),
|
||||
connectedInstances,
|
||||
totalUsers,
|
||||
});
|
||||
}
|
||||
catch {
|
||||
res.status(500).json({ error: 'Erro ao coletar métricas' });
|
||||
}
|
||||
});
|
||||
// Listar todos os tenants com presença online
|
||||
exports.adminRouter.get('/users', async (_req, res) => {
|
||||
try {
|
||||
const [users, presenceKeys] = await Promise.all([
|
||||
prisma_1.prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
planId: true,
|
||||
trialEndsAt: true,
|
||||
createdAt: true,
|
||||
_count: { select: { instances: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
dragonfly_1.dragonfly.raw().keys('presence:user:*'),
|
||||
]);
|
||||
const onlineIds = new Set(presenceKeys.map((k) => k.replace('presence:user:', '')));
|
||||
res.json(users.map((u) => ({ ...u, online: onlineIds.has(u.id) })));
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar usuários' });
|
||||
}
|
||||
});
|
||||
// Detalhes de um tenant
|
||||
exports.adminRouter.get('/users/:id', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const user = await prisma_1.prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
planId: true,
|
||||
trialEndsAt: true,
|
||||
createdAt: true,
|
||||
instances: { select: { id: true, name: true, status: true } },
|
||||
apiKeys: { select: { id: true, name: true, isActive: true, createdAt: true } },
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'Usuário não encontrado' });
|
||||
return;
|
||||
}
|
||||
res.json(user);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao buscar usuário' });
|
||||
}
|
||||
});
|
||||
// Ativar / desativar tenant
|
||||
exports.adminRouter.patch('/users/:id/status', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const { isActive } = zod_1.z.object({ isActive: zod_1.z.boolean() }).parse(req.body);
|
||||
const user = await prisma_1.prisma.user.update({
|
||||
where: { id },
|
||||
data: { isActive },
|
||||
select: { id: true, name: true, email: true, isActive: true },
|
||||
});
|
||||
res.json(user);
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof zod_1.z.ZodError) {
|
||||
res.status(400).json({ error: 'isActive deve ser boolean' });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: 'Erro ao atualizar status' });
|
||||
}
|
||||
});
|
||||
// Alterar plano do tenant
|
||||
exports.adminRouter.patch('/users/:id/plan', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const { planId } = zod_1.z.object({ planId: zod_1.z.string().uuid() }).parse(req.body);
|
||||
const user = await prisma_1.prisma.user.update({
|
||||
where: { id },
|
||||
data: { planId },
|
||||
select: { id: true, name: true, planId: true },
|
||||
});
|
||||
res.json(user);
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof zod_1.z.ZodError) {
|
||||
res.status(400).json({ error: 'planId inválido' });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: 'Erro ao atualizar plano' });
|
||||
}
|
||||
});
|
||||
// Estender trial de um tenant
|
||||
exports.adminRouter.patch('/users/:id/trial', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const { days } = zod_1.z.object({ days: zod_1.z.number().int().min(1).max(365) }).parse(req.body);
|
||||
const user = await prisma_1.prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
trialEndsAt: new Date(Date.now() + days * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
select: { id: true, name: true, trialEndsAt: true },
|
||||
});
|
||||
res.json(user);
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof zod_1.z.ZodError) {
|
||||
res.status(400).json({ error: 'days inválido' });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: 'Erro ao estender trial' });
|
||||
}
|
||||
});
|
||||
// Resetar senha de um tenant (admin)
|
||||
exports.adminRouter.patch('/users/:id/reset-password', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const { newPassword } = zod_1.z
|
||||
.object({ newPassword: zod_1.z.string().min(8).max(128) })
|
||||
.parse(req.body);
|
||||
const passwordHash = await bcryptjs_1.default.hash(newPassword, 12);
|
||||
await prisma_1.prisma.user.update({ where: { id }, data: { passwordHash } });
|
||||
res.json({ message: 'Senha redefinida com sucesso' });
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof zod_1.z.ZodError) {
|
||||
res.status(400).json({ error: 'newPassword inválido' });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: 'Erro ao redefinir senha' });
|
||||
}
|
||||
});
|
||||
// Obter engine ativa
|
||||
exports.adminRouter.get('/engine', async (_req, res) => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
const storedEngine = settings.baileysEngine ?? process.env.BAILEYS_ENGINE ?? 'infinite';
|
||||
res.json({ engine: storedEngine });
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[Admin] Erro ao obter engine:', err);
|
||||
res.status(500).json({ error: 'Erro ao obter a engine ativa' });
|
||||
}
|
||||
});
|
||||
// Altera a engine e reinicia o backend (via PM2 ou Docker)
|
||||
exports.adminRouter.post('/engine', async (req, res) => {
|
||||
try {
|
||||
const { engine } = zod_1.z.object({ engine: zod_1.z.enum(['infinite', 'official']) }).parse(req.body);
|
||||
// 1. Persiste nas configurações globais
|
||||
const settings = await getSettings();
|
||||
await dragonfly_1.dragonfly.setJson(SETTINGS_KEY, { ...settings, baileysEngine: engine }, 0);
|
||||
// 2. Escreve a alteração no arquivo .env para que persista no próximo boot
|
||||
const envPath = path_1.default.resolve('./.env');
|
||||
try {
|
||||
let content = await promises_1.default.readFile(envPath, 'utf-8');
|
||||
if (content.includes('BAILEYS_ENGINE=')) {
|
||||
content = content.replace(/BAILEYS_ENGINE=\w+/g, `BAILEYS_ENGINE=${engine}`);
|
||||
}
|
||||
else {
|
||||
content += `\nBAILEYS_ENGINE=${engine}\n`;
|
||||
}
|
||||
await promises_1.default.writeFile(envPath, content, 'utf-8');
|
||||
}
|
||||
catch (envErr) {
|
||||
console.error('[Admin] Erro ao gravar BAILEYS_ENGINE no .env:', envErr);
|
||||
}
|
||||
// 3. Responde ao frontend antes de desligar/reiniciar
|
||||
res.json({ ok: true, engine, message: 'Engine configurada. Reiniciando o servidor...' });
|
||||
// 4. Executa o restart após uma breve janela para dar tempo do client receber a resposta HTTP 200 OK
|
||||
setTimeout(() => {
|
||||
console.info(`[Admin] Alternando engine para "${engine}". Reiniciando processo...`);
|
||||
const { exec } = require('child_process');
|
||||
exec('pm2 restart newwhats-backend', (err) => {
|
||||
if (err) {
|
||||
console.warn('[Admin] PM2 não disponível ou falhou ao reiniciar. Saindo com process.exit(0) para restart automático via Docker...');
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
}, 1500);
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[Admin] Erro ao alternar engine:', err);
|
||||
if (err instanceof zod_1.z.ZodError) {
|
||||
res.status(400).json({ error: 'Engine inválida. Use "infinite" ou "official"' });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: 'Erro ao salvar a engine' });
|
||||
}
|
||||
});
|
||||
// ── Rota para Disparo Manual do Deploy ──
|
||||
exports.adminRouter.post('/deploys/trigger', async (_req, res) => {
|
||||
try {
|
||||
const response = await fetch('http://10.99.0.4:9000/hooks/clube67-deploy-hook', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ trigger: 'admin_panel' })
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(`Status ${response.status}`);
|
||||
res.json({ ok: true, message: 'Deploy automático iniciado na VPS 4.' });
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[Admin] Falha ao disparar webhook de deploy:', err.message);
|
||||
res.status(502).json({ error: 'Não foi possível se conectar ao servidor de build' });
|
||||
}
|
||||
});
|
||||
//# sourceMappingURL=admin.routes.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,53 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AuthRepository = void 0;
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
class AuthRepository {
|
||||
async findByEmail(email) {
|
||||
return prisma_1.prisma.user.findUnique({ where: { email } });
|
||||
}
|
||||
async findById(id) {
|
||||
return prisma_1.prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
planId: true,
|
||||
trialEndsAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
async create(data) {
|
||||
return prisma_1.prisma.user.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
passwordHash: data.passwordHash,
|
||||
role: data.role ?? 'USER',
|
||||
planId: data.planId,
|
||||
trialEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14 dias de trial
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
planId: true,
|
||||
trialEndsAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
async updatePassword(id, passwordHash) {
|
||||
return prisma_1.prisma.user.update({ where: { id }, data: { passwordHash } });
|
||||
}
|
||||
async setActive(id, isActive) {
|
||||
return prisma_1.prisma.user.update({ where: { id }, data: { isActive } });
|
||||
}
|
||||
}
|
||||
exports.AuthRepository = AuthRepository;
|
||||
//# sourceMappingURL=auth.repository.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"auth.repository.js","sourceRoot":"","sources":["auth.repository.ts"],"names":[],"mappings":";;;AAAA,wDAAoD;AAGpD,MAAa,cAAc;IACzB,KAAK,CAAC,WAAW,CAAC,KAAa;QAC7B,OAAO,eAAM,CAAC,IAAI,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,EAAE,CAAC,CAAA;IACrD,CAAC;IAED,KAAK,CAAC,QAAQ,CAAC,EAAU;QACvB,OAAO,eAAM,CAAC,IAAI,CAAC,UAAU,CAAC;YAC5B,KAAK,EAAE,EAAE,EAAE,EAAE;YACb,MAAM,EAAE;gBACN,EAAE,EAAE,IAAI;gBACR,IAAI,EAAE,IAAI;gBACV,KAAK,EAAE,IAAI;gBACX,IAAI,EAAE,IAAI;gBACV,QAAQ,EAAE,IAAI;gBACd,MAAM,EAAE,IAAI;gBACZ,WAAW,EAAE,IAAI;gBACjB,SAAS,EAAE,IAAI;aAChB;SACF,CAAC,CAAA;IACJ,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,IAMZ;QACC,OAAO,eAAM,CAAC,IAAI,CAAC,MAAM,CAAC;YACxB,IAAI,EAAE;gBACJ,IAAI,EAAE,IAAI,CAAC,IAAI;gBACf,KAAK,EAAE,IAAI,CAAC,KAAK;gBACjB,YAAY,EAAE,IAAI,CAAC,YAAY;gBAC/B,IAAI,EAAE,IAAI,CAAC,IAAI,IAAI,MAAM;gBACzB,MAAM,EAAE,IAAI,CAAC,MAAM;gBACnB,WAAW,EAAE,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,EAAE,mBAAmB;aAClF;YACD,MAAM,EAAE;gBACN,EAAE,EAAE,IAAI;gBACR,IAAI,EAAE,IAAI;gBACV,KAAK,EAAE,IAAI;gBACX,IAAI,EAAE,IAAI;gBACV,MAAM,EAAE,IAAI;gBACZ,WAAW,EAAE,IAAI;gBACjB,SAAS,EAAE,IAAI;aAChB;SACF,CAAC,CAAA;IACJ,CAAC;IAED,KAAK,CAAC,cAAc,CAAC,EAAU,EAAE,YAAoB;QACnD,OAAO,eAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,YAAY,EAAE,EAAE,CAAC,CAAA;IACtE,CAAC;IAED,KAAK,CAAC,SAAS,CAAC,EAAU,EAAE,QAAiB;QAC3C,OAAO,eAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAA;IAClE,CAAC;CACF;AAxDD,wCAwDC"}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.authRouter = void 0;
|
||||
const express_1 = require("express");
|
||||
const zod_1 = require("zod");
|
||||
const auth_service_1 = require("./auth.service");
|
||||
const auth_middleware_1 = require("../../shared/middlewares/auth.middleware");
|
||||
const service = new auth_service_1.AuthService();
|
||||
// ─── Schemas de validação ─────────────────────────────────────────────────────
|
||||
const registerSchema = zod_1.z.object({
|
||||
name: zod_1.z.string().min(2).max(100),
|
||||
email: zod_1.z.string().email(),
|
||||
password: zod_1.z.string().min(8).max(128),
|
||||
planId: zod_1.z.string().uuid().optional(),
|
||||
});
|
||||
const loginSchema = zod_1.z.object({
|
||||
email: zod_1.z.string().email(),
|
||||
password: zod_1.z.string().min(1),
|
||||
});
|
||||
const refreshSchema = zod_1.z.object({
|
||||
refreshToken: zod_1.z.string().min(1),
|
||||
});
|
||||
const changePasswordSchema = zod_1.z.object({
|
||||
currentPassword: zod_1.z.string().min(1),
|
||||
newPassword: zod_1.z.string().min(8).max(128),
|
||||
});
|
||||
// ─── Helper para erros de validação / serviço ─────────────────────────────────
|
||||
function handleError(res, err) {
|
||||
if (err instanceof zod_1.z.ZodError) {
|
||||
res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors });
|
||||
return;
|
||||
}
|
||||
const e = err;
|
||||
res.status(e.statusCode ?? 500).json({ error: e.message ?? 'Erro interno' });
|
||||
}
|
||||
// ─── Router ───────────────────────────────────────────────────────────────────
|
||||
exports.authRouter = (0, express_1.Router)();
|
||||
/**
|
||||
* POST /api/auth/register
|
||||
* Cria novo tenant (usuário com role USER).
|
||||
* Retorna access + refresh token e dados do usuário.
|
||||
*/
|
||||
exports.authRouter.post('/register', async (req, res) => {
|
||||
try {
|
||||
const body = registerSchema.parse(req.body);
|
||||
const result = await service.register(body);
|
||||
res.status(201).json(result);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Autentica usuário. Retorna access + refresh token.
|
||||
*/
|
||||
exports.authRouter.post('/login', async (req, res) => {
|
||||
try {
|
||||
const body = loginSchema.parse(req.body);
|
||||
const result = await service.login(body);
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* POST /api/auth/refresh
|
||||
* Renova o access token usando o refresh token.
|
||||
*/
|
||||
exports.authRouter.post('/refresh', async (req, res) => {
|
||||
try {
|
||||
const { refreshToken } = refreshSchema.parse(req.body);
|
||||
const result = await service.refresh(refreshToken);
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* Revoga o refresh token do usuário autenticado.
|
||||
*/
|
||||
exports.authRouter.post('/logout', auth_middleware_1.authMiddleware, async (req, res) => {
|
||||
try {
|
||||
await service.logout(req.tenantId);
|
||||
res.json({ message: 'Sessão encerrada' });
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* Retorna dados do usuário autenticado + dias restantes do trial.
|
||||
*/
|
||||
exports.authRouter.get('/me', auth_middleware_1.authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const user = await service.me(req.tenantId);
|
||||
res.json(user);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* PATCH /api/auth/password
|
||||
* Altera a senha do usuário autenticado.
|
||||
*/
|
||||
exports.authRouter.patch('/password', auth_middleware_1.authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const body = changePasswordSchema.parse(req.body);
|
||||
await service.changePassword(req.tenantId, body);
|
||||
res.json({ message: 'Senha alterada com sucesso' });
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
//# sourceMappingURL=auth.routes.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"auth.routes.js","sourceRoot":"","sources":["auth.routes.ts"],"names":[],"mappings":"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"}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AuthService = void 0;
|
||||
const bcryptjs_1 = __importDefault(require("bcryptjs"));
|
||||
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
||||
const env_1 = require("../../config/env");
|
||||
const auth_repository_1 = require("./auth.repository");
|
||||
const dragonfly_1 = require("../../infra/cache/dragonfly");
|
||||
const BCRYPT_ROUNDS = 12;
|
||||
const ACCESS_EXPIRES = '8h';
|
||||
const REFRESH_EXPIRES = '30d';
|
||||
const REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60;
|
||||
class AuthService {
|
||||
constructor() {
|
||||
this.repo = new auth_repository_1.AuthRepository();
|
||||
}
|
||||
// ─── Register ─────────────────────────────────────────────────────────────
|
||||
async register(input) {
|
||||
const existing = await this.repo.findByEmail(input.email);
|
||||
if (existing) {
|
||||
throw Object.assign(new Error('E-mail já cadastrado'), { statusCode: 409 });
|
||||
}
|
||||
const passwordHash = await bcryptjs_1.default.hash(input.password, BCRYPT_ROUNDS);
|
||||
const user = await this.repo.create({
|
||||
name: input.name,
|
||||
email: input.email,
|
||||
passwordHash,
|
||||
planId: input.planId,
|
||||
});
|
||||
const tokens = this.issueTokens(user.id, user.role);
|
||||
await this.saveRefreshToken(user.id, tokens.refreshToken);
|
||||
return { user, ...tokens };
|
||||
}
|
||||
// ─── Login ────────────────────────────────────────────────────────────────
|
||||
async login(input) {
|
||||
const user = await this.repo.findByEmail(input.email);
|
||||
if (!user) {
|
||||
throw Object.assign(new Error('Credenciais inválidas'), { statusCode: 401 });
|
||||
}
|
||||
if (!user.isActive) {
|
||||
throw Object.assign(new Error('Conta desativada'), { statusCode: 403 });
|
||||
}
|
||||
const valid = await bcryptjs_1.default.compare(input.password, user.passwordHash);
|
||||
if (!valid) {
|
||||
throw Object.assign(new Error('Credenciais inválidas'), { statusCode: 401 });
|
||||
}
|
||||
const tokens = this.issueTokens(user.id, user.role);
|
||||
await this.saveRefreshToken(user.id, tokens.refreshToken);
|
||||
const { passwordHash: _, ...safeUser } = user;
|
||||
return { user: safeUser, ...tokens };
|
||||
}
|
||||
// ─── Refresh ──────────────────────────────────────────────────────────────
|
||||
async refresh(refreshToken) {
|
||||
let payload;
|
||||
try {
|
||||
payload = jsonwebtoken_1.default.verify(refreshToken, env_1.env.JWT_SECRET);
|
||||
}
|
||||
catch {
|
||||
throw Object.assign(new Error('Refresh token inválido'), { statusCode: 401 });
|
||||
}
|
||||
if (payload.type !== 'refresh') {
|
||||
throw Object.assign(new Error('Token incorreto'), { statusCode: 401 });
|
||||
}
|
||||
// Valida que o token ainda está no cache (não foi revogado)
|
||||
const stored = await dragonfly_1.dragonfly.get(this.refreshKey(payload.sub));
|
||||
if (stored !== refreshToken) {
|
||||
throw Object.assign(new Error('Refresh token expirado ou revogado'), { statusCode: 401 });
|
||||
}
|
||||
const user = await this.repo.findById(payload.sub);
|
||||
if (!user || !user.isActive) {
|
||||
throw Object.assign(new Error('Usuário inativo'), { statusCode: 403 });
|
||||
}
|
||||
const tokens = this.issueTokens(user.id, user.role);
|
||||
await this.saveRefreshToken(user.id, tokens.refreshToken);
|
||||
return { user, ...tokens };
|
||||
}
|
||||
// ─── Logout ───────────────────────────────────────────────────────────────
|
||||
async logout(userId) {
|
||||
await dragonfly_1.dragonfly.del(this.refreshKey(userId));
|
||||
}
|
||||
// ─── Me ───────────────────────────────────────────────────────────────────
|
||||
async me(userId) {
|
||||
const user = await this.repo.findById(userId);
|
||||
if (!user) {
|
||||
throw Object.assign(new Error('Usuário não encontrado'), { statusCode: 404 });
|
||||
}
|
||||
const trialEndsAt = user.trialEndsAt ? new Date(user.trialEndsAt) : null;
|
||||
const daysRemaining = trialEndsAt
|
||||
? Math.max(0, Math.ceil((trialEndsAt.getTime() - Date.now()) / 86400000))
|
||||
: null;
|
||||
return { ...user, daysRemaining };
|
||||
}
|
||||
// ─── Change Password ──────────────────────────────────────────────────────
|
||||
async changePassword(userId, input) {
|
||||
const user = await this.repo.findByEmail((await this.repo.findById(userId))?.email ?? '');
|
||||
if (!user)
|
||||
throw Object.assign(new Error('Usuário não encontrado'), { statusCode: 404 });
|
||||
const valid = await bcryptjs_1.default.compare(input.currentPassword, user.passwordHash);
|
||||
if (!valid) {
|
||||
throw Object.assign(new Error('Senha atual incorreta'), { statusCode: 400 });
|
||||
}
|
||||
const newHash = await bcryptjs_1.default.hash(input.newPassword, BCRYPT_ROUNDS);
|
||||
await this.repo.updatePassword(userId, newHash);
|
||||
await this.logout(userId); // invalida todas as sessões
|
||||
}
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
issueTokens(userId, role) {
|
||||
const base = { sub: userId, role };
|
||||
const accessToken = jsonwebtoken_1.default.sign({ ...base, type: 'access' }, env_1.env.JWT_SECRET, { expiresIn: ACCESS_EXPIRES });
|
||||
const refreshToken = jsonwebtoken_1.default.sign({ ...base, type: 'refresh' }, env_1.env.JWT_SECRET, { expiresIn: REFRESH_EXPIRES });
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
async saveRefreshToken(userId, token) {
|
||||
await dragonfly_1.dragonfly.set(this.refreshKey(userId), token, REFRESH_TTL_SECONDS);
|
||||
}
|
||||
refreshKey(userId) {
|
||||
return `auth:refresh:${userId}`;
|
||||
}
|
||||
}
|
||||
exports.AuthService = AuthService;
|
||||
//# sourceMappingURL=auth.service.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,203 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildChatbotRoutes = buildChatbotRoutes;
|
||||
/**
|
||||
* chatbot.routes.ts — CRUD de credenciais e bots IA por instância.
|
||||
*
|
||||
* Montado em /api/chatbot (ver server.ts)
|
||||
* Todos os endpoints requerem authMiddleware.
|
||||
*/
|
||||
const express_1 = require("express");
|
||||
const zod_1 = require("zod");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const chatbot_repository_1 = require("./chatbot.repository");
|
||||
const logger_1 = require("../../config/logger");
|
||||
function buildChatbotRoutes() {
|
||||
const router = (0, express_1.Router)();
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
const validateInstance = async (instanceId, tenantId) => {
|
||||
return prisma_1.prisma.instance.findFirst({ where: { id: instanceId, tenantId } });
|
||||
};
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// CREDENTIALS
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// GET /api/chatbot/credentials/:instanceId
|
||||
router.get('/credentials/:instanceId', async (req, res) => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
if (!await validateInstance(instanceId, tenantId)) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' });
|
||||
return;
|
||||
}
|
||||
const creds = await chatbot_repository_1.credentialRepo.findAll(tenantId, instanceId);
|
||||
res.json(creds);
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, 'Erro ao listar credenciais');
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// POST /api/chatbot/credentials/:instanceId
|
||||
router.post('/credentials/:instanceId', async (req, res) => {
|
||||
const schema = zod_1.z.object({
|
||||
name: zod_1.z.string().min(1).max(80),
|
||||
apiKey: zod_1.z.string().min(10),
|
||||
provider: zod_1.z.enum(['GEMINI', 'OPENAI']).default('GEMINI'),
|
||||
});
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
if (!await validateInstance(instanceId, tenantId)) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' });
|
||||
return;
|
||||
}
|
||||
const body = schema.parse(req.body);
|
||||
const cred = await chatbot_repository_1.credentialRepo.create({ tenantId, instanceId, ...body });
|
||||
res.status(201).json(cred);
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof zod_1.z.ZodError) {
|
||||
res.status(400).json({ error: err.errors });
|
||||
return;
|
||||
}
|
||||
logger_1.logger.error({ err }, 'Erro ao criar credencial');
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// DELETE /api/chatbot/credentials/:instanceId/:credId
|
||||
router.delete('/credentials/:instanceId/:credId', async (req, res) => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const credId = req.params['credId'];
|
||||
await chatbot_repository_1.credentialRepo.delete(credId, tenantId);
|
||||
res.status(204).send();
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, 'Erro ao deletar credencial');
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// BOTS
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// GET /api/chatbot/bots/:instanceId
|
||||
router.get('/bots/:instanceId', async (req, res) => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
if (!await validateInstance(instanceId, tenantId)) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' });
|
||||
return;
|
||||
}
|
||||
const bots = await chatbot_repository_1.botRepo.findAll(tenantId, instanceId);
|
||||
res.json(bots);
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, 'Erro ao listar bots');
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// POST /api/chatbot/bots/:instanceId
|
||||
router.post('/bots/:instanceId', async (req, res) => {
|
||||
const schema = zod_1.z.object({
|
||||
credentialId: zod_1.z.string().uuid(),
|
||||
name: zod_1.z.string().min(1).max(80),
|
||||
systemPrompt: zod_1.z.string().min(10),
|
||||
model: zod_1.z.string().default('gemini-1.5-flash'),
|
||||
enabled: zod_1.z.boolean().default(false),
|
||||
triggerMode: zod_1.z.enum(['ALL', 'KEYWORD']).default('ALL'),
|
||||
keywords: zod_1.z.array(zod_1.z.string()).default([]),
|
||||
});
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
if (!await validateInstance(instanceId, tenantId)) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' });
|
||||
return;
|
||||
}
|
||||
const body = schema.parse(req.body);
|
||||
const existing = await chatbot_repository_1.botRepo.findAll(tenantId, instanceId);
|
||||
if (existing.length > 0) {
|
||||
res.status(409).json({ error: 'Já existe um bot nesta instância. Use PUT para atualizar.' });
|
||||
return;
|
||||
}
|
||||
const bot = await chatbot_repository_1.botRepo.create({ tenantId, instanceId, ...body });
|
||||
res.status(201).json(bot);
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof zod_1.z.ZodError) {
|
||||
res.status(400).json({ error: err.errors });
|
||||
return;
|
||||
}
|
||||
logger_1.logger.error({ err }, 'Erro ao criar bot');
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// PUT /api/chatbot/bots/:instanceId/:botId
|
||||
router.put('/bots/:instanceId/:botId', async (req, res) => {
|
||||
const schema = zod_1.z.object({
|
||||
credentialId: zod_1.z.string().uuid().optional(),
|
||||
name: zod_1.z.string().min(1).max(80).optional(),
|
||||
systemPrompt: zod_1.z.string().min(10).optional(),
|
||||
model: zod_1.z.string().optional(),
|
||||
enabled: zod_1.z.boolean().optional(),
|
||||
triggerMode: zod_1.z.enum(['ALL', 'KEYWORD']).optional(),
|
||||
keywords: zod_1.z.array(zod_1.z.string()).optional(),
|
||||
});
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const botId = req.params['botId'];
|
||||
const body = schema.parse(req.body);
|
||||
await chatbot_repository_1.botRepo.update(botId, tenantId, body);
|
||||
const updated = await chatbot_repository_1.botRepo.findById(botId, tenantId);
|
||||
res.json(updated);
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof zod_1.z.ZodError) {
|
||||
res.status(400).json({ error: err.errors });
|
||||
return;
|
||||
}
|
||||
logger_1.logger.error({ err }, 'Erro ao atualizar bot');
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// DELETE /api/chatbot/bots/:instanceId/:botId
|
||||
router.delete('/bots/:instanceId/:botId', async (req, res) => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const botId = req.params['botId'];
|
||||
await chatbot_repository_1.botRepo.delete(botId, tenantId);
|
||||
res.status(204).send();
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, 'Erro ao deletar bot');
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// BOT STATE — Human Takeover por chat
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// POST /api/chatbot/chats/:chatId/pause — pausa o bot (human takeover)
|
||||
router.post('/chats/:chatId/pause', async (req, res) => {
|
||||
try {
|
||||
await chatbot_repository_1.chatBotState.pauseBot(req.params['chatId']);
|
||||
res.json({ paused: true });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// POST /api/chatbot/chats/:chatId/resume — retoma o bot
|
||||
router.post('/chats/:chatId/resume', async (req, res) => {
|
||||
try {
|
||||
await chatbot_repository_1.chatBotState.resumeBot(req.params['chatId']);
|
||||
res.json({ paused: false });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
//# sourceMappingURL=chatbot.routes.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,168 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ChatRepository = void 0;
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
class ChatRepository {
|
||||
async findAllByInstance(tenantId, instanceId, opts = {}) {
|
||||
const { limit = 200, search, archived = false } = opts;
|
||||
// ── Q1: chats (sem includes — bate o N+1) ────────────────────────────────
|
||||
const chats = await prisma_1.prisma.chat.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
isArchived: archived,
|
||||
NOT: { OR: [
|
||||
{ jid: { endsWith: '@lid' } },
|
||||
{ jid: { contains: '@broadcast' } },
|
||||
{ jid: { endsWith: '@newsletter' } },
|
||||
] },
|
||||
...(search
|
||||
? {
|
||||
OR: [
|
||||
{ jid: { contains: search, mode: 'insensitive' } },
|
||||
{ contact: { name: { contains: search, mode: 'insensitive' } } },
|
||||
{ contact: { notify: { contains: search, mode: 'insensitive' } } },
|
||||
{ contact: { phone: { contains: search, mode: 'insensitive' } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [
|
||||
{ isPinned: 'desc' },
|
||||
{ lastMessageAt: { sort: 'desc', nulls: 'last' } },
|
||||
{ createdAt: 'desc' },
|
||||
],
|
||||
take: limit,
|
||||
});
|
||||
if (chats.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const chatIds = chats.map((c) => c.id);
|
||||
const contactIds = Array.from(new Set(chats.map((c) => c.contactId).filter((x) => !!x)));
|
||||
// ── Q2/Q3/Q4 em paralelo: contatos, protocols (1 por chat), last msg (1 por chat) ──
|
||||
const [contacts, protocols, lastMsgs] = await Promise.all([
|
||||
contactIds.length > 0
|
||||
? prisma_1.prisma.contact.findMany({
|
||||
where: { id: { in: contactIds } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
notify: true,
|
||||
verifiedName: true,
|
||||
phone: true,
|
||||
avatarUrl: true,
|
||||
scoreReputacao: true,
|
||||
flagRestricao: true,
|
||||
},
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
// DISTINCT ON: 1 query batch — pega o protocolo aberto mais recente por chat
|
||||
prisma_1.prisma.$queryRaw `
|
||||
SELECT DISTINCT ON ("chatId") "chatId", number, status::text AS status
|
||||
FROM protocols
|
||||
WHERE "chatId" = ANY(${chatIds}::text[])
|
||||
AND status IN ('OPEN','IN_PROGRESS','WAITING_CLIENT','WAITING_AGENT')
|
||||
ORDER BY "chatId", "createdAt" DESC
|
||||
`,
|
||||
// DISTINCT ON: 1 query batch — pega a última msg por chat
|
||||
prisma_1.prisma.$queryRaw `
|
||||
SELECT DISTINCT ON ("chatId") "chatId", body, "fromMe", status::text AS status, timestamp, type::text AS type, "pushName"
|
||||
FROM messages
|
||||
WHERE "chatId" = ANY(${chatIds}::text[])
|
||||
ORDER BY "chatId", timestamp DESC
|
||||
`,
|
||||
]);
|
||||
// ── Indexa para lookup O(1) ──────────────────────────────────────────────
|
||||
const contactById = new Map(contacts.map((c) => [c.id, c]));
|
||||
const protocolByChat = new Map(protocols.map((p) => [p.chatId, p]));
|
||||
const lastMsgByChat = new Map(lastMsgs.map((m) => [m.chatId, m]));
|
||||
// ── Re-monta a estrutura no formato esperado pelo chat.routes.ts ─────────
|
||||
return chats.map((c) => {
|
||||
const contact = c.contactId ? contactById.get(c.contactId) ?? null : null;
|
||||
const protocol = protocolByChat.get(c.id);
|
||||
const lastMsg = lastMsgByChat.get(c.id);
|
||||
return {
|
||||
...c,
|
||||
contact: contact
|
||||
? {
|
||||
name: contact.name,
|
||||
notify: contact.notify,
|
||||
verifiedName: contact.verifiedName,
|
||||
phone: contact.phone,
|
||||
avatarUrl: contact.avatarUrl,
|
||||
scoreReputacao: contact.scoreReputacao,
|
||||
flagRestricao: contact.flagRestricao,
|
||||
}
|
||||
: null,
|
||||
protocols: protocol
|
||||
? [{ number: protocol.number, status: protocol.status }]
|
||||
: [],
|
||||
messages: lastMsg
|
||||
? [{
|
||||
body: lastMsg.body,
|
||||
fromMe: lastMsg.fromMe,
|
||||
status: lastMsg.status,
|
||||
timestamp: lastMsg.timestamp,
|
||||
type: lastMsg.type,
|
||||
pushName: lastMsg.pushName,
|
||||
}]
|
||||
: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
async findById(id, tenantId) {
|
||||
return prisma_1.prisma.chat.findFirst({
|
||||
where: { id, tenantId },
|
||||
include: {
|
||||
contact: true,
|
||||
protocols: {
|
||||
where: { status: { in: ['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT'] } },
|
||||
take: 1,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
async findByJid(tenantId, instanceId, jid) {
|
||||
return prisma_1.prisma.chat.findUnique({
|
||||
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } },
|
||||
include: { contact: true },
|
||||
});
|
||||
}
|
||||
async clearUnread(id, tenantId) {
|
||||
return prisma_1.prisma.chat.updateMany({
|
||||
where: { id, tenantId },
|
||||
data: { unreadCount: 0 },
|
||||
});
|
||||
}
|
||||
async setArchived(id, tenantId, archived) {
|
||||
return prisma_1.prisma.chat.updateMany({
|
||||
where: { id, tenantId },
|
||||
data: { isArchived: archived },
|
||||
});
|
||||
}
|
||||
async setPinned(id, tenantId, pinned) {
|
||||
return prisma_1.prisma.chat.updateMany({
|
||||
where: { id, tenantId },
|
||||
data: { isPinned: pinned },
|
||||
});
|
||||
}
|
||||
async countUnreadByInstance(tenantId, instanceId) {
|
||||
const result = await prisma_1.prisma.chat.aggregate({
|
||||
where: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
isArchived: false,
|
||||
NOT: { OR: [
|
||||
{ jid: { endsWith: '@lid' } },
|
||||
{ jid: { contains: '@broadcast' } },
|
||||
{ jid: { endsWith: '@newsletter' } },
|
||||
] }
|
||||
},
|
||||
_sum: { unreadCount: true },
|
||||
});
|
||||
return result._sum.unreadCount ?? 0;
|
||||
}
|
||||
}
|
||||
exports.ChatRepository = ChatRepository;
|
||||
//# sourceMappingURL=chat.repository.js.map
|
||||
File diff suppressed because one or more lines are too long
+134
@@ -0,0 +1,134 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
|
||||
export class ChatRepository {
|
||||
async findAllByInstance(tenantId: string, instanceId: string, opts: {
|
||||
limit?: number
|
||||
search?: string
|
||||
archived?: boolean
|
||||
} = {}) {
|
||||
const { limit = 200, search, archived = false } = opts
|
||||
|
||||
return prisma.chat.findMany({
|
||||
relationLoadStrategy: 'join',
|
||||
where: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
isArchived: archived,
|
||||
NOT: { OR: [
|
||||
{ jid: { endsWith: '@lid' } },
|
||||
{ jid: { contains: '@broadcast' } },
|
||||
{ jid: { endsWith: '@newsletter' } },
|
||||
]},
|
||||
...(search
|
||||
? {
|
||||
OR: [
|
||||
{ jid: { contains: search, mode: 'insensitive' as const } },
|
||||
{ contact: { name: { contains: search, mode: 'insensitive' as const } } },
|
||||
{ contact: { notify: { contains: search, mode: 'insensitive' as const } } },
|
||||
{ contact: { phone: { contains: search, mode: 'insensitive' as const } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
contact: {
|
||||
select: {
|
||||
name: true,
|
||||
notify: true,
|
||||
verifiedName: true,
|
||||
phone: true,
|
||||
avatarUrl: true,
|
||||
scoreReputacao: true,
|
||||
flagRestricao: true,
|
||||
},
|
||||
},
|
||||
protocols: {
|
||||
where: { status: { in: ['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT'] } },
|
||||
select: { number: true, status: true },
|
||||
take: 1,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
messages: {
|
||||
select: {
|
||||
body: true,
|
||||
fromMe: true,
|
||||
status: true,
|
||||
timestamp: true,
|
||||
type: true,
|
||||
pushName: true,
|
||||
},
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ isPinned: 'desc' },
|
||||
// COALESCE: usa lastMessageAt se existir, senão createdAt (chats novos sem msg)
|
||||
// Garante que chats recém-criados não caiam para o fundo da lista
|
||||
{ lastMessageAt: { sort: 'desc', nulls: 'last' } },
|
||||
{ createdAt: 'desc' },
|
||||
],
|
||||
take: limit,
|
||||
})
|
||||
}
|
||||
|
||||
async findById(id: string, tenantId: string) {
|
||||
return prisma.chat.findFirst({
|
||||
where: { id, tenantId },
|
||||
include: {
|
||||
contact: true,
|
||||
protocols: {
|
||||
where: { status: { in: ['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT'] } },
|
||||
take: 1,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async findByJid(tenantId: string, instanceId: string, jid: string) {
|
||||
return prisma.chat.findUnique({
|
||||
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } },
|
||||
include: { contact: true },
|
||||
})
|
||||
}
|
||||
|
||||
async clearUnread(id: string, tenantId: string) {
|
||||
return prisma.chat.updateMany({
|
||||
where: { id, tenantId },
|
||||
data: { unreadCount: 0 },
|
||||
})
|
||||
}
|
||||
|
||||
async setArchived(id: string, tenantId: string, archived: boolean) {
|
||||
return prisma.chat.updateMany({
|
||||
where: { id, tenantId },
|
||||
data: { isArchived: archived },
|
||||
})
|
||||
}
|
||||
|
||||
async setPinned(id: string, tenantId: string, pinned: boolean) {
|
||||
return prisma.chat.updateMany({
|
||||
where: { id, tenantId },
|
||||
data: { isPinned: pinned },
|
||||
})
|
||||
}
|
||||
|
||||
async countUnreadByInstance(tenantId: string, instanceId: string): Promise<number> {
|
||||
const result = await prisma.chat.aggregate({
|
||||
where: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
isArchived: false,
|
||||
NOT: { OR: [
|
||||
{ jid: { endsWith: '@lid' } },
|
||||
{ jid: { contains: '@broadcast' } },
|
||||
{ jid: { endsWith: '@newsletter' } },
|
||||
]}
|
||||
},
|
||||
_sum: { unreadCount: true },
|
||||
})
|
||||
return result._sum.unreadCount ?? 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildChatRoutes = buildChatRoutes;
|
||||
/**
|
||||
* Rotas de Chats — API REST para a inbox do frontend.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /api/chats — Lista chats (com filtros e último msg)
|
||||
* GET /api/chats/search/messages — Busca full-text em mensagens
|
||||
* GET /api/chats/stats/overview — Contagens para o dashboard
|
||||
* GET /api/chats/:id — Detalhe de um chat
|
||||
* POST /api/chats/:id/read — Zera unread_count
|
||||
* PATCH /api/chats/:id/archive — Arquiva/desarquiva
|
||||
* PATCH /api/chats/:id/pin — Fixa/desfixa
|
||||
* DELETE /api/chats/:id — Remove chat e mensagens
|
||||
* GET /api/chats/:id/messages — Histórico paginado por cursor
|
||||
* GET /api/chats/:id/protocol — Protocolos do chat
|
||||
* POST /api/chats/:id/protocol — Abre novo protocolo
|
||||
* PATCH /api/chats/:chatId/protocol/:protocolId — Atualiza protocolo
|
||||
*
|
||||
* @see ChatRepository — queries otimizadas com batch DISTINCT ON
|
||||
* @see MessageRepository — busca e paginação de mensagens
|
||||
*/
|
||||
const express_1 = require("express");
|
||||
const crypto_1 = require("crypto");
|
||||
const zod_1 = require("zod");
|
||||
const chat_repository_1 = require("./chat.repository");
|
||||
const message_repository_1 = require("./message.repository");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const dragonfly_1 = require("../../infra/cache/dragonfly");
|
||||
const chat_cache_1 = require("./chat.cache");
|
||||
const chatRepo = new chat_repository_1.ChatRepository();
|
||||
const msgRepo = new message_repository_1.MessageRepository();
|
||||
/** TTL do cache de listagem de chats — longo para estabilidade, invalidado por eventos e mutações */
|
||||
const CHAT_LIST_CACHE_TTL_S = 86400;
|
||||
/** Handler genérico de erros: diferencia ZodError (400) de erros internos (500) */
|
||||
function handleError(res, err) {
|
||||
if (err instanceof zod_1.z.ZodError) {
|
||||
res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: err.message ?? 'Erro interno' });
|
||||
}
|
||||
/** Constrói a chave de cache da listagem. Inclui search/archived/limit. */
|
||||
function chatListCacheKey(tenantId, instanceId, archived, limit) {
|
||||
return `chats:list:${tenantId}:${instanceId}:${archived ? '1' : '0'}:${limit}`;
|
||||
}
|
||||
function buildChatRoutes(manager) {
|
||||
const router = (0, express_1.Router)();
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats — Lista chats com snapshot da última mensagem
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { instanceId, search, archived, limit } = zod_1.z.object({
|
||||
instanceId: zod_1.z.string().uuid(),
|
||||
search: zod_1.z.string().optional(),
|
||||
archived: zod_1.z.coerce.boolean().optional(),
|
||||
limit: zod_1.z.coerce.number().int().min(1).max(200).optional(),
|
||||
}).parse(req.query);
|
||||
const archivedFlag = archived ?? false;
|
||||
const limitVal = limit ?? 200;
|
||||
const tenantId = req.tenantId;
|
||||
// ── Cache hit: só quando não houver search (busca tem termos únicos) ──
|
||||
const useCache = !search;
|
||||
let cacheKey = null;
|
||||
if (useCache) {
|
||||
cacheKey = chatListCacheKey(tenantId, instanceId, archivedFlag, limitVal);
|
||||
const cached = await dragonfly_1.dragonfly.getJson(cacheKey);
|
||||
if (cached) {
|
||||
res.setHeader('X-Cache', 'HIT');
|
||||
res.json(cached);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const chats = await chatRepo.findAllByInstance(tenantId, instanceId, {
|
||||
search,
|
||||
archived,
|
||||
limit,
|
||||
});
|
||||
const result = chats.map((c) => {
|
||||
const contact = c.contact;
|
||||
const isGroup = c.jid.endsWith('@g.us');
|
||||
const lastMsg = c.messages[0];
|
||||
const activeProtocol = c.protocols[0] ?? null;
|
||||
const resolvedName = isGroup
|
||||
? (c.name ?? null)
|
||||
: (contact?.name ?? contact?.verifiedName ?? contact?.notify
|
||||
?? (lastMsg && !lastMsg.fromMe ? lastMsg.pushName : null)
|
||||
?? null);
|
||||
return {
|
||||
id: c.id,
|
||||
instanceId: c.instanceId,
|
||||
jid: c.jid,
|
||||
name: c.name ?? null,
|
||||
isPinned: c.isPinned,
|
||||
isArchived: c.isArchived,
|
||||
unreadCount: c.unreadCount,
|
||||
lastMessageAt: c.lastMessageAt,
|
||||
createdAt: c.createdAt,
|
||||
contact: contact
|
||||
? {
|
||||
name: resolvedName,
|
||||
phone: contact.phone,
|
||||
avatarUrl: contact.avatarUrl,
|
||||
// Hash curto da URL armazenada — muda quando o avatar é atualizado no banco.
|
||||
// Usado pelo frontend como chave de cache IndexedDB (invalida ao trocar foto).
|
||||
avatarVersion: contact.avatarUrl
|
||||
? (0, crypto_1.createHash)('md5').update(contact.avatarUrl).digest('hex').slice(0, 8)
|
||||
: null,
|
||||
scoreReputacao: contact.scoreReputacao,
|
||||
flagRestricao: contact.flagRestricao,
|
||||
}
|
||||
: isGroup
|
||||
? { name: resolvedName, phone: null, avatarUrl: null, avatarVersion: null, scoreReputacao: 0, flagRestricao: false }
|
||||
: null,
|
||||
lastMessage: lastMsg
|
||||
? {
|
||||
body: lastMsg.body,
|
||||
fromMe: lastMsg.fromMe,
|
||||
status: lastMsg.status,
|
||||
type: lastMsg.type,
|
||||
timestamp: lastMsg.timestamp,
|
||||
pushName: lastMsg.fromMe ? null : (lastMsg.pushName ?? null),
|
||||
}
|
||||
: null,
|
||||
protocol: activeProtocol
|
||||
? { number: activeProtocol.number, status: activeProtocol.status }
|
||||
: null,
|
||||
};
|
||||
});
|
||||
if (useCache && cacheKey) {
|
||||
// fire-and-forget — não bloqueia a resposta
|
||||
dragonfly_1.dragonfly.setJson(cacheKey, result, CHAT_LIST_CACHE_TTL_S).catch(() => { });
|
||||
}
|
||||
res.setHeader('X-Cache', 'MISS');
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats/search/messages
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.get('/search/messages', async (req, res) => {
|
||||
try {
|
||||
const { instanceId, q, limit } = zod_1.z.object({
|
||||
instanceId: zod_1.z.string().uuid(),
|
||||
q: zod_1.z.string().min(2),
|
||||
limit: zod_1.z.coerce.number().int().min(1).max(50).optional(),
|
||||
}).parse(req.query);
|
||||
const results = await msgRepo.search(req.tenantId, instanceId, q, limit);
|
||||
res.json(results);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats/stats/overview
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.get('/stats/overview', async (req, res) => {
|
||||
try {
|
||||
const { instanceId } = zod_1.z.object({ instanceId: zod_1.z.string().uuid() }).parse(req.query);
|
||||
const cacheKey = `stats:${req.tenantId}:${instanceId}`;
|
||||
const cached = await dragonfly_1.dragonfly.getJson(cacheKey);
|
||||
if (cached) {
|
||||
res.json(cached);
|
||||
return;
|
||||
}
|
||||
const [totalUnread, openProtocols, totalChats] = await Promise.all([
|
||||
chatRepo.countUnreadByInstance(req.tenantId, instanceId),
|
||||
prisma_1.prisma.protocol.count({
|
||||
where: {
|
||||
tenantId: req.tenantId,
|
||||
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
||||
},
|
||||
}),
|
||||
prisma_1.prisma.chat.count({
|
||||
where: {
|
||||
tenantId: req.tenantId,
|
||||
instanceId,
|
||||
isArchived: false,
|
||||
NOT: { jid: { endsWith: '@lid' } }
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const stats = { totalUnread, openProtocols, totalChats };
|
||||
await dragonfly_1.dragonfly.setJson(cacheKey, stats, 30);
|
||||
res.json(stats);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats/:id
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const chat = await chatRepo.findById(id, req.tenantId);
|
||||
if (!chat) {
|
||||
res.status(404).json({ error: 'Chat não encontrado' });
|
||||
return;
|
||||
}
|
||||
res.json(chat);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// POST /api/chats/:id/read
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.post('/:id/read', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const tenantId = req.tenantId;
|
||||
const chat = await prisma_1.prisma.chat.findFirst({ where: { id, tenantId }, select: { instanceId: true } });
|
||||
await chatRepo.clearUnread(id, tenantId);
|
||||
if (chat)
|
||||
(0, chat_cache_1.invalidateChatListCache)(tenantId, chat.instanceId).catch(() => { });
|
||||
res.json({ ok: true });
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PATCH /api/chats/:id/archive
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.patch('/:id/archive', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const { archived } = zod_1.z.object({ archived: zod_1.z.boolean() }).parse(req.body);
|
||||
const tenantId = req.tenantId;
|
||||
const chat = await prisma_1.prisma.chat.findFirst({ where: { id, tenantId }, select: { instanceId: true } });
|
||||
await chatRepo.setArchived(id, tenantId, archived);
|
||||
if (chat)
|
||||
(0, chat_cache_1.invalidateChatListCache)(tenantId, chat.instanceId).catch(() => { });
|
||||
res.json({ ok: true });
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PATCH /api/chats/:id/pin
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.patch('/:id/pin', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const { pinned } = zod_1.z.object({ pinned: zod_1.z.boolean() }).parse(req.body);
|
||||
const tenantId = req.tenantId;
|
||||
const chat = await prisma_1.prisma.chat.findFirst({ where: { id, tenantId }, select: { instanceId: true } });
|
||||
await chatRepo.setPinned(id, tenantId, pinned);
|
||||
if (chat)
|
||||
(0, chat_cache_1.invalidateChatListCache)(tenantId, chat.instanceId).catch(() => { });
|
||||
res.json({ ok: true });
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// DELETE /api/chats/:id
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const chat = await chatRepo.findById(id, req.tenantId);
|
||||
if (!chat) {
|
||||
res.status(404).json({ error: 'Chat não encontrado' });
|
||||
return;
|
||||
}
|
||||
await prisma_1.prisma.message.deleteMany({ where: { chatId: id } });
|
||||
await prisma_1.prisma.chat.delete({ where: { id } });
|
||||
(0, chat_cache_1.invalidateChatListCache)(req.tenantId, chat.instanceId).catch(() => { });
|
||||
res.status(204).send();
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats/:id/messages
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.get('/:id/messages', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const { before, after, limit } = zod_1.z.object({
|
||||
before: zod_1.z.string().datetime().optional(),
|
||||
after: zod_1.z.string().datetime().optional(),
|
||||
limit: zod_1.z.coerce.number().int().min(1).max(100).optional(),
|
||||
}).parse(req.query);
|
||||
const chat = await chatRepo.findById(id, req.tenantId);
|
||||
if (!chat) {
|
||||
res.status(404).json({ error: 'Chat não encontrado' });
|
||||
return;
|
||||
}
|
||||
const { messages, hasMore } = await msgRepo.findByChatPaginated(id, {
|
||||
before,
|
||||
after,
|
||||
limit,
|
||||
});
|
||||
res.json({ messages, hasMore });
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PROTOCOLOS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.get('/:id/protocol', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const chat = await chatRepo.findById(id, req.tenantId);
|
||||
if (!chat) {
|
||||
res.status(404).json({ error: 'Chat não encontrado' });
|
||||
return;
|
||||
}
|
||||
const protocols = await prisma_1.prisma.protocol.findMany({
|
||||
where: { chatId: id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
include: {
|
||||
sector: { select: { name: true } },
|
||||
team: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
res.json(protocols);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
router.post('/:id/protocol', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const { sectorId, teamId } = zod_1.z.object({
|
||||
sectorId: zod_1.z.string().uuid().optional(),
|
||||
teamId: zod_1.z.string().uuid().optional(),
|
||||
}).parse(req.body);
|
||||
const chat = await chatRepo.findById(id, req.tenantId);
|
||||
if (!chat) {
|
||||
res.status(404).json({ error: 'Chat não encontrado' });
|
||||
return;
|
||||
}
|
||||
if (!chat.contactId) {
|
||||
res.status(400).json({ error: 'Chat sem contato associado' });
|
||||
return;
|
||||
}
|
||||
const count = await prisma_1.prisma.protocol.count({ where: { chatId: id } });
|
||||
const year = new Date().getFullYear();
|
||||
const number = `#${String(count + 1).padStart(4, '0')}-${year}`;
|
||||
const protocol = await prisma_1.prisma.protocol.create({
|
||||
data: {
|
||||
tenantId: req.tenantId,
|
||||
number,
|
||||
chatId: id,
|
||||
contactId: chat.contactId,
|
||||
sectorId,
|
||||
teamId,
|
||||
status: 'OPEN',
|
||||
},
|
||||
});
|
||||
(0, chat_cache_1.invalidateChatListCache)(req.tenantId, chat.instanceId).catch(() => { });
|
||||
res.status(201).json(protocol);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
router.patch('/:chatId/protocol/:protocolId', async (req, res) => {
|
||||
try {
|
||||
const chatId = req.params['chatId'];
|
||||
const protocolId = req.params['protocolId'];
|
||||
const { status, summary } = zod_1.z.object({
|
||||
status: zod_1.z.enum(['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT', 'RESOLVED', 'CANCELLED']).optional(),
|
||||
summary: zod_1.z.string().max(500).optional(),
|
||||
}).parse(req.body);
|
||||
const protocol = await prisma_1.prisma.protocol.updateMany({
|
||||
where: { id: protocolId, chatId, tenantId: req.tenantId },
|
||||
data: {
|
||||
...(status && { status }),
|
||||
...(summary && { summary }),
|
||||
...(status === 'RESOLVED' && { resolvedAt: new Date() }),
|
||||
},
|
||||
});
|
||||
const chat = await prisma_1.prisma.chat.findFirst({ where: { id: chatId, tenantId: req.tenantId }, select: { instanceId: true } });
|
||||
if (chat)
|
||||
(0, chat_cache_1.invalidateChatListCache)(req.tenantId, chat.instanceId).catch(() => { });
|
||||
res.json({ updated: protocol.count });
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
//# sourceMappingURL=chat.routes.js.map
|
||||
File diff suppressed because one or more lines are too long
+480
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* Rotas de Chats — API REST para a inbox do frontend.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /api/chats — Lista chats (com filtros e último msg)
|
||||
* GET /api/chats/search/messages — Busca full-text em mensagens
|
||||
* GET /api/chats/stats/overview — Contagens para o dashboard
|
||||
* GET /api/chats/:id — Detalhe de um chat
|
||||
* POST /api/chats/:id/read — Zera unread_count
|
||||
* PATCH /api/chats/:id/archive — Arquiva/desarquiva
|
||||
* PATCH /api/chats/:id/pin — Fixa/desfixa
|
||||
* DELETE /api/chats/:id — Remove chat e mensagens
|
||||
* GET /api/chats/:id/messages — Histórico paginado por cursor
|
||||
* GET /api/chats/:id/protocol — Protocolos do chat
|
||||
* POST /api/chats/:id/protocol — Abre novo protocolo
|
||||
* PATCH /api/chats/:chatId/protocol/:protocolId — Atualiza protocolo
|
||||
*
|
||||
* RESOLUÇÃO DE NOME (displayName):
|
||||
* - Grupos: usa Chat.name (subject do grupo, sincronizado por groups.upsert)
|
||||
* - Individuais: Contact.name > Contact.verifiedName > Contact.notify > lastMsg.pushName
|
||||
* - Se nenhum nome disponível: retorna null → frontend formata o telefone
|
||||
*
|
||||
* @see ChatRepository — queries otimizadas com JOINs
|
||||
* @see MessageRepository — busca e paginação de mensagens
|
||||
*/
|
||||
import { Router, type Request, type Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { ChatRepository } from './chat.repository'
|
||||
import { MessageRepository } from './message.repository'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
import { dragonfly } from '../../infra/cache/dragonfly'
|
||||
import type { WhatsAppConnectionManager } from '../whatsapp/connection/WhatsAppConnectionManager'
|
||||
|
||||
const chatRepo = new ChatRepository()
|
||||
const msgRepo = new MessageRepository()
|
||||
|
||||
/** Handler genérico de erros: diferencia ZodError (400) de erros internos (500) */
|
||||
function handleError(res: Response, err: unknown) {
|
||||
if (err instanceof z.ZodError) {
|
||||
res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors })
|
||||
return
|
||||
}
|
||||
res.status(500).json({ error: (err as Error).message ?? 'Erro interno' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrói as rotas de chat.
|
||||
*
|
||||
* Montadas em: /api/chats
|
||||
* O WhatsAppConnectionManager é injetado mas não usado diretamente aqui
|
||||
* (reservado para futuras features como typing indicator).
|
||||
*/
|
||||
export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
const router = Router()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats — Lista chats com snapshot da última mensagem
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Lista chats do tenant para uma instância, com:
|
||||
* - Último mensagem (preview para a lista de chats)
|
||||
* - Protocolo ativo (se houver)
|
||||
* - Dados do contato (nome resolvido, avatar, reputação)
|
||||
*
|
||||
* Query params:
|
||||
* - instanceId: UUID (obrigatório)
|
||||
* - search?: string (busca por nome/telefone)
|
||||
* - archived?: boolean (filtrar por arquivados)
|
||||
* - limit?: number (1-200)
|
||||
*
|
||||
* REGRA DE RESOLUÇÃO DE NOME (resolvedName):
|
||||
* Grupos: Chat.name (subject do grupo)
|
||||
* Individuais:
|
||||
* 1. Contact.name (agenda do telefone — via contacts.upsert)
|
||||
* 2. Contact.verifiedName (conta business verificada)
|
||||
* 3. Contact.notify (pushname — via contacts.upsert ou mensagem)
|
||||
* 4. lastMsg.pushName (fallback: pushName da última mensagem recebida)
|
||||
* 5. null (frontend formata o telefone como "55 11 99999-9999")
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { instanceId, search, archived, limit } = z.object({
|
||||
instanceId: z.string().uuid(),
|
||||
search: z.string().optional(),
|
||||
archived: z.coerce.boolean().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
}).parse(req.query)
|
||||
|
||||
const chats = await chatRepo.findAllByInstance(req.tenantId!, instanceId, {
|
||||
search,
|
||||
archived,
|
||||
limit,
|
||||
})
|
||||
|
||||
// Serializa cada chat com nome resolvido e dados formatados para o frontend
|
||||
const result = chats.map((c) => {
|
||||
const contact = c.contact
|
||||
const isGroup = c.jid.endsWith('@g.us')
|
||||
|
||||
// Última mensagem (já vem do JOIN no repository, limitado a 1)
|
||||
const lastMsg = c.messages[0]
|
||||
// Protocolo ativo mais recente (OPEN ou IN_PROGRESS)
|
||||
const activeProtocol = c.protocols[0] ?? null
|
||||
|
||||
// ── Resolução de nome ─────────────────────────────────────────────
|
||||
// Regra: nunca retorna o JID bruto como nome — retorna null e deixa
|
||||
// o frontend fazer o fallback (telefone formatado ou "Grupo").
|
||||
//
|
||||
// Para individuais: usa hierarchia Contact > lastMsg.pushName > null
|
||||
// Para grupos: usa Chat.name (subject) ou null
|
||||
//
|
||||
// O fallback lastMsg.pushName cobre contatos que vieram do history
|
||||
// sync sem nome (a maioria — só ~22 de ~3000 têm verifiedName).
|
||||
const resolvedName = isGroup
|
||||
? (c.name ?? null)
|
||||
: (contact?.name ?? contact?.verifiedName ?? contact?.notify
|
||||
?? (lastMsg && !lastMsg.fromMe ? lastMsg.pushName : null)
|
||||
?? null)
|
||||
|
||||
return {
|
||||
id: c.id,
|
||||
instanceId: c.instanceId,
|
||||
jid: c.jid,
|
||||
name: c.name ?? null,
|
||||
isPinned: c.isPinned,
|
||||
isArchived: c.isArchived,
|
||||
unreadCount: c.unreadCount,
|
||||
lastMessageAt: c.lastMessageAt,
|
||||
createdAt: c.createdAt,
|
||||
contact: contact
|
||||
? {
|
||||
name: resolvedName, // null quando não há nome real
|
||||
phone: contact.phone,
|
||||
avatarUrl: contact.avatarUrl,
|
||||
scoreReputacao: contact.scoreReputacao,
|
||||
flagRestricao: contact.flagRestricao,
|
||||
}
|
||||
: isGroup
|
||||
? { name: resolvedName, phone: null, avatarUrl: null, scoreReputacao: 0, flagRestricao: false }
|
||||
: null,
|
||||
lastMessage: lastMsg
|
||||
? {
|
||||
body: lastMsg.body,
|
||||
fromMe: lastMsg.fromMe,
|
||||
status: lastMsg.status,
|
||||
type: lastMsg.type,
|
||||
timestamp: lastMsg.timestamp,
|
||||
pushName: lastMsg.fromMe ? null : (lastMsg.pushName ?? null),
|
||||
}
|
||||
: null,
|
||||
protocol: activeProtocol
|
||||
? { number: activeProtocol.number, status: activeProtocol.status }
|
||||
: null,
|
||||
}
|
||||
})
|
||||
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats/search/messages — Busca full-text em mensagens
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Busca full-text no corpo das mensagens.
|
||||
* MUST ser definido ANTES de /:id para evitar captura de "search" como param.
|
||||
*
|
||||
* Query params:
|
||||
* - instanceId: UUID
|
||||
* - q: string (mínimo 2 chars)
|
||||
* - limit?: number (1-50)
|
||||
*/
|
||||
router.get('/search/messages', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { instanceId, q, limit } = z.object({
|
||||
instanceId: z.string().uuid(),
|
||||
q: z.string().min(2),
|
||||
limit: z.coerce.number().int().min(1).max(50).optional(),
|
||||
}).parse(req.query)
|
||||
|
||||
const results = await msgRepo.search(req.tenantId!, instanceId, q, limit)
|
||||
res.json(results)
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats/stats/overview — Contagens para o dashboard
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Retorna métricas resumidas para o dashboard do frontend.
|
||||
* MUST ser definido ANTES de /:id para evitar captura de "stats" como param.
|
||||
*
|
||||
* Response: { totalUnread, openProtocols, totalChats }
|
||||
*
|
||||
* Cache DragonflyDB de 30s para não sobrecarregar o banco em dashboards
|
||||
* com refresh automático.
|
||||
*/
|
||||
router.get('/stats/overview', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { instanceId } = z.object({ instanceId: z.string().uuid() }).parse(req.query)
|
||||
|
||||
const cacheKey = `stats:${req.tenantId}:${instanceId}`
|
||||
const cached = await dragonfly.getJson(cacheKey)
|
||||
if (cached) { res.json(cached); return }
|
||||
|
||||
// Executa as 3 queries em paralelo para minimizar latência
|
||||
const [totalUnread, openProtocols, totalChats] = await Promise.all([
|
||||
chatRepo.countUnreadByInstance(req.tenantId!, instanceId),
|
||||
prisma.protocol.count({
|
||||
where: {
|
||||
tenantId: req.tenantId!,
|
||||
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
||||
},
|
||||
}),
|
||||
prisma.chat.count({
|
||||
where: {
|
||||
tenantId: req.tenantId!,
|
||||
instanceId,
|
||||
isArchived: false,
|
||||
// Exclui chats @lid (artefatos internos do WhatsApp)
|
||||
NOT: { jid: { endsWith: '@lid' } }
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const stats = { totalUnread, openProtocols, totalChats }
|
||||
await dragonfly.setJson(cacheKey, stats, 30) // cache 30s
|
||||
|
||||
res.json(stats)
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats/:id — Detalhe de um chat
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params['id'] as string
|
||||
const chat = await chatRepo.findById(id, req.tenantId!)
|
||||
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
|
||||
res.json(chat)
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// POST /api/chats/:id/read — Zera contador de não-lidas
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Marca todas as mensagens do chat como lidas (zera unreadCount) */
|
||||
router.post('/:id/read', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params['id'] as string
|
||||
await chatRepo.clearUnread(id, req.tenantId!)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PATCH /api/chats/:id/archive — Arquivar/desarquivar
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Body: { archived: boolean } */
|
||||
router.patch('/:id/archive', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params['id'] as string
|
||||
const { archived } = z.object({ archived: z.boolean() }).parse(req.body)
|
||||
await chatRepo.setArchived(id, req.tenantId!, archived)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PATCH /api/chats/:id/pin — Fixar/desfixar
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Body: { pinned: boolean } */
|
||||
router.patch('/:id/pin', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params['id'] as string
|
||||
const { pinned } = z.object({ pinned: z.boolean() }).parse(req.body)
|
||||
await chatRepo.setPinned(id, req.tenantId!, pinned)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// DELETE /api/chats/:id — Remove chat e mensagens
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Remove um chat e TODAS as suas mensagens permanentemente.
|
||||
* Operação irreversível — use com cuidado.
|
||||
* Remove mensagens primeiro (FK constraint) e depois o chat.
|
||||
*/
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params['id'] as string
|
||||
const chat = await chatRepo.findById(id, req.tenantId!)
|
||||
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
|
||||
await prisma.message.deleteMany({ where: { chatId: id } })
|
||||
await prisma.chat.delete({ where: { id } })
|
||||
res.status(204).send()
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats/:id/messages — Histórico paginado
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Retorna mensagens do chat com paginação por cursor (timestamp ISO).
|
||||
*
|
||||
* Query params:
|
||||
* - before?: ISO datetime (mensagens anteriores a este timestamp)
|
||||
* - after?: ISO datetime (mensagens posteriores a este timestamp)
|
||||
* - limit?: number (1-100, default definido no repository)
|
||||
*
|
||||
* Response: { messages: Message[], hasMore: boolean }
|
||||
*
|
||||
* O frontend usa infinite scroll: carrega as mais recentes primeiro,
|
||||
* depois pede mais antigas passando `before` do timestamp mais antigo.
|
||||
*/
|
||||
router.get('/:id/messages', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params['id'] as string
|
||||
const { before, after, limit } = z.object({
|
||||
before: z.string().datetime().optional(),
|
||||
after: z.string().datetime().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).optional(),
|
||||
}).parse(req.query)
|
||||
|
||||
// Valida que o chat pertence ao tenant (segurança multi-tenant)
|
||||
const chat = await chatRepo.findById(id, req.tenantId!)
|
||||
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
|
||||
|
||||
const { messages, hasMore } = await msgRepo.findByChatPaginated(id, {
|
||||
before,
|
||||
after,
|
||||
limit,
|
||||
})
|
||||
|
||||
res.json({ messages, hasMore })
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PROTOCOLOS DE ATENDIMENTO
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* GET /api/chats/:id/protocol — Últimos 10 protocolos do chat.
|
||||
* Inclui setor e equipe associados.
|
||||
*/
|
||||
router.get('/:id/protocol', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params['id'] as string
|
||||
const chat = await chatRepo.findById(id, req.tenantId!)
|
||||
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
|
||||
|
||||
const protocols = await prisma.protocol.findMany({
|
||||
where: { chatId: id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
include: {
|
||||
sector: { select: { name: true } },
|
||||
team: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
res.json(protocols)
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/chats/:id/protocol — Abre novo protocolo de atendimento.
|
||||
*
|
||||
* Body (opcional):
|
||||
* - sectorId?: UUID — setor responsável
|
||||
* - teamId?: UUID — equipe responsável
|
||||
*
|
||||
* O número do protocolo é gerado sequencialmente: #0001-2026, #0002-2026, etc.
|
||||
* Exige que o chat tenha um contactId vinculado (não funciona para chats órfãos).
|
||||
*/
|
||||
router.post('/:id/protocol', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params['id'] as string
|
||||
const { sectorId, teamId } = z.object({
|
||||
sectorId: z.string().uuid().optional(),
|
||||
teamId: z.string().uuid().optional(),
|
||||
}).parse(req.body)
|
||||
|
||||
const chat = await chatRepo.findById(id, req.tenantId!)
|
||||
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
|
||||
if (!chat.contactId) { res.status(400).json({ error: 'Chat sem contato associado' }); return }
|
||||
|
||||
// Gera número de protocolo sequencial com ano
|
||||
const count = await prisma.protocol.count({ where: { chatId: id } })
|
||||
const year = new Date().getFullYear()
|
||||
const number = `#${String(count + 1).padStart(4, '0')}-${year}`
|
||||
|
||||
const protocol = await prisma.protocol.create({
|
||||
data: {
|
||||
tenantId: req.tenantId!,
|
||||
number,
|
||||
chatId: id,
|
||||
contactId: chat.contactId,
|
||||
sectorId,
|
||||
teamId,
|
||||
status: 'OPEN',
|
||||
},
|
||||
})
|
||||
|
||||
res.status(201).json(protocol)
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* PATCH /api/chats/:chatId/protocol/:protocolId — Atualiza protocolo.
|
||||
*
|
||||
* Body:
|
||||
* - status?: enum — novo status do protocolo
|
||||
* - summary?: string (max 500) — resumo do atendimento
|
||||
*
|
||||
* Se status='RESOLVED', preenche resolvedAt automaticamente.
|
||||
*
|
||||
* Status possíveis:
|
||||
* OPEN → IN_PROGRESS → WAITING_CLIENT / WAITING_AGENT → RESOLVED / CANCELLED
|
||||
*/
|
||||
router.patch('/:chatId/protocol/:protocolId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chatId = req.params['chatId'] as string
|
||||
const protocolId = req.params['protocolId'] as string
|
||||
const { status, summary } = z.object({
|
||||
status: z.enum(['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT', 'RESOLVED', 'CANCELLED']).optional(),
|
||||
summary: z.string().max(500).optional(),
|
||||
}).parse(req.body)
|
||||
|
||||
const protocol = await prisma.protocol.updateMany({
|
||||
where: { id: protocolId, chatId, tenantId: req.tenantId! },
|
||||
data: {
|
||||
...(status && { status }),
|
||||
...(summary && { summary }),
|
||||
...(status === 'RESOLVED' && { resolvedAt: new Date() }),
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ updated: protocol.count })
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.MessageRepository = void 0;
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
class MessageRepository {
|
||||
/**
|
||||
* Histórico paginado por cursor (timestamp).
|
||||
* Retorna `limit` mensagens anteriores ao cursor dado.
|
||||
* Se cursor não for fornecido, retorna as mais recentes.
|
||||
*/
|
||||
async findByChatPaginated(chatId, opts = {}) {
|
||||
const { limit = 40, before, after } = opts;
|
||||
const where = { chatId };
|
||||
if (before)
|
||||
where['timestamp'] = { lt: new Date(before) };
|
||||
if (after)
|
||||
where['timestamp'] = { gt: new Date(after) };
|
||||
// Initial load (sem cursor) e scroll-up (before): busca DESC e inverte
|
||||
// para retornar as mensagens mais recentes em ordem cronológica.
|
||||
// Scroll-down (after): busca ASC naturalmente.
|
||||
const needsReverse = !after;
|
||||
const messages = await prisma_1.prisma.message.findMany({
|
||||
where,
|
||||
orderBy: { timestamp: needsReverse ? 'desc' : 'asc' },
|
||||
take: limit,
|
||||
include: {
|
||||
replyTo: {
|
||||
select: {
|
||||
id: true,
|
||||
messageId: true,
|
||||
body: true,
|
||||
fromMe: true,
|
||||
type: true,
|
||||
mediaUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
// Inverte para ordem cronológica (mais antiga → mais recente)
|
||||
if (needsReverse)
|
||||
messages.reverse();
|
||||
const hasMore = messages.length === limit;
|
||||
return { messages, hasMore };
|
||||
}
|
||||
async findById(id) {
|
||||
return prisma_1.prisma.message.findUnique({ where: { id } });
|
||||
}
|
||||
async search(tenantId, instanceId, query, limit = 20) {
|
||||
return prisma_1.prisma.message.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
body: { contains: query, mode: 'insensitive' },
|
||||
},
|
||||
include: {
|
||||
chat: {
|
||||
select: {
|
||||
jid: true,
|
||||
contact: { select: { name: true, notify: true, avatarUrl: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
async updateStatus(messageId, instanceId, status) {
|
||||
return prisma_1.prisma.message.updateMany({
|
||||
where: { messageId, instanceId },
|
||||
data: { status: status },
|
||||
});
|
||||
}
|
||||
async countByChat(chatId) {
|
||||
return prisma_1.prisma.message.count({ where: { chatId } });
|
||||
}
|
||||
}
|
||||
exports.MessageRepository = MessageRepository;
|
||||
//# sourceMappingURL=message.repository.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"message.repository.js","sourceRoot":"","sources":["message.repository.ts"],"names":[],"mappings":";;;AAAA,wDAAoD;AAEpD,MAAa,iBAAiB;IAC5B;;;;OAIG;IACH,KAAK,CAAC,mBAAmB,CAAC,MAAc,EAAE,OAItC,EAAE;QACJ,MAAM,EAAE,KAAK,GAAG,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,IAAI,CAAA;QAE1C,MAAM,KAAK,GAA4B,EAAE,MAAM,EAAE,CAAA;QAEjD,IAAI,MAAM;YAAE,KAAK,CAAC,WAAW,CAAC,GAAG,EAAE,EAAE,EAAE,IAAI,IAAI,CAAC,MAAM,CAAC,EAAE,CAAA;QACzD,IAAI,KAAK;YAAE,KAAK,CAAC,WAAW,CAAC,GAAG,EAAE,EAAE,EAAE,IAAI,IAAI,CAAC,KAAK,CAAC,EAAE,CAAA;QAEvD,uEAAuE;QACvE,iEAAiE;QACjE,+CAA+C;QAC/C,MAAM,YAAY,GAAG,CAAC,KAAK,CAAA;QAE3B,MAAM,QAAQ,GAAG,MAAM,eAAM,CAAC,OAAO,CAAC,QAAQ,CAAC;YAC7C,KAAK;YACL,OAAO,EAAE,EAAE,SAAS,EAAE,YAAY,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,EAAE;YACrD,IAAI,EAAE,KAAK;YACX,OAAO,EAAE;gBACP,OAAO,EAAE;oBACP,MAAM,EAAE;wBACN,EAAE,EAAE,IAAI;wBACR,SAAS,EAAE,IAAI;wBACf,IAAI,EAAE,IAAI;wBACV,MAAM,EAAE,IAAI;wBACZ,IAAI,EAAE,IAAI;wBACV,QAAQ,EAAE,IAAI;qBACf;iBACF;aACF;SACF,CAAC,CAAA;QAEF,8DAA8D;QAC9D,IAAI,YAAY;YAAE,QAAQ,CAAC,OAAO,EAAE,CAAA;QAEpC,MAAM,OAAO,GAAG,QAAQ,CAAC,MAAM,KAAK,KAAK,CAAA;QAEzC,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAA;IAC9B,CAAC;IAED,KAAK,CAAC,QAAQ,CAAC,EAAU;QACvB,OAAO,eAAM,CAAC,OAAO,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,CAAA;IACrD,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,QAAgB,EAAE,UAAkB,EAAE,KAAa,EAAE,KAAK,GAAG,EAAE;QAC1E,OAAO,eAAM,CAAC,OAAO,CAAC,QAAQ,CAAC;YAC7B,KAAK,EAAE;gBACL,QAAQ;gBACR,UAAU;gBACV,IAAI,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,IAAI,EAAE,aAAa,EAAE;aAC/C;YACD,OAAO,EAAE;gBACP,IAAI,EAAE;oBACJ,MAAM,EAAE;wBACN,GAAG,EAAE,IAAI;wBACT,OAAO,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,EAAE;qBACnE;iBACF;aACF;YACD,OAAO,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE;YAC9B,IAAI,EAAE,KAAK;SACZ,CAAC,CAAA;IACJ,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,SAAiB,EAAE,UAAkB,EAAE,MAAc;QACtE,OAAO,eAAM,CAAC,OAAO,CAAC,UAAU,CAAC;YAC/B,KAAK,EAAE,EAAE,SAAS,EAAE,UAAU,EAAE;YAChC,IAAI,EAAE,EAAE,MAAM,EAAE,MAAa,EAAE;SAChC,CAAC,CAAA;IACJ,CAAC;IAED,KAAK,CAAC,WAAW,CAAC,MAAc;QAC9B,OAAO,eAAM,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,CAAC,CAAA;IACpD,CAAC;CACF;AAnFD,8CAmFC"}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildContactRoutes = buildContactRoutes;
|
||||
const express_1 = require("express");
|
||||
const zod_1 = require("zod");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const whatsapp_1 = require("../../shared/utils/whatsapp");
|
||||
const chat_cache_1 = require("../chats/chat.cache");
|
||||
function buildContactRoutes() {
|
||||
const router = (0, express_1.Router)();
|
||||
// ── GET /api/contacts/stats/overview — deve vir ANTES de /:id ─────────────
|
||||
router.get('/stats/overview', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.query['instanceId'];
|
||||
const where = { tenantId };
|
||||
if (instanceId)
|
||||
where['instanceId'] = instanceId;
|
||||
const [total, blocked, flagged] = await Promise.all([
|
||||
prisma_1.prisma.contact.count({ where }),
|
||||
prisma_1.prisma.contact.count({ where: { ...where, isBlocked: true } }),
|
||||
prisma_1.prisma.contact.count({ where: { ...where, flagRestricao: true } }),
|
||||
]);
|
||||
res.json({ total, blocked, flagged, active: total - blocked });
|
||||
});
|
||||
// ── GET /api/contacts — lista paginada ────────────────────────────────────
|
||||
router.get('/', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.query['instanceId'];
|
||||
const search = req.query['search'];
|
||||
const blocked = req.query['blocked'];
|
||||
const limit = Math.min(Number(req.query['limit'] ?? 50), 200);
|
||||
const offset = Number(req.query['offset'] ?? 0);
|
||||
const where = { tenantId };
|
||||
if (instanceId)
|
||||
where['instanceId'] = instanceId;
|
||||
if (blocked === 'true')
|
||||
where['isBlocked'] = true;
|
||||
if (blocked === 'false')
|
||||
where['isBlocked'] = false;
|
||||
if (search) {
|
||||
where['OR'] = [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ verifiedName: { contains: search, mode: 'insensitive' } },
|
||||
{ notify: { contains: search, mode: 'insensitive' } },
|
||||
{ phone: { contains: search } },
|
||||
{ jid: { contains: search } },
|
||||
];
|
||||
}
|
||||
const [total, contacts] = await Promise.all([
|
||||
prisma_1.prisma.contact.count({ where }),
|
||||
prisma_1.prisma.contact.findMany({
|
||||
where,
|
||||
// Tiebreaker por id é obrigatório: muitos contatos compartilham o mesmo
|
||||
// updatedAt (bulk sync), e sem segunda coluna a paginação por OFFSET
|
||||
// devolve linhas sobrepostas entre páginas — quebra "carregar mais".
|
||||
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: {
|
||||
id: true,
|
||||
instanceId: true,
|
||||
jid: true,
|
||||
name: true,
|
||||
verifiedName: true,
|
||||
notify: true,
|
||||
phone: true,
|
||||
avatarUrl: true,
|
||||
scoreReputacao: true,
|
||||
flagRestricao: true,
|
||||
isBlocked: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
res.json({
|
||||
total,
|
||||
contacts: contacts.map(c => ({
|
||||
...c,
|
||||
displayName: c.name || c.verifiedName || c.notify || (c.phone ? `+${c.phone}` : (0, whatsapp_1.normalizeJid)(c.jid).split('@')[0])
|
||||
}))
|
||||
});
|
||||
});
|
||||
// ── GET /api/contacts/:id — detalhe ──────────────────────────────────────
|
||||
router.get('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = req.params['id'];
|
||||
const contact = await prisma_1.prisma.contact.findFirst({
|
||||
where: { id, tenantId },
|
||||
include: {
|
||||
chats: {
|
||||
orderBy: { lastMessageAt: 'desc' },
|
||||
take: 1,
|
||||
select: { id: true, unreadCount: true, lastMessageAt: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!contact) {
|
||||
res.status(404).json({ error: 'Contato não encontrado' });
|
||||
return;
|
||||
}
|
||||
res.json(contact);
|
||||
});
|
||||
// ── PATCH /api/contacts/:id — editar nome / bloquear ─────────────────────
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = req.params['id'];
|
||||
const schema = zod_1.z.object({
|
||||
name: zod_1.z.string().min(1).max(255).optional(),
|
||||
isBlocked: zod_1.z.boolean().optional(),
|
||||
});
|
||||
let body;
|
||||
try {
|
||||
body = schema.parse(req.body);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(400).json({ error: err.errors ?? err.message });
|
||||
return;
|
||||
}
|
||||
const existing = await prisma_1.prisma.contact.findFirst({ where: { id, tenantId } });
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Contato não encontrado' });
|
||||
return;
|
||||
}
|
||||
const updated = await prisma_1.prisma.contact.update({ where: { id }, data: body });
|
||||
if (body.name && existing.instanceId) {
|
||||
(0, chat_cache_1.invalidateChatListCache)(tenantId, existing.instanceId).catch(() => { });
|
||||
}
|
||||
res.json(updated);
|
||||
});
|
||||
// ── DELETE /api/contacts/:id ──────────────────────────────────────────────
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = req.params['id'];
|
||||
const existing = await prisma_1.prisma.contact.findFirst({ where: { id, tenantId } });
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Contato não encontrado' });
|
||||
return;
|
||||
}
|
||||
// Desvincular chats antes de deletar o contato
|
||||
await prisma_1.prisma.chat.updateMany({
|
||||
where: { contactId: id },
|
||||
data: { contactId: null },
|
||||
});
|
||||
await prisma_1.prisma.contact.delete({ where: { id } });
|
||||
if (existing.instanceId) {
|
||||
(0, chat_cache_1.invalidateChatListCache)(tenantId, existing.instanceId).catch(() => { });
|
||||
}
|
||||
res.status(204).send();
|
||||
});
|
||||
return router;
|
||||
}
|
||||
//# sourceMappingURL=contact.routes.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,117 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.pairRouter = void 0;
|
||||
const express_1 = require("express");
|
||||
const zod_1 = require("zod");
|
||||
const pair_service_1 = require("./pair.service");
|
||||
const auth_middleware_1 = require("../../shared/middlewares/auth.middleware");
|
||||
const service = new pair_service_1.PairService();
|
||||
function handleError(res, err) {
|
||||
if (err instanceof zod_1.z.ZodError) {
|
||||
res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors });
|
||||
return;
|
||||
}
|
||||
const e = err;
|
||||
res.status(e.statusCode ?? 500).json({ error: e.message ?? 'Erro interno' });
|
||||
}
|
||||
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
||||
const requestSchema = zod_1.z.object({
|
||||
email: zod_1.z.string().email(),
|
||||
clientSystem: zod_1.z.string().min(1).max(100),
|
||||
clientName: zod_1.z.string().min(1).max(200),
|
||||
clientUrl: zod_1.z.string().url().nullable().optional(),
|
||||
});
|
||||
const confirmSchema = zod_1.z.object({
|
||||
email: zod_1.z.string().email(),
|
||||
code: zod_1.z.string().length(6),
|
||||
clientSystem: zod_1.z.string().min(1).max(100),
|
||||
clientName: zod_1.z.string().min(1).max(200),
|
||||
clientUrl: zod_1.z.string().url().nullable().optional(),
|
||||
});
|
||||
// ─── Router ───────────────────────────────────────────────────────────────────
|
||||
exports.pairRouter = (0, express_1.Router)();
|
||||
/**
|
||||
* POST /api/pair/request
|
||||
* Inicia pareamento: gera OTP e envia por e-mail.
|
||||
* Público (chamado pelo sistema cliente).
|
||||
*/
|
||||
exports.pairRouter.post('/request', async (req, res) => {
|
||||
try {
|
||||
const body = requestSchema.parse(req.body);
|
||||
const result = await service.request({
|
||||
...body,
|
||||
clientUrl: body.clientUrl ?? null,
|
||||
});
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* POST /api/pair/confirm
|
||||
* Confirma o OTP e retorna a integration_key.
|
||||
* Público (chamado pelo sistema cliente com o código que o usuário digitou).
|
||||
*/
|
||||
exports.pairRouter.post('/confirm', async (req, res) => {
|
||||
try {
|
||||
const body = confirmSchema.parse(req.body);
|
||||
const result = await service.confirm({
|
||||
...body,
|
||||
clientUrl: body.clientUrl ?? null,
|
||||
});
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* GET /api/pair/verify
|
||||
* Verifica se uma integration_key é válida.
|
||||
* Público — autenticado pelo header x-nw-key.
|
||||
*/
|
||||
exports.pairRouter.get('/verify', async (req, res) => {
|
||||
try {
|
||||
const raw = req.headers['x-nw-key'];
|
||||
const key = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (!key) {
|
||||
res.status(401).json({ error: 'Header x-nw-key ausente' });
|
||||
return;
|
||||
}
|
||||
const result = await service.verify(key);
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* GET /api/pair/connections
|
||||
* Lista pares ativos do usuário autenticado.
|
||||
* Protegido — requer JWT via authMiddleware.
|
||||
*/
|
||||
exports.pairRouter.get('/connections', auth_middleware_1.authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const pairs = await service.list(req.tenantId);
|
||||
res.json(pairs);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* DELETE /api/pair/connections/:id
|
||||
* Revoga um par do usuário autenticado.
|
||||
* Protegido — requer JWT via authMiddleware.
|
||||
*/
|
||||
exports.pairRouter.delete('/connections/:id', auth_middleware_1.authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const result = await service.revoke(req.tenantId, String(req.params.id));
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
//# sourceMappingURL=pair.routes.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"pair.routes.js","sourceRoot":"","sources":["pair.routes.ts"],"names":[],"mappings":"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"}
|
||||
@@ -0,0 +1,205 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.PairService = void 0;
|
||||
const crypto_1 = __importDefault(require("crypto"));
|
||||
const nodemailer_1 = __importDefault(require("nodemailer"));
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const dragonfly_1 = require("../../infra/cache/dragonfly");
|
||||
const logger_1 = require("../../config/logger");
|
||||
const OTP_TTL = 10 * 60; // 10 minutos
|
||||
const KEY_TTL = 90 * 24 * 3600; // 90 dias
|
||||
// ─── Keys do Dragonfly ────────────────────────────────────────────────────────
|
||||
const otpKey = (email) => `pair:otp:${email}`;
|
||||
const intKey = (integrationKey) => `pair:key:${integrationKey}`;
|
||||
// ─── Email sender ─────────────────────────────────────────────────────────────
|
||||
async function sendOtpEmail(to, code, clientName) {
|
||||
// Lê config SMTP do admin settings
|
||||
const raw = await dragonfly_1.dragonfly.getJson('system:settings');
|
||||
const smtp = raw?.smtp ?? {};
|
||||
if (!smtp.host || !smtp.user) {
|
||||
logger_1.logger.warn('[pair] SMTP não configurado — email de OTP não enviado. Configure em Admin → Configurações.');
|
||||
logger_1.logger.info(`[pair] OTP para ${to}: ${code}`); // fallback: loga localmente
|
||||
return false;
|
||||
}
|
||||
const transporter = nodemailer_1.default.createTransport({
|
||||
host: smtp.host,
|
||||
port: smtp.port ?? 587,
|
||||
secure: smtp.secure ?? false,
|
||||
auth: { user: smtp.user, pass: smtp.password },
|
||||
});
|
||||
await transporter.sendMail({
|
||||
from: smtp.from ?? smtp.user,
|
||||
to,
|
||||
subject: `[NewWhats] Código de pareamento — ${clientName}`,
|
||||
text: [
|
||||
`Olá!`,
|
||||
``,
|
||||
`O sistema "${clientName}" solicitou conexão com sua conta NewWhats.`,
|
||||
``,
|
||||
`Seu código de confirmação: ${code}`,
|
||||
``,
|
||||
`Válido por 10 minutos.`,
|
||||
``,
|
||||
`Se você não solicitou isso, ignore este email.`,
|
||||
].join('\n'),
|
||||
html: `
|
||||
<p>Olá!</p>
|
||||
<p>O sistema <strong>${clientName}</strong> solicitou conexão com sua conta NewWhats.</p>
|
||||
<h2 style="letter-spacing:8px;font-size:32px">${code}</h2>
|
||||
<p style="color:#888">Válido por 10 minutos. Se não foi você, ignore este email.</p>
|
||||
`,
|
||||
});
|
||||
logger_1.logger.info(`[pair] OTP enviado para ${to}`);
|
||||
return true;
|
||||
}
|
||||
// ─── PairService ──────────────────────────────────────────────────────────────
|
||||
class PairService {
|
||||
// Etapa 1 — solicitar pareamento
|
||||
async request(input) {
|
||||
const user = await prisma_1.prisma.user.findUnique({ where: { email: input.email } });
|
||||
if (!user) {
|
||||
// Resposta genérica — não revela se o email existe ou não
|
||||
return { ok: true, message: 'Se o email existir, um código será enviado.' };
|
||||
}
|
||||
if (!user.isActive) {
|
||||
throw Object.assign(new Error('Conta desativada'), { statusCode: 403 });
|
||||
}
|
||||
// Gera OTP de 6 dígitos
|
||||
const code = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
const payload = {
|
||||
code,
|
||||
clientSystem: input.clientSystem,
|
||||
clientName: input.clientName,
|
||||
clientUrl: input.clientUrl,
|
||||
};
|
||||
await dragonfly_1.dragonfly.setJson(otpKey(input.email), payload, OTP_TTL);
|
||||
const emailSent = await sendOtpEmail(input.email, code, input.clientName);
|
||||
// Quando SMTP não está configurado, devolve o código na resposta
|
||||
// para que o admin possa completar o pareamento manualmente.
|
||||
if (!emailSent) {
|
||||
return { ok: true, message: 'SMTP não configurado.', dev_code: code };
|
||||
}
|
||||
return { ok: true, message: 'Se o email existir, um código será enviado.' };
|
||||
}
|
||||
// Etapa 2 — confirmar com o código recebido
|
||||
async confirm(input) {
|
||||
const stored = await dragonfly_1.dragonfly.getJson(otpKey(input.email));
|
||||
if (!stored) {
|
||||
throw Object.assign(new Error('Código expirado ou inexistente. Solicite um novo.'), { statusCode: 400 });
|
||||
}
|
||||
if (stored.code !== input.code) {
|
||||
throw Object.assign(new Error('Código incorreto.'), { statusCode: 400 });
|
||||
}
|
||||
if (stored.clientSystem !== input.clientSystem) {
|
||||
throw Object.assign(new Error('Sistema não corresponde ao da solicitação.'), { statusCode: 400 });
|
||||
}
|
||||
const user = await prisma_1.prisma.user.findUnique({ where: { email: input.email } });
|
||||
if (!user || !user.isActive) {
|
||||
throw Object.assign(new Error('Conta não encontrada ou inativa.'), { statusCode: 404 });
|
||||
}
|
||||
// Revoga par anterior do mesmo sistema (evita duplicatas)
|
||||
await prisma_1.prisma.pluginPair.updateMany({
|
||||
where: { userId: user.id, clientSystem: input.clientSystem, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
// Gera integration_key exclusiva
|
||||
const integrationKey = `nwk_${crypto_1.default.randomUUID().replace(/-/g, '')}`;
|
||||
const pair = await prisma_1.prisma.pluginPair.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
clientSystem: input.clientSystem,
|
||||
clientName: input.clientName,
|
||||
clientUrl: input.clientUrl,
|
||||
integrationKey,
|
||||
},
|
||||
});
|
||||
// Cache rápido para validação sem bater no banco
|
||||
const keyPayload = {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
clientSystem: input.clientSystem,
|
||||
clientName: input.clientName,
|
||||
};
|
||||
await dragonfly_1.dragonfly.setJson(intKey(integrationKey), keyPayload, KEY_TTL);
|
||||
// Limpa OTP usado
|
||||
await dragonfly_1.dragonfly.del(otpKey(input.email));
|
||||
logger_1.logger.info(`[pair] ${user.email} ↔ ${input.clientName} pareados (${pair.id})`);
|
||||
return {
|
||||
integration_key: integrationKey,
|
||||
account: { name: user.name, email: user.email },
|
||||
paired_at: pair.createdAt,
|
||||
};
|
||||
}
|
||||
// Verifica se uma integration_key é válida (chamada pelo sistema cliente)
|
||||
async verify(integrationKey) {
|
||||
// Fast path — Dragonfly
|
||||
const cached = await dragonfly_1.dragonfly.getJson(intKey(integrationKey));
|
||||
if (cached) {
|
||||
// Atualiza lastSeenAt de forma assíncrona (sem bloquear a resposta)
|
||||
prisma_1.prisma.pluginPair.updateMany({
|
||||
where: { integrationKey, revokedAt: null },
|
||||
data: { lastSeenAt: new Date() },
|
||||
}).catch(() => { });
|
||||
return { valid: true, account: { email: cached.email }, client: cached.clientSystem };
|
||||
}
|
||||
// Slow path — banco
|
||||
const pair = await prisma_1.prisma.pluginPair.findUnique({
|
||||
where: { integrationKey },
|
||||
include: { user: { select: { name: true, email: true, isActive: true } } },
|
||||
});
|
||||
if (!pair || pair.revokedAt || !pair.user.isActive) {
|
||||
return { valid: false };
|
||||
}
|
||||
// Restaura cache
|
||||
const keyPayload = {
|
||||
userId: pair.userId,
|
||||
email: pair.user.email,
|
||||
clientSystem: pair.clientSystem,
|
||||
clientName: pair.clientName,
|
||||
};
|
||||
await dragonfly_1.dragonfly.setJson(intKey(integrationKey), keyPayload, KEY_TTL);
|
||||
await prisma_1.prisma.pluginPair.update({
|
||||
where: { integrationKey },
|
||||
data: { lastSeenAt: new Date() },
|
||||
});
|
||||
return { valid: true, account: { name: pair.user.name, email: pair.user.email }, client: pair.clientSystem };
|
||||
}
|
||||
// Lista pares ativos do usuário autenticado
|
||||
async list(userId) {
|
||||
const pairs = await prisma_1.prisma.pluginPair.findMany({
|
||||
where: { userId, revokedAt: null },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
clientSystem: true,
|
||||
clientName: true,
|
||||
clientUrl: true,
|
||||
lastSeenAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
return pairs;
|
||||
}
|
||||
// Revoga um par (usuário autenticado só pode revogar os seus)
|
||||
async revoke(userId, pairId) {
|
||||
const pair = await prisma_1.prisma.pluginPair.findFirst({
|
||||
where: { id: pairId, userId, revokedAt: null },
|
||||
});
|
||||
if (!pair) {
|
||||
throw Object.assign(new Error('Conexão não encontrada'), { statusCode: 404 });
|
||||
}
|
||||
await prisma_1.prisma.pluginPair.update({
|
||||
where: { id: pairId },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
// Remove do cache imediatamente
|
||||
await dragonfly_1.dragonfly.del(intKey(pair.integrationKey));
|
||||
logger_1.logger.info(`[pair] Par ${pairId} revogado por ${userId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
exports.PairService = PairService;
|
||||
//# sourceMappingURL=pair.service.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,101 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.pluginsRouter = void 0;
|
||||
const express_1 = require("express");
|
||||
const plugin_registry_1 = require("../../core/plugin-registry");
|
||||
const plugin_config_1 = require("../../core/plugin-config");
|
||||
const StorageProvider_1 = require("../../core/StorageProvider");
|
||||
/**
|
||||
* Rotas de gerenciamento de plugins — exclusivo admin.
|
||||
* Montado em /api/admin/plugins pelo server.ts via adminRouter.
|
||||
*/
|
||||
exports.pluginsRouter = (0, express_1.Router)();
|
||||
// GET /api/admin/plugins — lista todos os plugins com status e config atual
|
||||
exports.pluginsRouter.get('/', (_req, res) => {
|
||||
const plugins = plugin_registry_1.pluginRegistry.list().map((entry) => ({
|
||||
name: entry.name,
|
||||
displayName: entry.manifest.displayName,
|
||||
version: entry.manifest.version,
|
||||
description: entry.manifest.description,
|
||||
author: entry.manifest.author,
|
||||
category: entry.manifest.category,
|
||||
active: entry.active,
|
||||
canDisable: entry.manifest.canDisable,
|
||||
dependencies: entry.manifest.dependencies,
|
||||
configSchema: entry.manifest.configSchema ?? [],
|
||||
config: plugin_config_1.pluginConfig.get(entry.name),
|
||||
hooks: entry.manifest.hooks ?? { subscribes: [], emits: [] },
|
||||
}));
|
||||
res.json(plugins);
|
||||
});
|
||||
// POST /api/admin/plugins/:name/enable — ativa plugin
|
||||
exports.pluginsRouter.post('/:name/enable', async (req, res) => {
|
||||
const { name } = req.params;
|
||||
try {
|
||||
await plugin_registry_1.pluginRegistry.activate(name);
|
||||
res.json({ ok: true, message: `Plugin "${name}" ativado` });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// POST /api/admin/plugins/:name/disable — desativa plugin (se canDisable=true)
|
||||
exports.pluginsRouter.post('/:name/disable', async (req, res) => {
|
||||
const { name } = req.params;
|
||||
try {
|
||||
await plugin_registry_1.pluginRegistry.deactivate(name);
|
||||
res.json({ ok: true, message: `Plugin "${name}" desativado` });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// GET /api/admin/plugins/:name/config — retorna config atual
|
||||
exports.pluginsRouter.get('/:name/config', (req, res) => {
|
||||
const { name } = req.params;
|
||||
const entry = plugin_registry_1.pluginRegistry.getEntry(name);
|
||||
if (!entry)
|
||||
return res.status(404).json({ error: `Plugin "${name}" não encontrado` });
|
||||
res.json({
|
||||
name,
|
||||
config: plugin_config_1.pluginConfig.get(name),
|
||||
schema: entry.manifest.configSchema ?? [],
|
||||
});
|
||||
});
|
||||
// GET /api/admin/plugins/storage/health — retorna status detalhado do storage
|
||||
exports.pluginsRouter.get('/storage/health', async (_req, res) => {
|
||||
if (!StorageProvider_1.storageProvider.isRegistered()) {
|
||||
const cfg = plugin_config_1.pluginConfig.get('UnifiedStorageProvider');
|
||||
const configured = !!(cfg.wasabiAccessKey && cfg.wasabiSecretKey && cfg.wasabiBucket);
|
||||
return res.json({
|
||||
healthy: false,
|
||||
configured,
|
||||
reason: configured ? 'needs_restart' : 'not_configured',
|
||||
message: configured
|
||||
? 'Credenciais salvas mas o plugin ainda não foi inicializado. Reinicie o servidor ou reative o plugin.'
|
||||
: 'Wasabi não configurado. Acesse Admin > Plugins > UnifiedStorageProvider e insira as credenciais.',
|
||||
});
|
||||
}
|
||||
await StorageProvider_1.storageProvider.updateHealth();
|
||||
return res.json(StorageProvider_1.storageProvider.getLastHealthStatus());
|
||||
});
|
||||
// PUT /api/admin/plugins/:name/config — salva config e reativa plugin se necessário
|
||||
exports.pluginsRouter.put('/:name/config', async (req, res) => {
|
||||
const { name } = req.params;
|
||||
const entry = plugin_registry_1.pluginRegistry.getEntry(name);
|
||||
if (!entry)
|
||||
return res.status(404).json({ error: `Plugin "${name}" não encontrado` });
|
||||
const newConfig = req.body;
|
||||
await plugin_config_1.pluginConfig.set(name, newConfig);
|
||||
// Reativa o plugin para aplicar a nova config imediatamente
|
||||
if (entry.active) {
|
||||
try {
|
||||
await plugin_registry_1.pluginRegistry.reactivate(name);
|
||||
}
|
||||
catch (err) {
|
||||
return res.status(500).json({ error: `Config salva mas falha ao reativar: ${err.message}` });
|
||||
}
|
||||
}
|
||||
res.json({ ok: true, message: `Config do plugin "${name}" salva`, config: newConfig });
|
||||
});
|
||||
//# sourceMappingURL=plugins.routes.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"plugins.routes.js","sourceRoot":"","sources":["plugins.routes.ts"],"names":[],"mappings":"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"}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildScheduledRoutes = buildScheduledRoutes;
|
||||
/**
|
||||
* Rotas de Agendamentos de Mensagem
|
||||
*
|
||||
* GET /api/scheduled — Lista agendamentos do tenant
|
||||
* POST /api/scheduled — Cria agendamento
|
||||
* GET /api/scheduled/:id — Detalhe
|
||||
* PUT /api/scheduled/:id — Atualiza
|
||||
* DELETE /api/scheduled/:id — Remove
|
||||
* POST /api/scheduled/:id/pause — Pausa
|
||||
* POST /api/scheduled/:id/resume — Retoma
|
||||
* POST /api/scheduled/:id/run-now — Executa imediatamente
|
||||
* GET /api/scheduled/:id/logs — Histórico de execuções
|
||||
*/
|
||||
const express_1 = require("express");
|
||||
const zod_1 = require("zod");
|
||||
const croner_1 = require("croner");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const recipientSchema = zod_1.z.object({
|
||||
jid: zod_1.z.string(),
|
||||
name: zod_1.z.string().optional(),
|
||||
birthday: zod_1.z.string().optional(),
|
||||
anniversary: zod_1.z.string().optional(),
|
||||
signupDate: zod_1.z.string().optional(),
|
||||
});
|
||||
const scheduleSchema = zod_1.z.object({
|
||||
instanceId: zod_1.z.string(),
|
||||
name: zod_1.z.string().min(1).max(100),
|
||||
templateId: zod_1.z.string().optional(),
|
||||
payload: zod_1.z.record(zod_1.z.unknown()),
|
||||
recipientMode: zod_1.z.enum(['MANUAL', 'ALL_CONTACTS', 'TAG_FILTER']).default('MANUAL'),
|
||||
recipients: zod_1.z.array(recipientSchema).default([]),
|
||||
scheduleType: zod_1.z.enum(['ONCE', 'RECURRING', 'EVENT']),
|
||||
cronExpr: zod_1.z.string().optional(),
|
||||
scheduledAt: zod_1.z.string().datetime().optional(),
|
||||
eventType: zod_1.z.enum(['BIRTHDAY', 'ANNIVERSARY', 'SIGNUP_DAYS']).optional(),
|
||||
timezone: zod_1.z.string().default('America/Sao_Paulo'),
|
||||
});
|
||||
function getId(req) {
|
||||
const id = req.params['id'];
|
||||
return Array.isArray(id) ? id[0] : id;
|
||||
}
|
||||
function calcNextRun(data) {
|
||||
if (data.scheduleType === 'ONCE' && data.scheduledAt) {
|
||||
return new Date(data.scheduledAt);
|
||||
}
|
||||
if (data.scheduleType === 'RECURRING' && data.cronExpr) {
|
||||
try {
|
||||
const job = new croner_1.Cron(data.cronExpr, { paused: true });
|
||||
return job.nextRun() ?? null;
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function buildScheduledRoutes(_scheduler) {
|
||||
const router = (0, express_1.Router)();
|
||||
// ── LIST ──────────────────────────────────────────────────────────────────
|
||||
router.get('/', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const status = Array.isArray(req.query['status']) ? req.query['status'][0] : req.query['status'];
|
||||
const type = Array.isArray(req.query['type']) ? req.query['type'][0] : req.query['type'];
|
||||
const schedules = await prisma_1.prisma.scheduledMessage.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
...(status ? { status: status } : {}),
|
||||
...(type ? { scheduleType: type } : {}),
|
||||
},
|
||||
include: {
|
||||
template: { select: { id: true, name: true, type: true } },
|
||||
_count: { select: { logs: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(schedules);
|
||||
});
|
||||
// ── CREATE ────────────────────────────────────────────────────────────────
|
||||
router.post('/', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const parse = scheduleSchema.safeParse(req.body);
|
||||
if (!parse.success) {
|
||||
res.status(400).json({ error: parse.error.errors });
|
||||
return;
|
||||
}
|
||||
const data = parse.data;
|
||||
const nextRunAt = calcNextRun(data);
|
||||
const schedule = await prisma_1.prisma.scheduledMessage.create({
|
||||
data: {
|
||||
tenantId,
|
||||
instanceId: data.instanceId,
|
||||
name: data.name,
|
||||
templateId: data.templateId,
|
||||
payload: data.payload,
|
||||
recipientMode: data.recipientMode,
|
||||
recipients: data.recipients,
|
||||
scheduleType: data.scheduleType,
|
||||
cronExpr: data.cronExpr,
|
||||
scheduledAt: data.scheduledAt ? new Date(data.scheduledAt) : null,
|
||||
eventType: data.eventType ?? null,
|
||||
timezone: data.timezone,
|
||||
nextRunAt,
|
||||
},
|
||||
});
|
||||
res.status(201).json(schedule);
|
||||
});
|
||||
// ── GET ONE ───────────────────────────────────────────────────────────────
|
||||
router.get('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const sched = await prisma_1.prisma.scheduledMessage.findFirst({
|
||||
where: { id: getId(req), tenantId },
|
||||
include: {
|
||||
template: true,
|
||||
logs: { orderBy: { runAt: 'desc' }, take: 5 },
|
||||
},
|
||||
});
|
||||
if (!sched) {
|
||||
res.status(404).json({ error: 'Agendamento não encontrado' });
|
||||
return;
|
||||
}
|
||||
res.json(sched);
|
||||
});
|
||||
// ── UPDATE ────────────────────────────────────────────────────────────────
|
||||
router.put('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = getId(req);
|
||||
const exists = await prisma_1.prisma.scheduledMessage.findFirst({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
if (!exists) {
|
||||
res.status(404).json({ error: 'Agendamento não encontrado' });
|
||||
return;
|
||||
}
|
||||
const parse = scheduleSchema.partial().safeParse(req.body);
|
||||
if (!parse.success) {
|
||||
res.status(400).json({ error: parse.error.errors });
|
||||
return;
|
||||
}
|
||||
const data = parse.data;
|
||||
const nextRunAt = calcNextRun({ ...exists, ...data });
|
||||
const { payload, recipients, scheduledAt, eventType, ...rest } = data;
|
||||
const updated = await prisma_1.prisma.scheduledMessage.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
...(payload !== undefined ? { payload: payload } : {}),
|
||||
...(recipients !== undefined ? { recipients: recipients } : {}),
|
||||
...(scheduledAt !== undefined ? { scheduledAt: new Date(scheduledAt) } : {}),
|
||||
...(eventType !== undefined ? { eventType } : {}),
|
||||
status: 'ACTIVE',
|
||||
nextRunAt,
|
||||
},
|
||||
});
|
||||
res.json(updated);
|
||||
});
|
||||
// ── DELETE ────────────────────────────────────────────────────────────────
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = getId(req);
|
||||
const exists = await prisma_1.prisma.scheduledMessage.findFirst({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
if (!exists) {
|
||||
res.status(404).json({ error: 'Agendamento não encontrado' });
|
||||
return;
|
||||
}
|
||||
await prisma_1.prisma.scheduledMessage.delete({ where: { id } });
|
||||
res.status(204).send();
|
||||
});
|
||||
// ── PAUSE ─────────────────────────────────────────────────────────────────
|
||||
router.post('/:id/pause', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = getId(req);
|
||||
const exists = await prisma_1.prisma.scheduledMessage.findFirst({ where: { id, tenantId } });
|
||||
if (!exists) {
|
||||
res.status(404).json({ error: 'Agendamento não encontrado' });
|
||||
return;
|
||||
}
|
||||
await prisma_1.prisma.scheduledMessage.update({ where: { id }, data: { status: 'PAUSED' } });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
// ── RESUME ────────────────────────────────────────────────────────────────
|
||||
router.post('/:id/resume', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = getId(req);
|
||||
const exists = await prisma_1.prisma.scheduledMessage.findFirst({ where: { id, tenantId } });
|
||||
if (!exists) {
|
||||
res.status(404).json({ error: 'Agendamento não encontrado' });
|
||||
return;
|
||||
}
|
||||
await prisma_1.prisma.scheduledMessage.update({ where: { id }, data: { status: 'ACTIVE' } });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
// ── RUN NOW ───────────────────────────────────────────────────────────────
|
||||
router.post('/:id/run-now', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = getId(req);
|
||||
const sched = await prisma_1.prisma.scheduledMessage.findFirst({ where: { id, tenantId } });
|
||||
if (!sched) {
|
||||
res.status(404).json({ error: 'Agendamento não encontrado' });
|
||||
return;
|
||||
}
|
||||
// Marca nextRunAt no passado — o tick do scheduler pega no próximo minuto
|
||||
await prisma_1.prisma.scheduledMessage.update({
|
||||
where: { id },
|
||||
data: { nextRunAt: new Date(Date.now() - 1000), status: 'ACTIVE' },
|
||||
});
|
||||
res.json({ ok: true, message: 'Execução agendada para o próximo tick' });
|
||||
});
|
||||
// ── LOGS ──────────────────────────────────────────────────────────────────
|
||||
router.get('/:id/logs', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = getId(req);
|
||||
const exists = await prisma_1.prisma.scheduledMessage.findFirst({ where: { id, tenantId } });
|
||||
if (!exists) {
|
||||
res.status(404).json({ error: 'Agendamento não encontrado' });
|
||||
return;
|
||||
}
|
||||
const logs = await prisma_1.prisma.scheduledMessageLog.findMany({
|
||||
where: { scheduleId: id },
|
||||
orderBy: { runAt: 'desc' },
|
||||
take: 50,
|
||||
});
|
||||
res.json(logs);
|
||||
});
|
||||
return router;
|
||||
}
|
||||
//# sourceMappingURL=scheduled.routes.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
+206
@@ -0,0 +1,206 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.SchedulerService = void 0;
|
||||
/**
|
||||
* SchedulerService — executa agendamentos de mensagens ricos.
|
||||
*
|
||||
* Tipos suportados:
|
||||
* ONCE — executa uma vez em scheduledAt
|
||||
* RECURRING — cron expression (cronExpr)
|
||||
* EVENT — dispara por evento (BIRTHDAY, ANNIVERSARY, SIGNUP_DAYS)
|
||||
* verificado diariamente às 08:00 contra dados dos contatos
|
||||
*
|
||||
* O serviço verifica a cada minuto agendamentos ONCE/RECURRING vencidos.
|
||||
* Eventos são checados uma vez por dia via job separado.
|
||||
*
|
||||
* Para enviar, chama o socket Baileys diretamente via WhatsAppConnectionManager
|
||||
* (sem passar pelo HTTP — evita latência e auth overhead).
|
||||
*/
|
||||
const node_cron_1 = require("node-cron");
|
||||
const croner_1 = require("croner");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const logger_1 = require("../../config/logger");
|
||||
class SchedulerService {
|
||||
constructor(manager) {
|
||||
this.minuteJob = null;
|
||||
this.eventJob = null;
|
||||
this.manager = manager;
|
||||
}
|
||||
start() {
|
||||
// Tick a cada minuto — processa ONCE e RECURRING vencidos
|
||||
this.minuteJob = (0, node_cron_1.schedule)('* * * * *', () => {
|
||||
this.processTimeBased().catch((err) => logger_1.logger.error({ err }, '[Scheduler] Erro no tick de minuto'));
|
||||
});
|
||||
// Tick diário às 08:00 — processa agendamentos por EVENTO
|
||||
this.eventJob = (0, node_cron_1.schedule)('0 8 * * *', () => {
|
||||
this.processEventBased().catch((err) => logger_1.logger.error({ err }, '[Scheduler] Erro no tick de evento'));
|
||||
});
|
||||
logger_1.logger.info('[Scheduler] Iniciado (minuto + evento diário)');
|
||||
}
|
||||
stop() {
|
||||
this.minuteJob?.stop();
|
||||
this.eventJob?.stop();
|
||||
}
|
||||
// ── Processa ONCE e RECURRING ──────────────────────────────────────────────
|
||||
async processTimeBased() {
|
||||
const now = new Date();
|
||||
const schedules = await prisma_1.prisma.scheduledMessage.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
scheduleType: { in: ['ONCE', 'RECURRING'] },
|
||||
OR: [
|
||||
{ nextRunAt: { lte: now } },
|
||||
{ nextRunAt: null, scheduledAt: { lte: now }, scheduleType: 'ONCE' },
|
||||
],
|
||||
},
|
||||
});
|
||||
for (const sched of schedules) {
|
||||
await this.executeSchedule(sched);
|
||||
}
|
||||
}
|
||||
// ── Processa EVENT (birthday, anniversary, signup) ─────────────────────────
|
||||
async processEventBased() {
|
||||
const today = new Date();
|
||||
const mm = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(today.getDate()).padStart(2, '0');
|
||||
const todayMmDd = `${mm}-${dd}`;
|
||||
const schedules = await prisma_1.prisma.scheduledMessage.findMany({
|
||||
where: { status: 'ACTIVE', scheduleType: 'EVENT' },
|
||||
});
|
||||
for (const sched of schedules) {
|
||||
const recipients = sched.recipients;
|
||||
const matched = [];
|
||||
for (const r of recipients) {
|
||||
if (!r.jid)
|
||||
continue;
|
||||
if (sched.eventType === 'BIRTHDAY' && r.birthday) {
|
||||
// birthday format: "YYYY-MM-DD" or "MM-DD"
|
||||
const parts = r.birthday.split('-');
|
||||
const bMmDd = parts.length >= 3 ? `${parts[1]}-${parts[2]}` : r.birthday;
|
||||
if (bMmDd === todayMmDd)
|
||||
matched.push(r);
|
||||
}
|
||||
else if (sched.eventType === 'ANNIVERSARY' && r.anniversary) {
|
||||
const parts = r.anniversary.split('-');
|
||||
const aMmDd = parts.length >= 3 ? `${parts[1]}-${parts[2]}` : r.anniversary;
|
||||
if (aMmDd === todayMmDd)
|
||||
matched.push(r);
|
||||
}
|
||||
else if (sched.eventType === 'SIGNUP_DAYS' && r.signupDate) {
|
||||
// Dispara N dias após o signup — N fica em cronExpr como "30"
|
||||
const daysAfter = parseInt(sched.cronExpr ?? '0', 10);
|
||||
const signup = new Date(r.signupDate);
|
||||
const diffDays = Math.floor((today.getTime() - signup.getTime()) / 86400000);
|
||||
if (diffDays === daysAfter)
|
||||
matched.push(r);
|
||||
}
|
||||
}
|
||||
if (matched.length > 0) {
|
||||
await this.executeSchedule(sched, matched);
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── Executa um agendamento ─────────────────────────────────────────────────
|
||||
async executeSchedule(sched, overrideRecipients) {
|
||||
const recipients = overrideRecipients ?? sched.recipients;
|
||||
const sock = this.manager.getSocket(sched.instanceId);
|
||||
if (!sock) {
|
||||
logger_1.logger.warn({ scheduleId: sched.id }, '[Scheduler] Socket não disponível — adiando');
|
||||
return;
|
||||
}
|
||||
let sentCount = 0;
|
||||
let failedCount = 0;
|
||||
const errors = [];
|
||||
for (const recipient of recipients) {
|
||||
if (!recipient.jid)
|
||||
continue;
|
||||
try {
|
||||
const payload = this.buildPayload(sched.payload, recipient);
|
||||
await sock.sendMessage(recipient.jid, payload);
|
||||
sentCount++;
|
||||
// Pequeno delay anti-ban
|
||||
await new Promise((r) => setTimeout(r, 800 + Math.random() * 400));
|
||||
}
|
||||
catch (err) {
|
||||
failedCount++;
|
||||
errors.push(`${recipient.jid}: ${err?.message ?? err}`);
|
||||
logger_1.logger.warn({ err, jid: recipient.jid, scheduleId: sched.id }, '[Scheduler] Falha ao enviar');
|
||||
}
|
||||
}
|
||||
// Persiste log
|
||||
await prisma_1.prisma.scheduledMessageLog.create({
|
||||
data: {
|
||||
scheduleId: sched.id,
|
||||
status: failedCount === 0 ? 'SUCCESS' : sentCount === 0 ? 'FAILED' : 'PARTIAL',
|
||||
sentCount,
|
||||
failedCount,
|
||||
error: errors.length ? errors.join('\n') : null,
|
||||
},
|
||||
});
|
||||
// Calcula próximo nextRunAt ou marca como DONE
|
||||
if (sched.scheduleType === 'ONCE') {
|
||||
await prisma_1.prisma.scheduledMessage.update({
|
||||
where: { id: sched.id },
|
||||
data: {
|
||||
status: 'DONE',
|
||||
lastRunAt: new Date(),
|
||||
runCount: { increment: 1 },
|
||||
nextRunAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
else if (sched.scheduleType === 'RECURRING' && sched.cronExpr) {
|
||||
const next = this.nextCronDate(sched.cronExpr);
|
||||
await prisma_1.prisma.scheduledMessage.update({
|
||||
where: { id: sched.id },
|
||||
data: {
|
||||
lastRunAt: new Date(),
|
||||
nextRunAt: next,
|
||||
runCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
}
|
||||
else {
|
||||
// EVENT — apenas registra execução
|
||||
await prisma_1.prisma.scheduledMessage.update({
|
||||
where: { id: sched.id },
|
||||
data: { lastRunAt: new Date(), runCount: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
logger_1.logger.info({ scheduleId: sched.id, sentCount, failedCount }, '[Scheduler] Agendamento executado');
|
||||
}
|
||||
// ── Monta payload Baileys com substituição de variáveis ────────────────────
|
||||
// Variáveis: {{name}}, {{birthday}}, etc.
|
||||
buildPayload(raw, recipient) {
|
||||
const replace = (str) => str
|
||||
.replace(/\{\{name\}\}/gi, recipient.name ?? '')
|
||||
.replace(/\{\{birthday\}\}/gi, recipient.birthday ?? '')
|
||||
.replace(/\{\{anniversary\}\}/gi, recipient.anniversary ?? '');
|
||||
const recurse = (node) => {
|
||||
if (typeof node === 'string')
|
||||
return replace(node);
|
||||
if (Array.isArray(node))
|
||||
return node.map(recurse);
|
||||
if (node && typeof node === 'object') {
|
||||
const out = {};
|
||||
for (const k of Object.keys(node))
|
||||
out[k] = recurse(node[k]);
|
||||
return out;
|
||||
}
|
||||
return node;
|
||||
};
|
||||
return recurse(raw);
|
||||
}
|
||||
// ── Calcula próxima data de um cron ───────────────────────────────────────
|
||||
nextCronDate(expr) {
|
||||
try {
|
||||
const job = new croner_1.Cron(expr, { paused: true });
|
||||
return job.nextRun() ?? null;
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.SchedulerService = SchedulerService;
|
||||
//# sourceMappingURL=scheduler.service.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,151 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildSectorRoutes = buildSectorRoutes;
|
||||
const express_1 = require("express");
|
||||
const zod_1 = require("zod");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
function buildSectorRoutes() {
|
||||
const router = (0, express_1.Router)();
|
||||
// GET /api/sectors
|
||||
router.get('/', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const sectors = await prisma_1.prisma.sector.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { name: 'asc' },
|
||||
include: { _count: { select: { teams: true, protocols: true } } },
|
||||
});
|
||||
res.json(sectors);
|
||||
});
|
||||
// POST /api/sectors
|
||||
router.post('/', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const schema = zod_1.z.object({ name: zod_1.z.string().min(1).max(100) });
|
||||
let body;
|
||||
try {
|
||||
body = schema.parse(req.body);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(400).json({ error: err.errors ?? err.message });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const sector = await prisma_1.prisma.sector.create({ data: { tenantId, name: body.name } });
|
||||
res.status(201).json(sector);
|
||||
}
|
||||
catch {
|
||||
res.status(409).json({ error: 'Setor com este nome já existe.' });
|
||||
}
|
||||
});
|
||||
// PATCH /api/sectors/:id
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = req.params['id'];
|
||||
const schema = zod_1.z.object({
|
||||
name: zod_1.z.string().min(1).max(100).optional(),
|
||||
isActive: zod_1.z.boolean().optional(),
|
||||
});
|
||||
let body;
|
||||
try {
|
||||
body = schema.parse(req.body);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(400).json({ error: err.errors ?? err.message });
|
||||
return;
|
||||
}
|
||||
const existing = await prisma_1.prisma.sector.findFirst({ where: { id, tenantId } });
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Setor não encontrado' });
|
||||
return;
|
||||
}
|
||||
const updated = await prisma_1.prisma.sector.update({ where: { id }, data: body });
|
||||
res.json(updated);
|
||||
});
|
||||
// DELETE /api/sectors/:id
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = req.params['id'];
|
||||
const existing = await prisma_1.prisma.sector.findFirst({ where: { id, tenantId } });
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Setor não encontrado' });
|
||||
return;
|
||||
}
|
||||
await prisma_1.prisma.team.deleteMany({ where: { sectorId: id } });
|
||||
await prisma_1.prisma.sector.delete({ where: { id } });
|
||||
res.status(204).send();
|
||||
});
|
||||
// GET /api/sectors/:id/teams
|
||||
router.get('/:id/teams', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const sectorId = req.params['id'];
|
||||
const teams = await prisma_1.prisma.team.findMany({
|
||||
where: { sectorId, tenantId },
|
||||
orderBy: { name: 'asc' },
|
||||
include: { _count: { select: { protocols: true } } },
|
||||
});
|
||||
res.json(teams);
|
||||
});
|
||||
// POST /api/sectors/:id/teams
|
||||
router.post('/:id/teams', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const sectorId = req.params['id'];
|
||||
const schema = zod_1.z.object({ name: zod_1.z.string().min(1).max(100) });
|
||||
let body;
|
||||
try {
|
||||
body = schema.parse(req.body);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(400).json({ error: err.errors ?? err.message });
|
||||
return;
|
||||
}
|
||||
const sector = await prisma_1.prisma.sector.findFirst({ where: { id: sectorId, tenantId } });
|
||||
if (!sector) {
|
||||
res.status(404).json({ error: 'Setor não encontrado' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const team = await prisma_1.prisma.team.create({ data: { tenantId, sectorId, name: body.name } });
|
||||
res.status(201).json(team);
|
||||
}
|
||||
catch {
|
||||
res.status(409).json({ error: 'Equipe com este nome já existe neste setor.' });
|
||||
}
|
||||
});
|
||||
// PATCH /api/sectors/:sectorId/teams/:teamId
|
||||
router.patch('/:sectorId/teams/:teamId', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const teamId = req.params['teamId'];
|
||||
const schema = zod_1.z.object({
|
||||
name: zod_1.z.string().min(1).max(100).optional(),
|
||||
isActive: zod_1.z.boolean().optional(),
|
||||
});
|
||||
let body;
|
||||
try {
|
||||
body = schema.parse(req.body);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(400).json({ error: err.errors ?? err.message });
|
||||
return;
|
||||
}
|
||||
const existing = await prisma_1.prisma.team.findFirst({ where: { id: teamId, tenantId } });
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Equipe não encontrada' });
|
||||
return;
|
||||
}
|
||||
const updated = await prisma_1.prisma.team.update({ where: { id: teamId }, data: body });
|
||||
res.json(updated);
|
||||
});
|
||||
// DELETE /api/sectors/:sectorId/teams/:teamId
|
||||
router.delete('/:sectorId/teams/:teamId', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const teamId = req.params['teamId'];
|
||||
const existing = await prisma_1.prisma.team.findFirst({ where: { id: teamId, tenantId } });
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Equipe não encontrada' });
|
||||
return;
|
||||
}
|
||||
await prisma_1.prisma.team.delete({ where: { id: teamId } });
|
||||
res.status(204).send();
|
||||
});
|
||||
return router;
|
||||
}
|
||||
//# sourceMappingURL=sector.routes.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,92 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildStickerRoutes = buildStickerRoutes;
|
||||
/**
|
||||
* Rotas de Figurinhas (Stickers)
|
||||
*
|
||||
* GET /api/stickers — Lista stickers do tenant (com listVersion)
|
||||
* GET /api/stickers/recent — Últimos 30 stickers usados
|
||||
* PATCH /api/stickers/:id/favorite — Toggle isFavorite
|
||||
*/
|
||||
const express_1 = require("express");
|
||||
const crypto_1 = require("crypto");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
function buildStickerRoutes() {
|
||||
const router = (0, express_1.Router)();
|
||||
// ── LIST (com listVersion para cache invalidation) ────────────────────────
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const stickers = await prisma_1.prisma.sticker.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: [{ isFavorite: 'desc' }, { lastSeenAt: 'desc' }],
|
||||
select: {
|
||||
id: true,
|
||||
sha256: true,
|
||||
wasabiPath: true,
|
||||
isAnimated: true,
|
||||
isFavorite: true,
|
||||
useCount: true,
|
||||
lastSeenAt: true,
|
||||
},
|
||||
});
|
||||
const versionSource = stickers.length === 0
|
||||
? 'empty'
|
||||
: `${stickers.length}:${stickers[0].sha256}:${stickers[0].lastSeenAt.getTime()}:${stickers.filter(s => s.isFavorite).length}`;
|
||||
const listVersion = (0, crypto_1.createHash)('md5').update(versionSource).digest('hex').slice(0, 8);
|
||||
res.json({ listVersion, stickers });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar stickers' });
|
||||
}
|
||||
});
|
||||
// ── RECENT (últimos 30) ───────────────────────────────────────────────────
|
||||
router.get('/recent', async (req, res) => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const stickers = await prisma_1.prisma.sticker.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { lastSeenAt: 'desc' },
|
||||
take: 30,
|
||||
select: {
|
||||
id: true,
|
||||
sha256: true,
|
||||
wasabiPath: true,
|
||||
isAnimated: true,
|
||||
isFavorite: true,
|
||||
},
|
||||
});
|
||||
res.json({ stickers });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar stickers recentes' });
|
||||
}
|
||||
});
|
||||
// ── TOGGLE FAVORITE ───────────────────────────────────────────────────────
|
||||
router.patch('/:id/favorite', async (req, res) => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const idParam = req.params['id'];
|
||||
const id = Array.isArray(idParam) ? idParam[0] : idParam;
|
||||
const sticker = await prisma_1.prisma.sticker.findFirst({
|
||||
where: { id, tenantId },
|
||||
select: { id: true, isFavorite: true },
|
||||
});
|
||||
if (!sticker) {
|
||||
res.status(404).json({ error: 'Sticker não encontrado' });
|
||||
return;
|
||||
}
|
||||
const updated = await prisma_1.prisma.sticker.update({
|
||||
where: { id: sticker.id },
|
||||
data: { isFavorite: !sticker.isFavorite },
|
||||
select: { id: true, isFavorite: true },
|
||||
});
|
||||
res.json(updated);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao atualizar favorito' });
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
//# sourceMappingURL=sticker.routes.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"sticker.routes.js","sourceRoot":"","sources":["sticker.routes.ts"],"names":[],"mappings":"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"}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildTemplateRoutes = buildTemplateRoutes;
|
||||
/**
|
||||
* Rotas de Templates de Mensagem
|
||||
*
|
||||
* GET /api/templates — Lista templates do tenant
|
||||
* POST /api/templates — Cria template
|
||||
* GET /api/templates/:id — Detalhe do template
|
||||
* PUT /api/templates/:id — Atualiza template
|
||||
* DELETE /api/templates/:id — Remove template
|
||||
* POST /api/templates/:id/use — Incrementa usageCount (IA chama ao usar)
|
||||
*/
|
||||
const express_1 = require("express");
|
||||
const zod_1 = require("zod");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const templateTypeValues = ['TEXT', 'BUTTONS', 'INTERACTIVE', 'LIST', 'POLL', 'CAROUSEL'];
|
||||
const templateSchema = zod_1.z.object({
|
||||
name: zod_1.z.string().min(1).max(100),
|
||||
description: zod_1.z.string().max(500).optional(),
|
||||
type: zod_1.z.enum(templateTypeValues),
|
||||
payload: zod_1.z.record(zod_1.z.unknown()),
|
||||
tags: zod_1.z.array(zod_1.z.string()).default([]),
|
||||
});
|
||||
function getId(req) {
|
||||
const id = req.params['id'];
|
||||
return Array.isArray(id) ? id[0] : id;
|
||||
}
|
||||
function buildTemplateRoutes() {
|
||||
const router = (0, express_1.Router)();
|
||||
// ── LIST ──────────────────────────────────────────────────────────────────
|
||||
router.get('/', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const type = Array.isArray(req.query['type']) ? req.query['type'][0] : req.query['type'];
|
||||
const search = Array.isArray(req.query['search']) ? req.query['search'][0] : req.query['search'];
|
||||
const templates = await prisma_1.prisma.messageTemplate.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
...(type ? { type: type } : {}),
|
||||
...(search ? { name: { contains: search, mode: 'insensitive' } } : {}),
|
||||
},
|
||||
orderBy: [{ usageCount: 'desc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
res.json(templates);
|
||||
});
|
||||
// ── CREATE ────────────────────────────────────────────────────────────────
|
||||
router.post('/', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const parse = templateSchema.safeParse(req.body);
|
||||
if (!parse.success) {
|
||||
res.status(400).json({ error: parse.error.errors });
|
||||
return;
|
||||
}
|
||||
const { name, description, type, payload, tags } = parse.data;
|
||||
const template = await prisma_1.prisma.messageTemplate.create({
|
||||
data: { tenantId, name, description, type, payload: payload, tags },
|
||||
});
|
||||
res.status(201).json(template);
|
||||
});
|
||||
// ── GET ONE ───────────────────────────────────────────────────────────────
|
||||
router.get('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const template = await prisma_1.prisma.messageTemplate.findFirst({
|
||||
where: { id: getId(req), tenantId },
|
||||
});
|
||||
if (!template) {
|
||||
res.status(404).json({ error: 'Template não encontrado' });
|
||||
return;
|
||||
}
|
||||
res.json(template);
|
||||
});
|
||||
// ── UPDATE ────────────────────────────────────────────────────────────────
|
||||
router.put('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = getId(req);
|
||||
const exists = await prisma_1.prisma.messageTemplate.findFirst({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
if (!exists) {
|
||||
res.status(404).json({ error: 'Template não encontrado' });
|
||||
return;
|
||||
}
|
||||
const parse = templateSchema.partial().safeParse(req.body);
|
||||
if (!parse.success) {
|
||||
res.status(400).json({ error: parse.error.errors });
|
||||
return;
|
||||
}
|
||||
const { payload, ...rest } = parse.data;
|
||||
const updated = await prisma_1.prisma.messageTemplate.update({
|
||||
where: { id },
|
||||
data: { ...rest, ...(payload !== undefined ? { payload: payload } : {}) },
|
||||
});
|
||||
res.json(updated);
|
||||
});
|
||||
// ── DELETE ────────────────────────────────────────────────────────────────
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = getId(req);
|
||||
const exists = await prisma_1.prisma.messageTemplate.findFirst({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
if (!exists) {
|
||||
res.status(404).json({ error: 'Template não encontrado' });
|
||||
return;
|
||||
}
|
||||
await prisma_1.prisma.messageTemplate.delete({ where: { id } });
|
||||
res.status(204).send();
|
||||
});
|
||||
// ── USE (IA incrementa uso) ────────────────────────────────────────────────
|
||||
router.post('/:id/use', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = getId(req);
|
||||
const exists = await prisma_1.prisma.messageTemplate.findFirst({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
if (!exists) {
|
||||
res.status(404).json({ error: 'Template não encontrado' });
|
||||
return;
|
||||
}
|
||||
await prisma_1.prisma.messageTemplate.update({
|
||||
where: { id },
|
||||
data: { usageCount: { increment: 1 } },
|
||||
});
|
||||
res.json({ ok: true });
|
||||
});
|
||||
return router;
|
||||
}
|
||||
//# sourceMappingURL=template.routes.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
+28
@@ -0,0 +1,28 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.InstanceRepository = void 0;
|
||||
const prisma_1 = require("../../../infra/database/prisma");
|
||||
class InstanceRepository {
|
||||
async findAllByTenant(tenantId) {
|
||||
return prisma_1.prisma.instance.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
async findById(id) {
|
||||
return prisma_1.prisma.instance.findUnique({ where: { id } });
|
||||
}
|
||||
async create(tenantId, name) {
|
||||
return prisma_1.prisma.instance.create({
|
||||
data: { tenantId, name },
|
||||
});
|
||||
}
|
||||
async updateStatus(id, status) {
|
||||
return prisma_1.prisma.instance.update({ where: { id }, data: { status } });
|
||||
}
|
||||
async delete(id) {
|
||||
return prisma_1.prisma.instance.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
exports.InstanceRepository = InstanceRepository;
|
||||
//# sourceMappingURL=InstanceRepository.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"InstanceRepository.js","sourceRoot":"","sources":["InstanceRepository.ts"],"names":[],"mappings":";;;AAAA,2DAAuD;AAGvD,MAAa,kBAAkB;IAC7B,KAAK,CAAC,eAAe,CAAC,QAAgB;QACpC,OAAO,eAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC;YAC9B,KAAK,EAAE,EAAE,QAAQ,EAAE;YACnB,OAAO,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE;SAC9B,CAAC,CAAA;IACJ,CAAC;IAED,KAAK,CAAC,QAAQ,CAAC,EAAU;QACvB,OAAO,eAAM,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,CAAA;IACtD,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,QAAgB,EAAE,IAAY;QACzC,OAAO,eAAM,CAAC,QAAQ,CAAC,MAAM,CAAC;YAC5B,IAAI,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE;SACzB,CAAC,CAAA;IACJ,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,EAAU,EAAE,MAAsB;QACnD,OAAO,eAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,EAAE,CAAC,CAAA;IACpE,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,EAAU;QACrB,OAAO,eAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,CAAA;IAClD,CAAC;CACF;AAzBD,gDAyBC"}
|
||||
@@ -0,0 +1,309 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.sendRichMessage = sendRichMessage;
|
||||
/**
|
||||
* rich-message.ts — Construção e envio de mensagens ricas via Baileys padrão
|
||||
*
|
||||
* O pacote @whiskeysockets/baileys não suporta os campos `nativeButtons`,
|
||||
* `nativeList` e `nativeCarousel` que são específicos do fork InfiniteAPI.
|
||||
* Este módulo replica a lógica de transformação do InfiniteAPI para que
|
||||
* o motor newwhats possa enviar botões, listas e carrosséis usando o
|
||||
* sock.relayMessage() com o proto correto.
|
||||
*
|
||||
* Referência: github:rsalcara/InfiniteAPI — lib/Socket/messages-send.js
|
||||
*
|
||||
* IMPORTANTE: O WhatsApp Web NÃO suporta viewOnceMessage para mensagens interativas.
|
||||
* Por isso usamos interactiveMessage DIRETO (sem wrapper viewOnce) + additionalNodes.
|
||||
*
|
||||
* O que é necessário para botões/listas/carrosséis funcionarem no WhatsApp
|
||||
* (celular E web):
|
||||
*
|
||||
* 1. interactiveMessage direto (NÃO viewOnceMessage) — viewOnce não carrega no Web
|
||||
*
|
||||
* 2. Nó adicional <biz><interactive type="native_flow" v="1"><native_flow v="9" name="mixed"/></interactive></biz>
|
||||
* injetado no stanza via relayMessage(additionalNodes)
|
||||
* (sem isso o WhatsApp Web exibe "Não foi possível carregar")
|
||||
*
|
||||
* 3. Nó <bot biz_bot="1"/> para chats 1-a-1
|
||||
* (necessário para interactive messages em conversas privadas)
|
||||
*
|
||||
* Lista (single_select) também usa o mesmo fluxo — InfiniteAPI converte
|
||||
* o viewOnceMessage > nativeFlowMessage de volta para listMessage no relayMessage,
|
||||
* mas testamos que a forma direta com nó biz > list também funciona.
|
||||
*/
|
||||
const engine_1 = require("./engine");
|
||||
// ─── Converte um botão nativo para o formato buttonParamsJson do WhatsApp ─────
|
||||
function formatNativeFlowButton(btn) {
|
||||
switch (btn.type) {
|
||||
case 'url':
|
||||
return {
|
||||
name: 'cta_url',
|
||||
buttonParamsJson: JSON.stringify({
|
||||
display_text: btn.text,
|
||||
url: btn.url,
|
||||
merchant_url: btn.url,
|
||||
}),
|
||||
};
|
||||
case 'copy':
|
||||
return {
|
||||
name: 'cta_copy',
|
||||
buttonParamsJson: JSON.stringify({
|
||||
display_text: btn.text,
|
||||
copy_code: btn.copyText,
|
||||
}),
|
||||
};
|
||||
case 'reply':
|
||||
return {
|
||||
name: 'quick_reply',
|
||||
buttonParamsJson: JSON.stringify({
|
||||
display_text: btn.text,
|
||||
id: btn.id,
|
||||
}),
|
||||
};
|
||||
case 'call':
|
||||
return {
|
||||
name: 'cta_call',
|
||||
buttonParamsJson: JSON.stringify({
|
||||
display_text: btn.text,
|
||||
phone_number: btn.phoneNumber,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
// ─── Nó biz > interactive > native_flow (obrigatório para renderizar no Web) ──
|
||||
const BIZ_NATIVE_FLOW_NODE = {
|
||||
tag: 'biz',
|
||||
attrs: {},
|
||||
content: [
|
||||
{
|
||||
tag: 'interactive',
|
||||
attrs: { type: 'native_flow', v: '1' },
|
||||
content: [
|
||||
{
|
||||
tag: 'native_flow',
|
||||
attrs: { v: '9', name: 'mixed' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
// ─── Nó biz para listMessage legado (formato que renderiza no Web) ───────────
|
||||
// InfiniteAPI usa <biz><list type="product_list" v="2"/></biz> com proto.listMessage
|
||||
// (não interactiveMessage). Comentário do fork: "Modern format causes rejection
|
||||
// (error 479) — Legacy format works on all platforms".
|
||||
const BIZ_LIST_NODE = {
|
||||
tag: 'biz',
|
||||
attrs: {},
|
||||
content: [
|
||||
{
|
||||
tag: 'list',
|
||||
attrs: { type: 'product_list', v: '2' },
|
||||
},
|
||||
],
|
||||
};
|
||||
// ─── Nó bot (necessário para chats 1-a-1 com interactive messages) ────────────
|
||||
const BOT_NODE = {
|
||||
tag: 'bot',
|
||||
attrs: { biz_bot: '1' },
|
||||
};
|
||||
// ─── Helper: jid é chat privado (não grupo, não status, não bot) ─────────────
|
||||
function isPrivateChat(jid) {
|
||||
return (!(0, engine_1.isJidGroup)(jid) &&
|
||||
!(0, engine_1.isJidStatusBroadcast)(jid) &&
|
||||
!jid.includes('newsletter') &&
|
||||
!jid.includes('broadcast'));
|
||||
}
|
||||
// ─── Helper: envia proto pré-construído pelo MESMO caminho do sendMessage ────
|
||||
// Diferença crítica vs `sock.relayMessage` direto:
|
||||
// sendMessage() → generateWAMessage() → WAProto.Message.fromObject() → relayMessage()
|
||||
// ↑ normalização proto que o WA Web exige
|
||||
// Sem esse passo, o Web descarta a mensagem ("Não foi possível carregar").
|
||||
// É o mesmo motivo pelo qual nossas enquetes (via sendMessage) funcionam no Web
|
||||
// e os botões (via relayMessage direto) não funcionavam.
|
||||
async function sendBuiltProto(sock, jid, protoMsg, msgId, additionalNodes, quoted) {
|
||||
const fullMsg = (0, engine_1.generateWAMessageFromContent)(jid, protoMsg, {
|
||||
userJid: sock.user?.id,
|
||||
messageId: msgId,
|
||||
quoted,
|
||||
});
|
||||
await sock.relayMessage(jid, fullMsg.message, {
|
||||
messageId: fullMsg.key.id,
|
||||
additionalNodes,
|
||||
});
|
||||
return fullMsg.key.id;
|
||||
}
|
||||
// ─── Helper: monta interactiveMessage DIRETO (sem viewOnceMessage wrapper) ────
|
||||
// viewOnceMessage wrapper faz o WhatsApp Web exibir "Não foi possível carregar":
|
||||
// o Web trata viewOnce como mensagem efêmera e recusa conteúdo interativo dentro.
|
||||
// Baileys padrão não adiciona messageContextInfo para interactiveMessage
|
||||
// (só faz isso para poll, com messageSecret). Usar interactiveMessage direto
|
||||
// + nó <biz> no additionalNodes é o caminho correto com @whiskeysockets/baileys.
|
||||
function makeInteractiveProto(interactiveMsg) {
|
||||
return {
|
||||
interactiveMessage: interactiveMsg,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Envia uma mensagem rica pelo socket Baileys padrão.
|
||||
*
|
||||
* - Buttons / CTAs: interactiveMessage direto + nó biz>interactive>native_flow + nó bot
|
||||
* - Carousel: interactiveMessage direto + nó biz>interactive>native_flow
|
||||
* - List: interactiveMessage direto + nó biz>interactive>native_flow + nó bot
|
||||
* - Poll: sock.sendMessage (requer encriptação especial do Baileys)
|
||||
* - Text: sock.sendMessage normal
|
||||
*
|
||||
* Retorna o messageId gerado.
|
||||
*/
|
||||
async function sendRichMessage(sock, jid, payload, quoted, engineType) {
|
||||
const { text, footer, nativeButtons, nativeList, nativeCarousel, poll } = payload;
|
||||
const engine = engineType ?? process.env.BAILEYS_ENGINE ?? 'infinite';
|
||||
// Se a engine ativa for 'infinite', utilizamos o suporte nativo do fork InfiniteAPI
|
||||
if (engine === 'infinite') {
|
||||
// ── Poll: delegado ao sendMessage padrão (requer encriptação especial do Baileys) ──
|
||||
if (poll) {
|
||||
const sent = await sock.sendMessage(jid, {
|
||||
poll: {
|
||||
name: poll.name,
|
||||
values: poll.values,
|
||||
selectableCount: Math.min(Math.max(1, poll.selectableCount ?? 1), poll.values.length),
|
||||
},
|
||||
}, quoted ? { quoted } : undefined);
|
||||
return sent?.key?.id ?? `poll-${Date.now()}`;
|
||||
}
|
||||
// ── Botões / CTAs nativos (InfiniteAPI) ──
|
||||
if (nativeButtons) {
|
||||
const sent = await sock.sendMessage(jid, {
|
||||
text: text ?? '',
|
||||
footer,
|
||||
nativeButtons,
|
||||
}, quoted ? { quoted } : undefined);
|
||||
return sent?.key?.id ?? `btn-${Date.now()}`;
|
||||
}
|
||||
// ── Carrossel nativo (InfiniteAPI) ──
|
||||
if (nativeCarousel) {
|
||||
const sent = await sock.sendMessage(jid, {
|
||||
text: text ?? '',
|
||||
footer,
|
||||
nativeCarousel,
|
||||
}, quoted ? { quoted } : undefined);
|
||||
return sent?.key?.id ?? `carousel-${Date.now()}`;
|
||||
}
|
||||
// ── Lista nativa (InfiniteAPI) ──
|
||||
if (nativeList) {
|
||||
const sent = await sock.sendMessage(jid, {
|
||||
text: text ?? '',
|
||||
footer,
|
||||
nativeList,
|
||||
}, quoted ? { quoted } : undefined);
|
||||
return sent?.key?.id ?? `list-${Date.now()}`;
|
||||
}
|
||||
// ── Texto simples (InfiniteAPI) ──
|
||||
const sent = await sock.sendMessage(jid, { text: text ?? '' }, quoted ? { quoted } : undefined);
|
||||
return sent?.key?.id ?? `text-${Date.now()}`;
|
||||
}
|
||||
// FALLBACK: Lógica de montagem manual de nós binários para o Baileys oficial
|
||||
// ── Poll: delegado ao sendMessage padrão (requer messageSecret) ───────────
|
||||
if (poll) {
|
||||
const sent = await sock.sendMessage(jid, {
|
||||
poll: {
|
||||
name: poll.name,
|
||||
values: poll.values,
|
||||
selectableCount: Math.min(Math.max(1, poll.selectableCount ?? 1), poll.values.length),
|
||||
},
|
||||
}, quoted ? { quoted } : undefined);
|
||||
return sent?.key?.id ?? `poll-${Date.now()}`;
|
||||
}
|
||||
// ── Texto simples: delegado ao sendMessage padrão ─────────────────────────
|
||||
if (!nativeButtons && !nativeList && !nativeCarousel) {
|
||||
const sent = await sock.sendMessage(jid, { text: text ?? '' }, quoted ? { quoted } : undefined);
|
||||
return sent?.key?.id ?? `text-${Date.now()}`;
|
||||
}
|
||||
const msgId = (0, engine_1.generateMessageIDV2)(sock.user?.id);
|
||||
const isPrivate = isPrivateChat(jid);
|
||||
// ── Botões / CTAs: interactiveMessage + nativeFlowMessage ────────────────────
|
||||
if (nativeButtons) {
|
||||
const formattedButtons = nativeButtons.map(formatNativeFlowButton);
|
||||
const protoMsg = makeInteractiveProto({
|
||||
body: { text: text ?? '' },
|
||||
footer: footer ? { text: footer } : undefined,
|
||||
header: { title: '', subtitle: '', hasMediaAttachment: false },
|
||||
nativeFlowMessage: {
|
||||
buttons: formattedButtons,
|
||||
messageParamsJson: JSON.stringify({}),
|
||||
messageVersion: 2,
|
||||
},
|
||||
});
|
||||
const additionalNodes = [BIZ_NATIVE_FLOW_NODE];
|
||||
return await sendBuiltProto(sock, jid, protoMsg, msgId, additionalNodes, quoted);
|
||||
}
|
||||
// ── Carrossel: viewOnceMessage > interactiveMessage > carouselMessage ─────
|
||||
if (nativeCarousel) {
|
||||
const cards = nativeCarousel.cards;
|
||||
if (cards.length < 2)
|
||||
throw new Error('Carrossel requer no mínimo 2 cards');
|
||||
if (cards.length > 10)
|
||||
throw new Error('Carrossel suporta no máximo 10 cards');
|
||||
const carouselCards = cards.map((card) => {
|
||||
const rawButtons = card.buttons ?? [];
|
||||
const cardButtons = rawButtons.length > 0
|
||||
? rawButtons.map((btn) => formatNativeFlowButton({
|
||||
type: btn.type ?? 'reply',
|
||||
id: btn.id ?? btn.text.toLowerCase().replace(/\s+/g, '_'),
|
||||
text: btn.text,
|
||||
}))
|
||||
: [{ name: 'quick_reply', buttonParamsJson: JSON.stringify({ display_text: 'Ver mais', id: 'see_more' }) }];
|
||||
return {
|
||||
header: {
|
||||
title: card.title ?? '',
|
||||
subtitle: '',
|
||||
hasMediaAttachment: !!(card.image?.url),
|
||||
},
|
||||
body: { text: card.body ?? '' },
|
||||
footer: card.footer ? { text: card.footer } : undefined,
|
||||
nativeFlowMessage: {
|
||||
buttons: cardButtons,
|
||||
messageParamsJson: JSON.stringify({}),
|
||||
},
|
||||
};
|
||||
});
|
||||
const protoMsg = makeInteractiveProto({
|
||||
body: { text: text ?? '' },
|
||||
footer: footer ? { text: footer } : undefined,
|
||||
header: { title: '', subtitle: '', hasMediaAttachment: false },
|
||||
carouselMessage: {
|
||||
cards: carouselCards,
|
||||
messageVersion: 1,
|
||||
},
|
||||
});
|
||||
const additionalNodes = [BIZ_NATIVE_FLOW_NODE];
|
||||
return await sendBuiltProto(sock, jid, protoMsg, msgId, additionalNodes, quoted);
|
||||
}
|
||||
// ── Lista: proto.listMessage LEGADO + nó <biz><list type="product_list" v="2"/></biz> ──
|
||||
if (nativeList) {
|
||||
const protoMsg = {
|
||||
listMessage: {
|
||||
title: '',
|
||||
description: text ?? '',
|
||||
buttonText: nativeList.buttonText,
|
||||
footerText: footer ?? '',
|
||||
listType: 2,
|
||||
sections: nativeList.sections.map((s) => ({
|
||||
title: s.title,
|
||||
rows: s.rows.map((r) => ({
|
||||
rowId: r.id,
|
||||
title: r.title,
|
||||
description: r.description ?? '',
|
||||
})),
|
||||
})),
|
||||
},
|
||||
};
|
||||
const additionalNodes = [BIZ_LIST_NODE];
|
||||
if (isPrivate)
|
||||
additionalNodes.push(BOT_NODE);
|
||||
return await sendBuiltProto(sock, jid, protoMsg, msgId, additionalNodes, quoted);
|
||||
}
|
||||
// Fallback (não deve chegar aqui)
|
||||
const sent = await sock.sendMessage(jid, { text: text ?? '' });
|
||||
return sent?.key?.id ?? `fallback-${Date.now()}`;
|
||||
}
|
||||
//# sourceMappingURL=rich-message.js.map
|
||||
File diff suppressed because one or more lines are too long
+107
@@ -0,0 +1,107 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.WhatsAppWarmer = void 0;
|
||||
/**
|
||||
* WhatsApp Warmer — aquecimento gradual de número novo.
|
||||
*
|
||||
* Envia mensagens em intervalos aleatórios crescentes para simular
|
||||
* uso humano e reduzir risco de ban em números recém-criados.
|
||||
*/
|
||||
const logger_1 = require("../../../config/logger");
|
||||
const MESSAGES = [
|
||||
'Olá! Tudo bem?',
|
||||
'Oi, como vai?',
|
||||
'Boa tarde!',
|
||||
'Só passando para dizer oi 😊',
|
||||
'Tudo certo por aí?',
|
||||
'Bom dia! 🌤',
|
||||
'E aí, tudo bem?',
|
||||
'Boa noite!',
|
||||
];
|
||||
function randomBetween(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
function randomMessage() {
|
||||
return MESSAGES[Math.floor(Math.random() * MESSAGES.length)];
|
||||
}
|
||||
class WhatsAppWarmer {
|
||||
constructor(manager) {
|
||||
this.manager = manager;
|
||||
this.sessions = new Map();
|
||||
}
|
||||
start(config) {
|
||||
if (this.sessions.has(config.instanceId)) {
|
||||
logger_1.logger.warn({ instanceId: config.instanceId }, 'Warmer já em execução para esta instância');
|
||||
return;
|
||||
}
|
||||
const session = {
|
||||
config,
|
||||
dayCount: 0,
|
||||
sentToday: 0,
|
||||
timer: null,
|
||||
};
|
||||
this.sessions.set(config.instanceId, session);
|
||||
logger_1.logger.info({ instanceId: config.instanceId, totalDays: config.totalDays }, '🔥 Warmer iniciado');
|
||||
this.scheduleNext(config.instanceId);
|
||||
}
|
||||
stop(instanceId) {
|
||||
const session = this.sessions.get(instanceId);
|
||||
if (!session)
|
||||
return;
|
||||
if (session.timer)
|
||||
clearTimeout(session.timer);
|
||||
this.sessions.delete(instanceId);
|
||||
logger_1.logger.info({ instanceId }, 'Warmer encerrado');
|
||||
}
|
||||
scheduleNext(instanceId) {
|
||||
const session = this.sessions.get(instanceId);
|
||||
if (!session)
|
||||
return;
|
||||
const { config, dayCount, sentToday } = session;
|
||||
// Limite diário cresce linearmente: day 1 = 10%, day N = 100%
|
||||
const progressRatio = Math.min(1, (dayCount + 1) / config.totalDays);
|
||||
const limitHoje = Math.ceil(config.dailyMessageLimit * progressRatio);
|
||||
if (sentToday >= limitHoje) {
|
||||
// Dia completo — aguarda até o próximo dia (24h + jitter)
|
||||
const nextDayMs = 24 * 60 * 60 * 1000 + randomBetween(0, 30 * 60 * 1000);
|
||||
logger_1.logger.debug({ instanceId, dayCount, sentToday }, 'Limite diário atingido — aguardando próximo dia');
|
||||
if (dayCount + 1 >= config.totalDays) {
|
||||
logger_1.logger.info({ instanceId }, '✅ Aquecimento concluído!');
|
||||
this.sessions.delete(instanceId);
|
||||
return;
|
||||
}
|
||||
session.dayCount += 1;
|
||||
session.sentToday = 0;
|
||||
session.timer = setTimeout(() => this.scheduleNext(instanceId), nextDayMs);
|
||||
return;
|
||||
}
|
||||
// Intervalo aleatório entre mensagens (1–5 minutos com jitter)
|
||||
const delayMs = randomBetween(60000, 5 * 60000);
|
||||
session.timer = setTimeout(() => this.sendWarmerMessage(instanceId), delayMs);
|
||||
}
|
||||
async sendWarmerMessage(instanceId) {
|
||||
const session = this.sessions.get(instanceId);
|
||||
if (!session)
|
||||
return;
|
||||
const sock = this.manager.getSocket(instanceId);
|
||||
if (!sock) {
|
||||
logger_1.logger.warn({ instanceId }, 'Warmer: socket não encontrado — pausando');
|
||||
session.timer = setTimeout(() => this.sendWarmerMessage(instanceId), 60000);
|
||||
return;
|
||||
}
|
||||
const targets = session.config.targets;
|
||||
const target = targets[Math.floor(Math.random() * targets.length)];
|
||||
const text = randomMessage();
|
||||
try {
|
||||
await sock.sendMessage(target, { text });
|
||||
session.sentToday += 1;
|
||||
logger_1.logger.debug({ instanceId, target, sentToday: session.sentToday }, 'Warmer: mensagem enviada');
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, instanceId, target }, 'Warmer: falha ao enviar mensagem');
|
||||
}
|
||||
this.scheduleNext(instanceId);
|
||||
}
|
||||
}
|
||||
exports.WhatsAppWarmer = WhatsAppWarmer;
|
||||
//# sourceMappingURL=WhatsAppWarmer.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"WhatsAppWarmer.js","sourceRoot":"","sources":["WhatsAppWarmer.ts"],"names":[],"mappings":"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"}
|
||||
+373
@@ -0,0 +1,373 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildMediaRoutes = buildMediaRoutes;
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const promises_1 = __importDefault(require("fs/promises"));
|
||||
const express_1 = require("express");
|
||||
const multer_1 = __importDefault(require("multer"));
|
||||
const engine_1 = require("./engine");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const logger_1 = require("../../config/logger");
|
||||
const StorageProvider_1 = require("../../core/StorageProvider");
|
||||
// ─── Tipos de mídia suportados ────────────────────────────────────────────────
|
||||
const MIME_MAP = {
|
||||
'image/jpeg': 'image',
|
||||
'image/png': 'image',
|
||||
'image/webp': 'sticker',
|
||||
'image/gif': 'video', // GIFs são enviados como video no Baileys
|
||||
'video/mp4': 'video',
|
||||
'video/3gpp': 'video',
|
||||
'video/quicktime': 'video',
|
||||
'audio/ogg': 'audio',
|
||||
'audio/mpeg': 'audio',
|
||||
'audio/mp4': 'audio',
|
||||
'audio/wav': 'audio',
|
||||
'audio/webm': 'audio',
|
||||
'application/pdf': 'document',
|
||||
'application/msword': 'document',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'document',
|
||||
'application/vnd.ms-excel': 'document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'document',
|
||||
'application/zip': 'document',
|
||||
'text/plain': 'document',
|
||||
};
|
||||
const MAX_FILE_SIZE = 64 * 1024 * 1024; // 64 MB
|
||||
// ─── Multer: armazena em memória (buffer) ─────────────────────────────────────
|
||||
// Buffers são passados diretamente ao Baileys sem gravar arquivo temporário
|
||||
const upload = (0, multer_1.default)({
|
||||
storage: multer_1.default.memoryStorage(),
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (MIME_MAP[file.mimetype]) {
|
||||
cb(null, true);
|
||||
}
|
||||
else {
|
||||
cb(new Error(`Tipo de arquivo não suportado: ${file.mimetype}`));
|
||||
}
|
||||
},
|
||||
});
|
||||
// ─── Helper: salva mídia enviada no disco (para histórico local) ──────────────
|
||||
async function saveMediaLocally(instanceId, messageId, buffer, originalName) {
|
||||
const ext = path_1.default.extname(originalName) || '.bin';
|
||||
const fileName = `${messageId}${ext}`;
|
||||
const filePath = path_1.default.resolve('./media', instanceId, 'sent', fileName);
|
||||
await promises_1.default.mkdir(path_1.default.dirname(filePath), { recursive: true });
|
||||
await promises_1.default.writeFile(filePath, buffer);
|
||||
return `/media/${instanceId}/sent/${fileName}`;
|
||||
}
|
||||
// ─── Router ───────────────────────────────────────────────────────────────────
|
||||
function buildMediaRoutes(manager) {
|
||||
const router = (0, express_1.Router)({ mergeParams: true });
|
||||
// ── POST /api/instances/:instanceId/send-media ─────────────────────────────
|
||||
// Body: multipart/form-data { file, jid, caption?, replyToMessageId? }
|
||||
router.post('/send-media', upload.single('file'), async (req, res) => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
const { jid, caption, replyToMessageId } = req.body;
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: 'Arquivo não enviado' });
|
||||
return;
|
||||
}
|
||||
if (!jid) {
|
||||
res.status(400).json({ error: 'jid é obrigatório' });
|
||||
return;
|
||||
}
|
||||
const instance = await prisma_1.prisma.instance.findFirst({ where: { id: instanceId, tenantId } });
|
||||
if (!instance || instance.status !== 'CONNECTED') {
|
||||
res.status(400).json({ error: 'Instância não conectada' });
|
||||
return;
|
||||
}
|
||||
const sock = manager.getSocket(instanceId);
|
||||
if (!sock) {
|
||||
res.status(503).json({ error: 'Socket não disponível' });
|
||||
return;
|
||||
}
|
||||
const { buffer, mimetype, originalname, size } = req.file;
|
||||
const mediaType = MIME_MAP[mimetype] ?? 'document';
|
||||
// ── Monta o content para o Baileys ────────────────────────────────────
|
||||
let content;
|
||||
switch (mediaType) {
|
||||
case 'image':
|
||||
content = { image: buffer, mimetype, caption: caption ?? '' };
|
||||
break;
|
||||
case 'video':
|
||||
content = {
|
||||
video: buffer,
|
||||
mimetype,
|
||||
caption: caption ?? '',
|
||||
gifPlayback: mimetype === 'image/gif',
|
||||
};
|
||||
break;
|
||||
case 'audio':
|
||||
content = { audio: buffer, mimetype, ptt: false };
|
||||
break;
|
||||
case 'sticker':
|
||||
content = { sticker: buffer, mimetype };
|
||||
break;
|
||||
default:
|
||||
content = { document: buffer, mimetype, fileName: originalname, caption: caption ?? '' };
|
||||
}
|
||||
// ── Reply context ─────────────────────────────────────────────────────
|
||||
let quotedMsg;
|
||||
if (replyToMessageId) {
|
||||
const reply = await prisma_1.prisma.message.findUnique({
|
||||
where: { instanceId_messageId: { instanceId, messageId: replyToMessageId } },
|
||||
});
|
||||
if (reply?.body) {
|
||||
quotedMsg = {
|
||||
key: { remoteJid: jid, fromMe: reply.fromMe, id: reply.messageId },
|
||||
message: { conversation: reply.body },
|
||||
};
|
||||
}
|
||||
}
|
||||
// ── Envia via Baileys ─────────────────────────────────────────────────
|
||||
const sent = await sock.sendMessage(jid, content, quotedMsg ? { quoted: quotedMsg } : undefined);
|
||||
// ── Salva arquivo e faz upload para Wasabi ────────────────────────────
|
||||
const messageId = sent?.key.id ?? `local-${Date.now()}`;
|
||||
const localUrl = await saveMediaLocally(instanceId, messageId, buffer, originalname);
|
||||
let mediaUrl = localUrl;
|
||||
if (StorageProvider_1.storageProvider.isRegistered()) {
|
||||
try {
|
||||
const result = await StorageProvider_1.storageProvider.uploadFile({
|
||||
category: 'whatsapp',
|
||||
usuarioSis: tenantId,
|
||||
sessionJID: instanceId,
|
||||
file: { originalname, buffer, mimetype },
|
||||
});
|
||||
mediaUrl = result.path;
|
||||
}
|
||||
catch (uploadErr) {
|
||||
logger_1.logger.warn({ uploadErr, messageId }, 'Upload Wasabi falhou (mídia enviada), usando path local');
|
||||
}
|
||||
}
|
||||
// ── Garante chat + persiste mensagem ─────────────────────────────────
|
||||
const chat = await prisma_1.prisma.chat.upsert({
|
||||
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } },
|
||||
create: { tenantId, instanceId, jid, lastMessageAt: new Date() },
|
||||
update: { lastMessageAt: new Date() },
|
||||
});
|
||||
const messageType = mediaType.toUpperCase();
|
||||
// Usa upsert para evitar P2002 — o MessageHandler pode ter inserido antes
|
||||
const msg = await prisma_1.prisma.message.upsert({
|
||||
where: { instanceId_messageId: { instanceId, messageId } },
|
||||
create: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
chatId: chat.id,
|
||||
remoteJid: jid,
|
||||
messageId,
|
||||
fromMe: true,
|
||||
type: messageType,
|
||||
body: caption ?? null,
|
||||
mediaUrl,
|
||||
mimeType: mimetype,
|
||||
fileName: mediaType === 'document' ? originalname : null,
|
||||
status: 'SENT',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
update: {
|
||||
mediaUrl,
|
||||
mimeType: mimetype,
|
||||
status: 'SENT',
|
||||
},
|
||||
});
|
||||
logger_1.logger.info({ instanceId, jid, mediaType, size }, 'Mídia enviada');
|
||||
res.status(201).json(msg);
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, 'Erro ao enviar mídia');
|
||||
res.status(500).json({ error: err.message ?? 'Erro ao enviar mídia' });
|
||||
}
|
||||
});
|
||||
// ── POST /api/instances/:instanceId/send-audio ─────────────────────────────
|
||||
// Áudio gravado pelo InputBar (PTT — Push to Talk)
|
||||
// Body: multipart/form-data { audio (blob ogg/webm), jid }
|
||||
router.post('/send-audio', upload.single('audio'), async (req, res) => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
const { jid } = req.body;
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: 'Áudio não enviado' });
|
||||
return;
|
||||
}
|
||||
if (!jid) {
|
||||
res.status(400).json({ error: 'jid é obrigatório' });
|
||||
return;
|
||||
}
|
||||
const instance = await prisma_1.prisma.instance.findFirst({ where: { id: instanceId, tenantId } });
|
||||
if (!instance || instance.status !== 'CONNECTED') {
|
||||
res.status(400).json({ error: 'Instância não conectada' });
|
||||
return;
|
||||
}
|
||||
const sock = manager.getSocket(instanceId);
|
||||
if (!sock) {
|
||||
res.status(503).json({ error: 'Socket não disponível' });
|
||||
return;
|
||||
}
|
||||
const { buffer, mimetype, originalname } = req.file;
|
||||
// PTT (push-to-talk) — aparece como nota de voz no WhatsApp
|
||||
const sent = await sock.sendMessage(jid, {
|
||||
audio: buffer,
|
||||
mimetype: mimetype || 'audio/ogg; codecs=opus',
|
||||
ptt: true,
|
||||
});
|
||||
const messageId = sent?.key.id ?? `local-${Date.now()}`;
|
||||
const localUrl = await saveMediaLocally(instanceId, messageId, buffer, originalname || 'audio.ogg');
|
||||
let mediaUrl = localUrl;
|
||||
if (StorageProvider_1.storageProvider.isRegistered()) {
|
||||
try {
|
||||
const result = await StorageProvider_1.storageProvider.uploadFile({
|
||||
category: 'whatsapp',
|
||||
usuarioSis: tenantId,
|
||||
sessionJID: instanceId,
|
||||
file: { originalname: originalname || 'audio.ogg', buffer, mimetype: mimetype || 'audio/ogg' },
|
||||
});
|
||||
mediaUrl = result.path;
|
||||
}
|
||||
catch (uploadErr) {
|
||||
logger_1.logger.warn({ uploadErr, messageId }, 'Upload Wasabi falhou (áudio enviado), usando path local');
|
||||
}
|
||||
}
|
||||
const chat = await prisma_1.prisma.chat.upsert({
|
||||
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } },
|
||||
create: { tenantId, instanceId, jid, lastMessageAt: new Date() },
|
||||
update: { lastMessageAt: new Date() },
|
||||
});
|
||||
// upsert evita colisão com o registro criado pelo MessageHandler via messages.upsert
|
||||
const msg = await prisma_1.prisma.message.upsert({
|
||||
where: { instanceId_messageId: { instanceId, messageId } },
|
||||
create: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
chatId: chat.id,
|
||||
remoteJid: jid,
|
||||
messageId,
|
||||
fromMe: true,
|
||||
type: 'AUDIO',
|
||||
mimeType: mimetype || 'audio/ogg',
|
||||
mediaUrl,
|
||||
status: 'SENT',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
update: { mediaUrl, status: 'SENT' },
|
||||
});
|
||||
logger_1.logger.info({ instanceId, jid }, 'Áudio PTT enviado');
|
||||
res.status(201).json(msg);
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, 'Erro ao enviar áudio');
|
||||
res.status(500).json({ error: err.message ?? 'Erro ao enviar áudio' });
|
||||
}
|
||||
});
|
||||
// ── POST /api/instances/:instanceId/redownload-media/:messageDbId ─────────
|
||||
// Re-solicita ao WhatsApp a mídia de uma mensagem cujo download falhou.
|
||||
// Usa o payload WAMessage salvo em messages.mediaPayload para reconstruir
|
||||
// a requisição com updateMediaMessage (renova URL expirada se necessário).
|
||||
router.post('/redownload-media/:messageDbId', async (req, res) => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
const messageDbId = req.params['messageDbId'];
|
||||
const msg = await prisma_1.prisma.message.findFirst({
|
||||
where: { id: messageDbId, instanceId, tenantId },
|
||||
select: { id: true, mediaPayload: true, type: true, chatId: true, mediaUrl: true, messageId: true },
|
||||
});
|
||||
if (!msg) {
|
||||
res.status(404).json({ error: 'Mensagem não encontrada' });
|
||||
return;
|
||||
}
|
||||
if (msg.mediaUrl) {
|
||||
res.json({ mediaUrl: msg.mediaUrl });
|
||||
return;
|
||||
}
|
||||
if (!msg.mediaPayload) {
|
||||
res.status(400).json({ error: 'Payload de mídia não disponível para esta mensagem' });
|
||||
return;
|
||||
}
|
||||
const sock = manager.getSocket(instanceId);
|
||||
if (!sock) {
|
||||
res.status(503).json({ error: 'Instância não conectada' });
|
||||
return;
|
||||
}
|
||||
// Desserializa payload: { _b64: base64 } → Buffer/Uint8Array
|
||||
const waMsg = deserializePayload(msg.mediaPayload);
|
||||
const buffer = await (0, engine_1.downloadMediaMessage)(waMsg, 'buffer', {}, { logger: logger_1.logger, reuploadRequest: sock.updateMediaMessage });
|
||||
if (!buffer) {
|
||||
res.status(500).json({ error: 'Nenhum buffer retornado pelo WhatsApp' });
|
||||
return;
|
||||
}
|
||||
const contentType = (0, engine_1.getContentType)(waMsg.message) ?? 'imageMessage';
|
||||
const ext = EXT_MAP[contentType] ?? 'bin';
|
||||
const mime = MIME_FOR_TYPE[contentType] ?? 'application/octet-stream';
|
||||
const fileName = `${messageDbId}.${ext}`;
|
||||
let mediaUrl = `/media/${instanceId}/${fileName}`;
|
||||
if (StorageProvider_1.storageProvider.isRegistered()) {
|
||||
try {
|
||||
const result = await StorageProvider_1.storageProvider.uploadFile({
|
||||
category: 'whatsapp',
|
||||
usuarioSis: tenantId,
|
||||
sessionJID: instanceId,
|
||||
file: { originalname: fileName, buffer: buffer, mimetype: mime },
|
||||
});
|
||||
mediaUrl = result.path;
|
||||
}
|
||||
catch (uploadErr) {
|
||||
logger_1.logger.warn({ uploadErr, messageDbId }, 'Upload Wasabi falhou no re-download, usando path local');
|
||||
const filePath = path_1.default.resolve('./media', instanceId, fileName);
|
||||
await promises_1.default.mkdir(path_1.default.dirname(filePath), { recursive: true });
|
||||
await promises_1.default.writeFile(filePath, buffer);
|
||||
}
|
||||
}
|
||||
else {
|
||||
const filePath = path_1.default.resolve('./media', instanceId, fileName);
|
||||
await promises_1.default.mkdir(path_1.default.dirname(filePath), { recursive: true });
|
||||
await promises_1.default.writeFile(filePath, buffer);
|
||||
}
|
||||
await prisma_1.prisma.message.update({ where: { id: messageDbId }, data: { mediaUrl } });
|
||||
manager.getIO().to(`chat:${msg.chatId}`).emit('message:media_ready', {
|
||||
messageId: messageDbId,
|
||||
mediaUrl,
|
||||
});
|
||||
logger_1.logger.info({ messageDbId, contentType }, 'Mídia re-baixada com sucesso');
|
||||
res.json({ mediaUrl });
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, 'Erro ao re-baixar mídia');
|
||||
res.status(500).json({ error: err.message ?? 'Erro ao re-baixar mídia' });
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
const EXT_MAP = {
|
||||
imageMessage: 'jpg',
|
||||
videoMessage: 'mp4',
|
||||
audioMessage: 'ogg',
|
||||
documentMessage: 'bin',
|
||||
stickerMessage: 'webp',
|
||||
};
|
||||
const MIME_FOR_TYPE = {
|
||||
imageMessage: 'image/jpeg',
|
||||
videoMessage: 'video/mp4',
|
||||
audioMessage: 'audio/ogg',
|
||||
documentMessage: 'application/octet-stream',
|
||||
stickerMessage: 'image/webp',
|
||||
};
|
||||
function deserializePayload(val) {
|
||||
if (Array.isArray(val))
|
||||
return val.map(deserializePayload);
|
||||
if (val !== null && typeof val === 'object') {
|
||||
const obj = val;
|
||||
if ('_b64' in obj && typeof obj._b64 === 'string') {
|
||||
return Buffer.from(obj._b64, 'base64');
|
||||
}
|
||||
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, deserializePayload(v)]));
|
||||
}
|
||||
return val;
|
||||
}
|
||||
//# sourceMappingURL=whatsapp.media.routes.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,170 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildWhatsAppRoutes = buildWhatsAppRoutes;
|
||||
const express_1 = require("express");
|
||||
const promises_1 = __importDefault(require("fs/promises"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const InstanceRepository_1 = require("./repositories/InstanceRepository");
|
||||
const dragonfly_1 = require("../../infra/cache/dragonfly");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const logger_1 = require("../../config/logger");
|
||||
const StorageProvider_1 = require("../../core/StorageProvider");
|
||||
const ENGINE_CHANGE_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutos entre trocas
|
||||
function buildWhatsAppRoutes(manager) {
|
||||
const router = (0, express_1.Router)();
|
||||
const repo = new InstanceRepository_1.InstanceRepository();
|
||||
// Listar instâncias do tenant
|
||||
router.get('/', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const instances = await repo.findAllByTenant(tenantId);
|
||||
res.json(instances);
|
||||
});
|
||||
// Criar instância
|
||||
router.post('/', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const { name } = req.body;
|
||||
if (!name) {
|
||||
res.status(400).json({ error: 'name é obrigatório' });
|
||||
return;
|
||||
}
|
||||
const instance = await repo.create(tenantId, name);
|
||||
res.status(201).json(instance);
|
||||
});
|
||||
// Conectar instância (emite QR via Socket.IO)
|
||||
router.post('/:id/connect', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = req.params['id'];
|
||||
const instance = await repo.findById(id);
|
||||
if (!instance || instance.tenantId !== tenantId) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' });
|
||||
return;
|
||||
}
|
||||
// Não aguarda — a conexão ocorre em background e o QR chega via Socket.IO
|
||||
manager.connect(id, tenantId).catch((err) => {
|
||||
console.error('[Connect] Falha ao iniciar conexão:', err);
|
||||
});
|
||||
res.json({ message: 'Conectando... aguarde o QR Code via WebSocket' });
|
||||
});
|
||||
// Desconectar instância
|
||||
router.post('/:id/disconnect', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = req.params['id'];
|
||||
const instance = await repo.findById(id);
|
||||
if (!instance || instance.tenantId !== tenantId) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' });
|
||||
return;
|
||||
}
|
||||
await manager.disconnect(id);
|
||||
res.json({ message: 'Desconectado' });
|
||||
});
|
||||
// QR Code atual (fallback REST — normalmente via Socket.IO)
|
||||
router.get('/:id/qr', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = req.params['id'];
|
||||
const instance = await repo.findById(id);
|
||||
if (!instance || instance.tenantId !== tenantId) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' });
|
||||
return;
|
||||
}
|
||||
const qrBase64 = await dragonfly_1.dragonfly.get(`instance:${id}:qr`);
|
||||
if (!qrBase64) {
|
||||
res.status(404).json({ error: 'QR Code não disponível. Inicie a conexão primeiro.' });
|
||||
return;
|
||||
}
|
||||
res.json({ qrBase64 });
|
||||
});
|
||||
// Deletar instância
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = req.params['id'];
|
||||
const instance = await repo.findById(id);
|
||||
if (!instance || instance.tenantId !== tenantId) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' });
|
||||
return;
|
||||
}
|
||||
// 1. Desconectar e deslogar a sessão do WhatsApp se estiver ativa
|
||||
await manager.disconnect(id).catch(() => { });
|
||||
// 2. Garantir a exclusão física da pasta de credenciais da sessão no servidor
|
||||
await manager.deleteSessionFiles(id).catch(() => { });
|
||||
// 3. Garantir a exclusão física da pasta de mídia local ./media/{instanceId} no servidor
|
||||
const localMediaDir = path_1.default.resolve('./media', id);
|
||||
await promises_1.default.rm(localMediaDir, { recursive: true, force: true }).catch((err) => {
|
||||
console.error(`[deleteInstance] Erro ao deletar pasta local de mídia da instância ${id}:`, err);
|
||||
});
|
||||
// 4. Apagar do banco de dados
|
||||
await repo.delete(id);
|
||||
// 5. Deleta mídias associadas à instância no storage remoto (Wasabi/S3) de forma assíncrona
|
||||
StorageProvider_1.storageProvider.deletePrefix(`whatsapp/${tenantId}/${id}/`).catch((err) => {
|
||||
console.error(`[StorageProvider] Erro ao deletar mídias da instância ${id}:`, err);
|
||||
});
|
||||
res.json({ message: 'Instância removida com sucesso de todos os storages e servidores' });
|
||||
});
|
||||
// Estatísticas de mensagens da instância (contagens do banco)
|
||||
router.get('/:id/stats', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = req.params['id'];
|
||||
const instance = await repo.findById(id);
|
||||
if (!instance || instance.tenantId !== tenantId) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' });
|
||||
return;
|
||||
}
|
||||
const [received, sent, media, chats] = await Promise.all([
|
||||
prisma_1.prisma.message.count({ where: { instanceId: id, fromMe: false } }),
|
||||
prisma_1.prisma.message.count({ where: { instanceId: id, fromMe: true } }),
|
||||
prisma_1.prisma.message.count({
|
||||
where: {
|
||||
instanceId: id,
|
||||
type: { in: ['IMAGE', 'VIDEO', 'AUDIO', 'DOCUMENT', 'STICKER'] },
|
||||
},
|
||||
}),
|
||||
prisma_1.prisma.chat.count({ where: { instanceId: id } }),
|
||||
]);
|
||||
res.json({ received, sent, media, chats });
|
||||
});
|
||||
// Trocar engine de uma instância (com cooldown + auditoria)
|
||||
router.patch('/:id/engine', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = req.params['id'];
|
||||
const { engine } = req.body;
|
||||
if (engine !== 'infinite' && engine !== 'official') {
|
||||
res.status(400).json({ error: 'engine deve ser "infinite" ou "official"' });
|
||||
return;
|
||||
}
|
||||
const instance = await repo.findById(id);
|
||||
if (!instance || instance.tenantId !== tenantId) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' });
|
||||
return;
|
||||
}
|
||||
// Cooldown: bloqueia nova troca por 5 min após a última
|
||||
const cooldownKey = `instance:${id}:engine_change_lock`;
|
||||
const lockedAt = await dragonfly_1.dragonfly.get(cooldownKey);
|
||||
if (lockedAt) {
|
||||
const remaining = Math.ceil((ENGINE_CHANGE_COOLDOWN_MS - (Date.now() - Number(lockedAt))) / 1000);
|
||||
res.status(429).json({ error: `Aguarde ${remaining}s antes de trocar a engine novamente` });
|
||||
return;
|
||||
}
|
||||
const fromEngine = instance.engine ?? 'infinite';
|
||||
if (fromEngine === engine) {
|
||||
res.status(200).json({ message: 'Engine já está definida para este valor', engine });
|
||||
return;
|
||||
}
|
||||
// Persiste a troca
|
||||
await prisma_1.prisma.instance.update({ where: { id }, data: { engine } });
|
||||
// Audit log estruturado
|
||||
logger_1.logger.info({ tenantId, instanceId: id, fromEngine, toEngine: engine }, '[EngineChange] Troca de engine auditada');
|
||||
// Aplica cooldown (5 min via TTL no Redis)
|
||||
await dragonfly_1.dragonfly.psetex(cooldownKey, ENGINE_CHANGE_COOLDOWN_MS, String(Date.now()));
|
||||
// Reconecta a instância com a nova engine
|
||||
const isConnected = manager.getSocket(id) != null;
|
||||
if (isConnected) {
|
||||
await manager.disconnect(id).catch(() => { });
|
||||
manager.connect(id, tenantId).catch(() => { });
|
||||
}
|
||||
res.json({ message: 'Engine atualizada', engine, reconnecting: isConnected });
|
||||
});
|
||||
return router;
|
||||
}
|
||||
//# sourceMappingURL=whatsapp.routes.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
+741
@@ -0,0 +1,741 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildSendRoutes = buildSendRoutes;
|
||||
exports.buildAvatarRoute = buildAvatarRoute;
|
||||
/**
|
||||
* Rotas de envio e operações WhatsApp.
|
||||
*
|
||||
* Endpoints:
|
||||
* POST /api/instances/:instanceId/send — Envia mensagem de texto (com @mentions e reply)
|
||||
* GET /api/instances/:instanceId/groups/:jid/participants — Lista participantes de grupo
|
||||
* POST /api/instances/:instanceId/reconcile-names — Reconcilia nomes via pushName (bulk SQL)
|
||||
* POST /api/instances/:instanceId/broadcast — Disparo em massa (async, até 500 números)
|
||||
* GET /api/instances/:instanceId/broadcast — Lista broadcasts da instância
|
||||
* GET /api/instances/:instanceId/broadcast/:id — Detalhe de um broadcast
|
||||
* GET /api/inbox/avatar/:jid — Proxy de foto de perfil (evita CORS)
|
||||
*
|
||||
* Todas as rotas exigem autenticação (tenantId no req).
|
||||
* O WhatsAppConnectionManager é injetado via buildSendRoutes() para
|
||||
* acessar os sockets Baileys ativos.
|
||||
*
|
||||
* @see WhatsAppConnectionManager — provê getSocket() e getIO()
|
||||
* @see ContactHandler — reconciliação de nomes (mesma lógica, mas via endpoint HTTP)
|
||||
*/
|
||||
const express_1 = require("express");
|
||||
const crypto_1 = require("crypto");
|
||||
const zod_1 = require("zod");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const dragonfly_1 = require("../../infra/cache/dragonfly");
|
||||
const logger_1 = require("../../config/logger");
|
||||
const StorageProvider_1 = require("../../core/StorageProvider");
|
||||
const rich_message_1 = require("./rich-message");
|
||||
/**
|
||||
* Constrói as rotas de envio e operações WhatsApp.
|
||||
*
|
||||
* Montadas em: /api/instances/:instanceId/
|
||||
* Usa mergeParams para herdar :instanceId do router pai.
|
||||
*/
|
||||
function buildSendRoutes(manager) {
|
||||
const router = (0, express_1.Router)({ mergeParams: true });
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// POST /api/instances/:instanceId/send — Envio de mensagem de texto
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Envia uma mensagem de texto para um JID (contato ou grupo).
|
||||
*
|
||||
* Body:
|
||||
* - jid: string (obrigatório) — JID destino (ex: "5511999999999@s.whatsapp.net")
|
||||
* - text: string (obrigatório) — Texto da mensagem (até 4096 chars)
|
||||
* - replyToMessageId?: string — ID da mensagem sendo respondida (quote/reply)
|
||||
* - mentions?: string[] — Lista de JIDs mencionados com @ (para grupos)
|
||||
*
|
||||
* Fluxo:
|
||||
* 1. Valida instância conectada e socket disponível
|
||||
* 2. Monta contexto de reply (se houver replyToMessageId)
|
||||
* 3. Envia via Baileys sock.sendMessage()
|
||||
* 4. Cria/atualiza Chat no banco
|
||||
* 5. Persiste a mensagem enviada
|
||||
* 6. Retorna 201 com o registro da mensagem
|
||||
*/
|
||||
router.post('/send', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
const { jid, text, footer, replyToMessageId, mentions,
|
||||
// ── Tipos ricos (InfiniteAPI/Baileys) ────────────────────────────────
|
||||
nativeButtons, nativeList, nativeCarousel, poll, } = req.body;
|
||||
const hasRichContent = !!(nativeButtons || nativeList || nativeCarousel || poll);
|
||||
if (!jid || (!text && !hasRichContent)) {
|
||||
res.status(400).json({ error: 'jid e text (ou tipo rico) são obrigatórios' });
|
||||
return;
|
||||
}
|
||||
// Valida que a instância existe e está conectada
|
||||
const instance = await prisma_1.prisma.instance.findFirst({
|
||||
where: { id: instanceId, tenantId },
|
||||
});
|
||||
if (!instance || instance.status !== 'CONNECTED') {
|
||||
res.status(400).json({ error: 'Instância não conectada' });
|
||||
return;
|
||||
}
|
||||
// Busca o socket Baileys ativo
|
||||
const sock = manager.getSocket(instanceId);
|
||||
if (!sock) {
|
||||
res.status(503).json({ error: 'Socket não disponível' });
|
||||
return;
|
||||
}
|
||||
// ── Resolve o JID real via onWhatsApp (corrige o dígito 9 brasileiro) ────
|
||||
// Números brasileiros migraram de 8 para 9 dígitos. O usuário pode digitar
|
||||
// com ou sem o 9 — o WhatsApp sabe o JID correto. Grupos (@g.us) são
|
||||
// excluídos pois já têm JID definitivo.
|
||||
let resolvedJid = jid;
|
||||
if (!jid.endsWith('@g.us')) {
|
||||
try {
|
||||
const waResults = await sock.onWhatsApp(jid);
|
||||
const waResult = waResults?.[0];
|
||||
if (!waResult?.exists) {
|
||||
res.status(422).json({ error: 'Número não encontrado no WhatsApp' });
|
||||
return;
|
||||
}
|
||||
// Se onWhatsApp retornar @lid (WhatsApp moderno), ignoramos — o Baileys
|
||||
// resolve @lid internamente ao enviar. Armazenar @lid no banco criaria
|
||||
// um chat duplicado conflitando com o @s.whatsapp.net do MessageHandler.
|
||||
if (!waResult.jid.endsWith('@lid')) {
|
||||
resolvedJid = waResult.jid; // JID correto com ou sem o 9
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.warn({ err, jid }, 'onWhatsApp falhou — usando JID original');
|
||||
// Continua com o JID original se a consulta falhar
|
||||
}
|
||||
}
|
||||
// Monta contexto de reply (quoted message) se necessário
|
||||
// O Baileys precisa da mensagem original para exibir o quote no WhatsApp
|
||||
let quotedMsg;
|
||||
if (replyToMessageId) {
|
||||
const reply = await prisma_1.prisma.message.findUnique({
|
||||
where: { instanceId_messageId: { instanceId, messageId: replyToMessageId } },
|
||||
});
|
||||
if (reply?.body) {
|
||||
quotedMsg = {
|
||||
key: { remoteJid: resolvedJid, fromMe: reply.fromMe, id: reply.messageId },
|
||||
message: { conversation: reply.body },
|
||||
};
|
||||
}
|
||||
}
|
||||
// ── Mensagens ricas: delega ao rich-message builder ──────────────────────
|
||||
// O @whiskeysockets/baileys padrão não suporta nativeButtons/nativeList/
|
||||
// nativeCarousel. O builder converte para os protos corretos e usa
|
||||
// relayMessage() para enviar sem passar pelo generateWAMessageContent.
|
||||
const hasRich = !!(nativeButtons || nativeList || nativeCarousel || poll);
|
||||
let sentMessageId;
|
||||
if (hasRich) {
|
||||
sentMessageId = await (0, rich_message_1.sendRichMessage)(sock, resolvedJid, { text, footer, nativeButtons, nativeList, nativeCarousel, poll }, quotedMsg, instance.engine);
|
||||
}
|
||||
else {
|
||||
// Texto simples (com suporte a @mentions)
|
||||
const msgContent = { text: text ?? '' };
|
||||
if (mentions?.length)
|
||||
msgContent.mentions = mentions;
|
||||
const sent = await sock.sendMessage(resolvedJid, msgContent, quotedMsg ? { quoted: quotedMsg } : undefined);
|
||||
sentMessageId = sent?.key?.id ?? `text-${Date.now()}`;
|
||||
}
|
||||
const sent = { key: { id: sentMessageId } };
|
||||
// ── Persiste chat e mensagem imediatamente ────────────────────────────────
|
||||
// O Baileys dispara messages.upsert com type='append' para mensagens enviadas
|
||||
// por nós, o que não emite eventos Socket.IO. Por isso fazemos a criação aqui
|
||||
// e emitimos os eventos manualmente para o frontend atualizar em tempo real.
|
||||
const isGroup = resolvedJid.endsWith('@g.us');
|
||||
// Garantir que o Contato existe no banco
|
||||
const contact = await prisma_1.prisma.contact.upsert({
|
||||
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid: resolvedJid } },
|
||||
create: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
jid: resolvedJid,
|
||||
phone: isGroup ? null : resolvedJid.split('@')[0],
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
const chat = await prisma_1.prisma.chat.upsert({
|
||||
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid: resolvedJid } },
|
||||
create: { tenantId, instanceId, jid: resolvedJid, contactId: contact.id, lastMessageAt: new Date() },
|
||||
update: { contactId: contact.id, lastMessageAt: new Date() },
|
||||
});
|
||||
const messageId = sent?.key.id ?? `local-${Date.now()}`;
|
||||
// Determina tipo, body e richPayload para persistência
|
||||
let msgType = 'TEXT';
|
||||
let msgBody = text ?? '';
|
||||
let richPayloadData;
|
||||
if (poll) {
|
||||
msgType = 'POLL';
|
||||
msgBody = poll.name;
|
||||
richPayloadData = { poll };
|
||||
}
|
||||
else if (nativeButtons) {
|
||||
// Detecta se há botões CTA (url/copy/call) → INTERACTIVE, senão → BUTTONS
|
||||
const hasCta = nativeButtons.some((b) => ['url', 'copy', 'call'].includes(b.type));
|
||||
msgType = hasCta ? 'INTERACTIVE' : 'BUTTONS';
|
||||
msgBody = text ?? '';
|
||||
richPayloadData = { text, footer, nativeButtons };
|
||||
}
|
||||
else if (nativeList) {
|
||||
msgType = 'LIST';
|
||||
msgBody = text ?? nativeList.buttonText;
|
||||
richPayloadData = { text, footer, nativeList };
|
||||
}
|
||||
else if (nativeCarousel) {
|
||||
msgType = 'CAROUSEL';
|
||||
msgBody = text ?? nativeCarousel.cards[0]?.title ?? '[Carrossel]';
|
||||
richPayloadData = { text, footer, nativeCarousel };
|
||||
}
|
||||
const msg = await prisma_1.prisma.message.upsert({
|
||||
where: { instanceId_messageId: { instanceId, messageId } },
|
||||
create: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
chatId: chat.id,
|
||||
remoteJid: resolvedJid,
|
||||
messageId,
|
||||
fromMe: true,
|
||||
type: msgType,
|
||||
body: msgBody,
|
||||
richPayload: richPayloadData ? JSON.parse(JSON.stringify(richPayloadData)) : undefined,
|
||||
status: 'SENT',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
update: {}, // já existe (eco do Baileys chegou primeiro) — não sobrescreve
|
||||
});
|
||||
// ── Emite eventos Socket.IO para o frontend atualizar em tempo real ───────
|
||||
const io = manager.getIO();
|
||||
// Inclui replyTo quando é uma resposta — necessário para exibir a caixa
|
||||
// de citação em sessões adicionais do mesmo tenant que tenham o chat aberto.
|
||||
const msgToEmit = replyToMessageId
|
||||
? await prisma_1.prisma.message.findUnique({
|
||||
where: { id: msg.id },
|
||||
include: {
|
||||
replyTo: {
|
||||
select: { id: true, messageId: true, body: true, fromMe: true, type: true, mediaUrl: true },
|
||||
},
|
||||
},
|
||||
}) ?? msg
|
||||
: msg;
|
||||
// Atualiza o chat aberto (se o usuário já estiver nele)
|
||||
io.to(`chat:${chat.id}`).emit('message:new', { ...msgToEmit, pushName: null, senderJid: null });
|
||||
const displayName = isGroup
|
||||
? (chat.name ?? null)
|
||||
: (contact.name ?? contact.verifiedName ?? contact.notify ?? null);
|
||||
// Atualiza a lista de chats do tenant (novo chat ou reordenar pelo lastMessageAt)
|
||||
io.to(`tenant:${tenantId}`).emit('chat:upsert', {
|
||||
id: chat.id,
|
||||
tenantId: chat.tenantId,
|
||||
instanceId: chat.instanceId,
|
||||
jid: chat.jid,
|
||||
name: chat.name ?? null,
|
||||
unreadCount: chat.unreadCount,
|
||||
lastMessageAt: chat.lastMessageAt,
|
||||
isPinned: chat.isPinned,
|
||||
isArchived: chat.isArchived,
|
||||
botPaused: chat.botPaused,
|
||||
contact: {
|
||||
name: displayName,
|
||||
phone: contact.phone,
|
||||
avatarUrl: contact.avatarUrl,
|
||||
scoreReputacao: contact.scoreReputacao,
|
||||
flagRestricao: contact.flagRestricao,
|
||||
},
|
||||
lastMessage: {
|
||||
body: msgBody,
|
||||
fromMe: true,
|
||||
status: 'SENT',
|
||||
type: msgType,
|
||||
timestamp: msg.timestamp,
|
||||
},
|
||||
});
|
||||
res.status(201).json({ id: msg.id, messageId: msg.messageId });
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/instances/:instanceId/groups/:groupJid/participants
|
||||
// Lista participantes de um grupo (para autocomplete de @mentions)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Retorna a lista de participantes de um grupo WhatsApp.
|
||||
*
|
||||
* Usado pelo InputBar do frontend para autocomplete de @mentions.
|
||||
* Busca metadata via Baileys groupMetadata() e enriquece com dados do banco:
|
||||
* - Nome do contato (se existir no banco)
|
||||
* - Para JIDs @lid sem nome: busca pushName da última mensagem no grupo
|
||||
* - Telefone formatado
|
||||
*
|
||||
* Response: { groupJid, subject, participants: [{ jid, admin, name, phone }] }
|
||||
*/
|
||||
router.get('/groups/:groupJid/participants', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
const groupJid = req.params['groupJid'];
|
||||
if (!groupJid?.endsWith('@g.us')) {
|
||||
res.status(400).json({ error: 'JID de grupo inválido' });
|
||||
return;
|
||||
}
|
||||
const instance = await prisma_1.prisma.instance.findFirst({
|
||||
where: { id: instanceId, tenantId },
|
||||
});
|
||||
if (!instance || instance.status !== 'CONNECTED') {
|
||||
res.status(400).json({ error: 'Instância não conectada' });
|
||||
return;
|
||||
}
|
||||
const sock = manager.getSocket(instanceId);
|
||||
if (!sock) {
|
||||
res.status(503).json({ error: 'Socket não disponível' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Busca metadata do grupo via Baileys (inclui lista de participantes)
|
||||
const metadata = await sock.groupMetadata(groupJid);
|
||||
const participants = await Promise.all(metadata.participants.map(async (p) => {
|
||||
// Enriquece com dados do banco (nome, telefone)
|
||||
const contact = await prisma_1.prisma.contact.findFirst({
|
||||
where: { tenantId, instanceId, jid: p.id },
|
||||
select: { name: true, notify: true, phone: true },
|
||||
});
|
||||
let name = contact?.name || contact?.notify || null;
|
||||
let phone = contact?.phone || null;
|
||||
// Para @lid JIDs sem nome: tenta buscar pushName da última mensagem no grupo
|
||||
// (LID não tem telefone no JID, então o nome só pode vir de mensagens)
|
||||
if (!name && p.id.endsWith('@lid')) {
|
||||
const lastMsg = await prisma_1.prisma.message.findFirst({
|
||||
where: {
|
||||
instanceId,
|
||||
remoteJid: groupJid,
|
||||
senderJid: p.id,
|
||||
fromMe: false,
|
||||
pushName: { not: null },
|
||||
},
|
||||
orderBy: { timestamp: 'desc' },
|
||||
select: { pushName: true },
|
||||
});
|
||||
if (lastMsg?.pushName)
|
||||
name = lastMsg.pushName;
|
||||
}
|
||||
// Extrai telefone do JID (apenas para @s.whatsapp.net, não @lid)
|
||||
if (!phone) {
|
||||
phone = p.id.endsWith('@lid') ? null : p.id.split('@')[0].split(':')[0];
|
||||
}
|
||||
return {
|
||||
jid: p.id,
|
||||
admin: p.admin ?? null, // 'admin' | 'superadmin' | null
|
||||
name,
|
||||
phone,
|
||||
};
|
||||
}));
|
||||
res.json({ groupJid, subject: metadata.subject, participants });
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, groupJid }, '[Groups] Erro ao buscar participantes');
|
||||
res.status(500).json({ error: 'Erro ao buscar participantes do grupo' });
|
||||
}
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// POST /api/instances/:instanceId/reconcile-names
|
||||
// Reconciliação bulk de nomes via pushName (endpoint HTTP)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Reconcilia nomes de contatos usando pushName das mensagens recebidas.
|
||||
*
|
||||
* Diferente de ContactHandler.reconcileNamesFromMessages() que roda no sync,
|
||||
* este endpoint pode ser chamado pelo frontend a qualquer momento (ex: page reload).
|
||||
*
|
||||
* Implementação:
|
||||
* - Usa raw SQL com DISTINCT ON para buscar o pushName mais recente por JID
|
||||
* - Uma única query UPDATE...FROM (subquery) atualiza todos os contatos sem nome
|
||||
* - Cache DragonflyDB de 30min para evitar re-execução em loop
|
||||
* - Idempotente: se já executou nos últimos 30min, retorna 204
|
||||
*
|
||||
* NOTA sobre tabelas: Prisma usa nomes como "Contact" e "Message" nos models,
|
||||
* mas as tabelas PostgreSQL reais são "contacts" e "messages" (via @@map).
|
||||
* No $executeRaw, SEMPRE usar os nomes reais das tabelas.
|
||||
*/
|
||||
// Fallback in-memory para o TTL de reconciliação quando Redis está fora
|
||||
// Map<cacheKey, timestampMs do último reconcile>
|
||||
const reconcileMemCache = new Map();
|
||||
const RECONCILE_TTL_MS = 30 * 60 * 1000; // 30 min
|
||||
router.post('/reconcile-names', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
const cacheKey = `reconcile:${tenantId}:${instanceId}`;
|
||||
// Camada 1: Redis (30min TTL)
|
||||
const cached = await dragonfly_1.dragonfly.get(cacheKey);
|
||||
if (cached) {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
// Camada 2: in-memory (protege quando Redis está fora)
|
||||
const memTs = reconcileMemCache.get(cacheKey);
|
||||
if (memTs && Date.now() - memTs < RECONCILE_TTL_MS) {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Query bulk: encontra contatos sem nome que têm msgs recebidas com pushName.
|
||||
// Usa DISTINCT ON (PostgreSQL) para pegar o pushName mais recente por JID.
|
||||
// Uma única query UPDATE...FROM atualiza todos — O(1) round-trips ao banco.
|
||||
const updated = await prisma_1.prisma.$executeRaw `
|
||||
UPDATE contacts c
|
||||
SET
|
||||
"name" = sub."pushName",
|
||||
"notify" = COALESCE(c."notify", sub."pushName"),
|
||||
"updatedAt" = NOW()
|
||||
FROM (
|
||||
SELECT DISTINCT ON (m."remoteJid")
|
||||
m."remoteJid",
|
||||
m."pushName"
|
||||
FROM messages m
|
||||
WHERE m."instanceId" = ${instanceId}
|
||||
AND m."fromMe" = false
|
||||
AND m."pushName" IS NOT NULL
|
||||
AND m."pushName" != ''
|
||||
ORDER BY m."remoteJid", m."timestamp" DESC
|
||||
) sub
|
||||
WHERE c."instanceId" = ${instanceId}
|
||||
AND c."tenantId" = ${tenantId}
|
||||
AND c."jid" = sub."remoteJid"
|
||||
AND c."name" IS NULL
|
||||
AND c."notify" IS NULL
|
||||
`;
|
||||
// Seta flag no Redis (30min) + in-memory — independente do resultado
|
||||
await dragonfly_1.dragonfly.set(cacheKey, String(updated), 1800);
|
||||
reconcileMemCache.set(cacheKey, Date.now());
|
||||
if (updated > 0) {
|
||||
logger_1.logger.info({ instanceId, updated }, '[Reconcile] Nomes atualizados via pushName');
|
||||
// Notifica frontend para re-buscar a lista de chats
|
||||
const io = manager.getIO();
|
||||
if (io) {
|
||||
io.to(`tenant:${tenantId}`).emit('chats:refresh', { instanceId, reason: 'reconcile', updated });
|
||||
}
|
||||
}
|
||||
res.json({ updated });
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, instanceId }, '[Reconcile] Erro na reconciliação bulk');
|
||||
// Mesmo com erro, seta cache curto (60s) para não ficar retentando em loop
|
||||
await dragonfly_1.dragonfly.set(cacheKey, '0', 60).catch(() => { });
|
||||
res.status(500).json({ error: 'Erro na reconciliação' });
|
||||
}
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// POST /api/instances/:instanceId/broadcast — Disparo em massa
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Inicia um disparo em massa (broadcast) para até 500 números.
|
||||
*
|
||||
* Validação:
|
||||
* - numbers: array de strings (mínimo 5 chars cada, 1-500 itens)
|
||||
* - message: texto (1-4096 chars)
|
||||
* - delayMs: delay entre envios (500-60000ms, default 2000ms)
|
||||
*
|
||||
* Fluxo:
|
||||
* 1. Valida payload via Zod
|
||||
* 2. Cria registro Broadcast com status 'RUNNING'
|
||||
* 3. Retorna 202 com jobId imediatamente
|
||||
* 4. Processa envios em background (async IIFE)
|
||||
* 5. Atualiza sentCount/failedCount a cada envio
|
||||
* 6. Marca como 'DONE' ao finalizar
|
||||
*
|
||||
* IMPORTANTE: O delay entre envios é obrigatório para evitar ban do WhatsApp.
|
||||
* O default de 2s é conservador — ajuste conforme volume e tolerância ao risco.
|
||||
*/
|
||||
router.post('/broadcast', async (req, res) => {
|
||||
const schema = zod_1.z.object({
|
||||
numbers: zod_1.z.array(zod_1.z.string().min(5)).min(1).max(500),
|
||||
message: zod_1.z.string().min(1).max(4096),
|
||||
delayMs: zod_1.z.number().int().min(500).max(60000).default(2000),
|
||||
});
|
||||
let body;
|
||||
try {
|
||||
body = schema.parse(req.body);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(400).json({ error: err.errors ?? err.message });
|
||||
return;
|
||||
}
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
const instance = await prisma_1.prisma.instance.findFirst({ where: { id: instanceId, tenantId } });
|
||||
if (!instance || instance.status !== 'CONNECTED') {
|
||||
res.status(400).json({ error: 'Instância não conectada' });
|
||||
return;
|
||||
}
|
||||
const sock = manager.getSocket(instanceId);
|
||||
if (!sock) {
|
||||
res.status(503).json({ error: 'Socket não disponível' });
|
||||
return;
|
||||
}
|
||||
// Cria registro do broadcast no banco
|
||||
const broadcast = await prisma_1.prisma.broadcast.create({
|
||||
data: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
message: body.message,
|
||||
totalCount: body.numbers.length,
|
||||
sentCount: 0,
|
||||
failedCount: 0,
|
||||
status: 'RUNNING',
|
||||
},
|
||||
});
|
||||
// Retorna imediatamente (processamento assíncrono)
|
||||
res.status(202).json({ jobId: broadcast.id, total: body.numbers.length });
|
||||
(async () => {
|
||||
let sent = 0, failed = 0;
|
||||
for (let i = 0; i < body.numbers.length; i++) {
|
||||
const rawNumber = body.numbers[i];
|
||||
// Normaliza número: remove caracteres não-numéricos
|
||||
const number = rawNumber.replace(/\D/g, '');
|
||||
// Se já é um JID completo (contém @), usa como está; senão, adiciona sufixo
|
||||
const jid = number.includes('@') ? number : `${number}@s.whatsapp.net`;
|
||||
try {
|
||||
await sock.sendMessage(jid, { text: body.message });
|
||||
await prisma_1.prisma.broadcastRecipient.create({
|
||||
data: { broadcastId: broadcast.id, jid, status: 'SENT' },
|
||||
});
|
||||
sent++;
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.warn({ jid, err }, '[Broadcast] Falha ao enviar');
|
||||
await prisma_1.prisma.broadcastRecipient.create({
|
||||
data: { broadcastId: broadcast.id, jid, status: 'FAILED' },
|
||||
}).catch(() => { });
|
||||
failed++;
|
||||
}
|
||||
// Atualiza contadores em tempo real (permite consulta de progresso)
|
||||
await prisma_1.prisma.broadcast.update({
|
||||
where: { id: broadcast.id },
|
||||
data: { sentCount: sent, failedCount: failed },
|
||||
});
|
||||
// Delay entre envios para evitar ban do WhatsApp (pula no último item)
|
||||
if (i < body.numbers.length - 1) {
|
||||
await new Promise(r => setTimeout(r, body.delayMs));
|
||||
}
|
||||
}
|
||||
// Marca como concluído
|
||||
await prisma_1.prisma.broadcast.update({
|
||||
where: { id: broadcast.id },
|
||||
data: { status: 'DONE', finishedAt: new Date() },
|
||||
});
|
||||
})().catch(err => {
|
||||
logger_1.logger.error({ err }, '[Broadcast] Erro no job');
|
||||
prisma_1.prisma.broadcast.update({ where: { id: broadcast.id }, data: { status: 'FAILED' } }).catch(() => { });
|
||||
});
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/instances/:instanceId/broadcast — Lista broadcasts
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/** Lista os últimos broadcasts da instância (paginado, max 50) */
|
||||
router.get('/broadcast', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
const limit = Math.min(Number(req.query['limit'] ?? 20), 50);
|
||||
const rows = await prisma_1.prisma.broadcast.findMany({
|
||||
where: { tenantId, instanceId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
include: { _count: { select: { recipients: true } } },
|
||||
});
|
||||
res.json(rows);
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/instances/:instanceId/broadcast/:id — Detalhe de broadcast
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/** Retorna detalhe de um broadcast específico, incluindo lista de destinatários */
|
||||
router.get('/broadcast/:broadcastId', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const broadcastId = req.params['broadcastId'];
|
||||
const row = await prisma_1.prisma.broadcast.findFirst({
|
||||
where: { id: broadcastId, tenantId },
|
||||
include: { recipients: { orderBy: { createdAt: 'asc' } } },
|
||||
});
|
||||
if (!row) {
|
||||
res.status(404).json({ error: 'Não encontrado' });
|
||||
return;
|
||||
}
|
||||
res.json(row);
|
||||
});
|
||||
return router;
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// ROTA DE AVATAR — Proxy de foto de perfil (evita CORS)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Constrói a rota de proxy de avatar.
|
||||
*
|
||||
* Montada em: /api/inbox/avatar/:jid
|
||||
*
|
||||
* O frontend não pode buscar avatares diretamente do CDN do WhatsApp (CORS).
|
||||
* Esta rota age como proxy:
|
||||
* 1. Busca avatarUrl salvo no banco (cache DB)
|
||||
* 2. Se URL da DB expirou, busca via Baileys profilePictureUrl
|
||||
* 3. Faz proxy do buffer (download → response) para evitar redirect/CORS
|
||||
* 4. Salva URL nova no banco para próximas requisições
|
||||
*
|
||||
* Retorna 204 (sem conteúdo) quando avatar não disponível:
|
||||
* - Foto de perfil privada
|
||||
* - JID @lid (não suporta profilePictureUrl)
|
||||
* - Socket não disponível
|
||||
* O frontend trata 204 mostrando iniciais em vez da foto.
|
||||
*
|
||||
* Cache HTTP: max-age=86400 (24h) + stale-while-revalidate=604800 (7d) para
|
||||
* avatares encontrados; 300s para 204. Valida revalidação via ETag.
|
||||
*
|
||||
* Negative cache: quando decide 204, grava `avatar:none:*` no Dragonfly
|
||||
* com TTL de 1h — próximos hits respondem sem tocar no Prisma nem no Baileys.
|
||||
*/
|
||||
function buildAvatarRoute(manager) {
|
||||
const router = (0, express_1.Router)();
|
||||
// Cabeçalho longo com stale-while-revalidate: o browser serve do cache
|
||||
// local por 24h e, entre 24h e 7d, usa o stale enquanto revalida em
|
||||
// background. Corta requisições repetidas na navegação normal.
|
||||
const HIT_CACHE_CONTROL = 'public, max-age=86400, stale-while-revalidate=604800';
|
||||
const MISS_CACHE_CONTROL = 'public, max-age=300';
|
||||
const NO_AVATAR_TTL_SECONDS = 3600; // 1h
|
||||
const noAvatarKey = (tenantId, instanceId, jid) => `avatar:none:${tenantId}:${instanceId}:${jid}`;
|
||||
// ETag baseado no avatarUrl conhecido. É determinístico (hash da string),
|
||||
// logo quando a URL do banco muda — sinal de que a foto mudou — o ETag
|
||||
// também muda e força o browser a buscar bytes novos.
|
||||
const etagFor = (urlOrMarker) => `"${(0, crypto_1.createHash)('md5').update(urlOrMarker).digest('hex')}"`;
|
||||
router.get('/avatar/:jid', async (req, res) => {
|
||||
const jid = req.params['jid'];
|
||||
const instanceId = req.query['instance'];
|
||||
const t0 = Date.now();
|
||||
const tenantId = req.tenantId;
|
||||
// Helper: retorna 204 No Content (não 404) para que o browser não logue
|
||||
// erro no console. O <img onError> do frontend detecta a falta de imagem
|
||||
// e mostra iniciais. Também grava no negative cache para curto-circuitar
|
||||
// as próximas chamadas ao mesmo JID.
|
||||
const noAvatar = (persist = true) => {
|
||||
if (persist && instanceId) {
|
||||
dragonfly_1.dragonfly
|
||||
.set(noAvatarKey(tenantId, instanceId, jid), '1', NO_AVATAR_TTL_SECONDS)
|
||||
.catch(() => { });
|
||||
}
|
||||
res.set('Cache-Control', MISS_CACHE_CONTROL).status(204).end();
|
||||
};
|
||||
if (!instanceId) {
|
||||
res.set('Cache-Control', MISS_CACHE_CONTROL).status(204).end();
|
||||
return;
|
||||
}
|
||||
// 0. Negative cache: se sabemos que não tem avatar há menos de 1h, corta
|
||||
// antes de tocar Prisma/Baileys (o backend está sob pressão de memória).
|
||||
try {
|
||||
if (await dragonfly_1.dragonfly.exists(noAvatarKey(tenantId, instanceId, jid))) {
|
||||
res.set('Cache-Control', MISS_CACHE_CONTROL).status(204).end();
|
||||
logger_1.logger.debug({ jid, ms: Date.now() - t0, source: 'neg_cache' }, '[Avatar] 204 cached');
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Dragonfly indisponível — segue o fluxo normal sem quebrar
|
||||
}
|
||||
// ── Helpers para persistência no Wasabi ────────────────────────────────────
|
||||
// Path determinístico: avatars/{tenantId}/{instanceId}/{jid_safe}.jpg
|
||||
// Não usa timestamp — sobrescreve o arquivo quando o avatar muda.
|
||||
const avatarWasabiPath = (tid, iid, j) => `avatars/${tid}/${iid}/${j.replace(/[^a-zA-Z0-9]/g, '_')}.jpg`;
|
||||
// Faz upload fire-and-forget para Wasabi e atualiza o banco com o path relativo.
|
||||
// Chamado após servir os bytes ao browser para não bloquear a resposta.
|
||||
const persistToWasabi = (buffer) => {
|
||||
if (!StorageProvider_1.storageProvider.isRegistered())
|
||||
return;
|
||||
const wasabiPath = avatarWasabiPath(tenantId, instanceId, jid);
|
||||
StorageProvider_1.storageProvider.uploadFile({
|
||||
category: 'whatsapp',
|
||||
customPath: wasabiPath,
|
||||
file: { originalname: `${jid.replace(/[^a-zA-Z0-9]/g, '_')}.jpg`, buffer, mimetype: 'image/jpeg' },
|
||||
}).then(() => prisma_1.prisma.contact.updateMany({ where: { tenantId, instanceId, jid }, data: { avatarUrl: wasabiPath } })).catch(() => { });
|
||||
};
|
||||
// 1. Tenta avatarUrl já salvo no banco (funciona para todos os tipos de JID)
|
||||
const contact = await prisma_1.prisma.contact.findFirst({
|
||||
where: { tenantId, instanceId, jid },
|
||||
select: { avatarUrl: true },
|
||||
});
|
||||
if (contact?.avatarUrl) {
|
||||
const storedUrl = contact.avatarUrl;
|
||||
const isWasabi = !storedUrl.startsWith('http');
|
||||
const etag = etagFor(storedUrl);
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
res.set('ETag', etag).set('Cache-Control', HIT_CACHE_CONTROL).status(304).end();
|
||||
logger_1.logger.debug({ jid, ms: Date.now() - t0, source: 'etag_304' }, '[Avatar] 304');
|
||||
return;
|
||||
}
|
||||
if (isWasabi) {
|
||||
// Já está no Wasabi — serve direto sem tocar no CDN do WhatsApp
|
||||
try {
|
||||
const buffer = await StorageProvider_1.storageProvider.getBuffer(storedUrl);
|
||||
if (buffer) {
|
||||
res.set('Content-Type', 'image/jpeg')
|
||||
.set('Cache-Control', HIT_CACHE_CONTROL)
|
||||
.set('ETag', etag)
|
||||
.set('Content-Length', String(buffer.length))
|
||||
.send(buffer);
|
||||
logger_1.logger.debug({ jid, ms: Date.now() - t0, source: 'wasabi' }, '[Avatar] served');
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch { /* Wasabi indisponível — cai no fallback Baileys abaixo */ }
|
||||
}
|
||||
else {
|
||||
// CDN URL do WhatsApp — busca bytes e inicia upload para Wasabi
|
||||
try {
|
||||
const upstream = await fetch(storedUrl);
|
||||
if (upstream.ok) {
|
||||
const buffer = Buffer.from(await upstream.arrayBuffer());
|
||||
res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg')
|
||||
.set('Cache-Control', HIT_CACHE_CONTROL)
|
||||
.set('ETag', etag)
|
||||
.set('Content-Length', String(buffer.length))
|
||||
.send(buffer);
|
||||
logger_1.logger.debug({ jid, ms: Date.now() - t0, source: 'db_cache' }, '[Avatar] served');
|
||||
persistToWasabi(buffer);
|
||||
return;
|
||||
}
|
||||
// URL expirou — cai no fallback Baileys abaixo
|
||||
logger_1.logger.debug({ jid, ms: Date.now() - t0 }, '[Avatar] DB URL expirada, fallback Baileys');
|
||||
}
|
||||
catch { /* Erro de rede — cai no fallback Baileys abaixo */ }
|
||||
}
|
||||
}
|
||||
// 2. JIDs @lid não suportam profilePictureUrl no Baileys (retorna 404 no WA)
|
||||
if (jid.endsWith('@lid')) {
|
||||
noAvatar();
|
||||
return;
|
||||
}
|
||||
// 3. Busca URL fresca via Baileys profilePictureUrl
|
||||
const sock = manager.getSocket(instanceId);
|
||||
if (!sock) {
|
||||
noAvatar();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const url = await sock.profilePictureUrl(jid, 'image');
|
||||
if (!url) {
|
||||
noAvatar();
|
||||
return;
|
||||
}
|
||||
const upstream = await fetch(url);
|
||||
if (!upstream.ok) {
|
||||
noAvatar();
|
||||
return;
|
||||
}
|
||||
const buffer = Buffer.from(await upstream.arrayBuffer());
|
||||
res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg')
|
||||
.set('Cache-Control', HIT_CACHE_CONTROL)
|
||||
.set('ETag', etagFor(url))
|
||||
.set('Content-Length', String(buffer.length))
|
||||
.send(buffer);
|
||||
logger_1.logger.debug({ jid, ms: Date.now() - t0, source: 'baileys' }, '[Avatar] served');
|
||||
// Persiste no Wasabi (fire-and-forget) e invalida negative cache
|
||||
persistToWasabi(buffer);
|
||||
dragonfly_1.dragonfly.del(noAvatarKey(tenantId, instanceId, jid)).catch(() => { });
|
||||
}
|
||||
catch {
|
||||
noAvatar();
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
//# sourceMappingURL=whatsapp.send.routes.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,228 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
require("dotenv/config");
|
||||
const http_1 = __importDefault(require("http"));
|
||||
const fs_1 = require("fs");
|
||||
const express_1 = __importDefault(require("express"));
|
||||
const cors_1 = __importDefault(require("cors"));
|
||||
const helmet_1 = __importDefault(require("helmet"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const env_1 = require("./config/env");
|
||||
const logger_1 = require("./config/logger");
|
||||
const prisma_1 = require("./infra/database/prisma");
|
||||
// prisma re-usado inline abaixo (plan/status route)
|
||||
const dragonfly_1 = require("./infra/cache/dragonfly");
|
||||
const socketServer_1 = require("./infra/socket/socketServer");
|
||||
const hook_bus_1 = require("./core/hook-bus");
|
||||
const plugin_config_1 = require("./core/plugin-config");
|
||||
const db_1 = require("./core/db");
|
||||
const plugin_loader_1 = require("./core/plugin-loader");
|
||||
const plugin_registry_1 = require("./core/plugin-registry");
|
||||
const WhatsAppConnectionManager_1 = require("./modules/whatsapp/connection/WhatsAppConnectionManager");
|
||||
const whatsapp_routes_1 = require("./modules/whatsapp/whatsapp.routes");
|
||||
const chat_routes_1 = require("./modules/chats/chat.routes");
|
||||
const whatsapp_send_routes_1 = require("./modules/whatsapp/whatsapp.send.routes");
|
||||
const whatsapp_media_routes_1 = require("./modules/whatsapp/whatsapp.media.routes");
|
||||
const chatbot_routes_1 = require("./modules/chatbot/chatbot.routes");
|
||||
const contact_routes_1 = require("./modules/contacts/contact.routes");
|
||||
const sector_routes_1 = require("./modules/sectors/sector.routes");
|
||||
const apikey_routes_1 = require("./modules/api-keys/apikey.routes");
|
||||
const auth_routes_1 = require("./modules/auth/auth.routes");
|
||||
const admin_routes_1 = require("./modules/auth/admin.routes");
|
||||
const pair_routes_1 = require("./modules/pair/pair.routes");
|
||||
const template_routes_1 = require("./modules/templates/template.routes");
|
||||
const scheduled_routes_1 = require("./modules/scheduled/scheduled.routes");
|
||||
const sticker_routes_1 = require("./modules/stickers/sticker.routes");
|
||||
const StorageProvider_1 = require("./core/StorageProvider");
|
||||
const scheduler_service_1 = require("./modules/scheduled/scheduler.service");
|
||||
const auth_middleware_1 = require("./shared/middlewares/auth.middleware");
|
||||
const admin_middleware_1 = require("./shared/middlewares/admin.middleware");
|
||||
const error_middleware_1 = require("./shared/middlewares/error.middleware");
|
||||
async function bootstrap() {
|
||||
// ── Express ──────────────────────────────────────────────────────────────
|
||||
const app = (0, express_1.default)();
|
||||
app.use((0, helmet_1.default)({ crossOriginResourcePolicy: { policy: 'cross-origin' } }));
|
||||
app.use((0, cors_1.default)({ origin: env_1.env.FRONTEND_URL, credentials: true }));
|
||||
app.use(express_1.default.json({ limit: '10mb' }));
|
||||
// Servir arquivos de mídia baixados (ambos os prefixos: acesso direto e via proxy nginx)
|
||||
const mediaStatic = express_1.default.static(path_1.default.resolve('./media'), { maxAge: '7d' });
|
||||
app.use('/media', mediaStatic);
|
||||
app.use('/api/media', mediaStatic);
|
||||
// ── HTTP Server + Socket.IO ───────────────────────────────────────────────
|
||||
const httpServer = http_1.default.createServer(app);
|
||||
const io = (0, socketServer_1.buildSocketServer)(httpServer);
|
||||
// ── WhatsApp Connection Manager ───────────────────────────────────────────
|
||||
const whatsAppManager = new WhatsAppConnectionManager_1.WhatsAppConnectionManager(io);
|
||||
globalThis.__whatsAppManager = whatsAppManager;
|
||||
// ── Rotas ─────────────────────────────────────────────────────────────────
|
||||
app.use('/api/auth', auth_routes_1.authRouter);
|
||||
app.use('/api/pair', pair_routes_1.pairRouter);
|
||||
// Configurações públicas de branding (sem autenticação — usadas pelo frontend antes do login)
|
||||
app.get('/api/admin/settings/public', async (_req, res) => {
|
||||
try {
|
||||
const raw = await dragonfly_1.dragonfly.getJson('system:settings');
|
||||
const defaults = { systemName: 'NewWhats', logoUrl: null, faviconUrl: null, accentColor: '#3b82f6', maintenance: { enabled: false, message: '' } };
|
||||
const cfg = { ...defaults, ...(raw ?? {}) };
|
||||
res.json({ systemName: cfg.systemName, logoUrl: cfg.logoUrl, faviconUrl: cfg.faviconUrl, accentColor: cfg.accentColor, maintenance: cfg.maintenance });
|
||||
}
|
||||
catch {
|
||||
res.status(500).json({ error: 'Erro' });
|
||||
}
|
||||
});
|
||||
app.use('/api/admin', auth_middleware_1.authMiddleware, admin_middleware_1.adminMiddleware, admin_routes_1.adminRouter);
|
||||
app.use('/api/chats', auth_middleware_1.authMiddleware, (0, chat_routes_1.buildChatRoutes)(whatsAppManager));
|
||||
app.use('/api/instances', auth_middleware_1.authMiddleware, (0, whatsapp_routes_1.buildWhatsAppRoutes)(whatsAppManager));
|
||||
app.use('/api/instances/:instanceId', auth_middleware_1.authMiddleware, (0, whatsapp_send_routes_1.buildSendRoutes)(whatsAppManager));
|
||||
app.use('/api/instances/:instanceId', auth_middleware_1.authMiddleware, (0, whatsapp_media_routes_1.buildMediaRoutes)(whatsAppManager));
|
||||
app.use('/api/chatbot', auth_middleware_1.authMiddleware, (0, chatbot_routes_1.buildChatbotRoutes)());
|
||||
app.use('/api/contacts', auth_middleware_1.authMiddleware, (0, contact_routes_1.buildContactRoutes)());
|
||||
app.use('/api/sectors', auth_middleware_1.authMiddleware, (0, sector_routes_1.buildSectorRoutes)());
|
||||
app.use('/api/api-keys', auth_middleware_1.authMiddleware, (0, apikey_routes_1.buildApiKeyRoutes)());
|
||||
app.use('/api/inbox', auth_middleware_1.authMiddleware, (0, whatsapp_send_routes_1.buildAvatarRoute)(whatsAppManager));
|
||||
// ── Templates e Agendamentos ──────────────────────────────────────────────
|
||||
const scheduler = new scheduler_service_1.SchedulerService(whatsAppManager);
|
||||
app.use('/api/templates', auth_middleware_1.authMiddleware, (0, template_routes_1.buildTemplateRoutes)());
|
||||
app.use('/api/scheduled', auth_middleware_1.authMiddleware, (0, scheduled_routes_1.buildScheduledRoutes)(scheduler));
|
||||
app.use('/api/stickers', auth_middleware_1.authMiddleware, (0, sticker_routes_1.buildStickerRoutes)());
|
||||
// Proxy genérico para arquivos no Wasabi — sem authMiddleware porque <img>/<audio>/<video>
|
||||
// do browser não enviam JWT. Paths são UUID-based (obscuros) — mesmo nível de segurança
|
||||
// que a rota /media/ estática que também não requer autenticação.
|
||||
app.get('/api/storage/view/*', async (req, res) => {
|
||||
const filePath = req.params[0];
|
||||
if (!filePath) {
|
||||
res.status(400).end();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const buffer = await StorageProvider_1.storageProvider.getBuffer(filePath);
|
||||
if (!buffer) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||
const mime = ext === 'webp' ? 'image/webp' :
|
||||
ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' :
|
||||
ext === 'png' ? 'image/png' :
|
||||
ext === 'mp4' ? 'video/mp4' :
|
||||
ext === 'ogg' ? 'audio/ogg' :
|
||||
'application/octet-stream';
|
||||
res.setHeader('Content-Type', mime);
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
res.send(buffer);
|
||||
}
|
||||
catch {
|
||||
res.status(500).end();
|
||||
}
|
||||
});
|
||||
// Banner do frontend — dias restantes do plano
|
||||
app.get('/api/plan/status', auth_middleware_1.authMiddleware, async (req, res) => {
|
||||
const user = await prisma_1.prisma.user.findUnique({
|
||||
where: { id: req.tenantId },
|
||||
select: { trialEndsAt: true, plan: { select: { name: true } } },
|
||||
});
|
||||
const daysRemaining = user?.trialEndsAt
|
||||
? Math.max(0, Math.ceil((new Date(user.trialEndsAt).getTime() - Date.now()) / 86400000))
|
||||
: null;
|
||||
res.json({ daysRemaining, planName: user?.plan?.name ?? 'Trial' });
|
||||
});
|
||||
// Presença: retorna se o próprio usuário está online (chave ainda viva no Dragonfly)
|
||||
// Útil para self-check; para listar agentes online em admin, use a mesma lógica com prefixo
|
||||
app.get('/api/presence/me', auth_middleware_1.authMiddleware, async (req, res) => {
|
||||
const online = await dragonfly_1.dragonfly.exists(`presence:user:${req.tenantId}`);
|
||||
res.json({ online });
|
||||
});
|
||||
// Health check (sem autenticação)
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
// Retorna menu items de plugins ativos — usado pelo frontend após login
|
||||
app.get('/api/plugins/menu', auth_middleware_1.authMiddleware, (_req, res) => {
|
||||
const items = plugin_registry_1.pluginRegistry.getActiveMenuItems();
|
||||
res.json(items);
|
||||
});
|
||||
app.use(error_middleware_1.errorMiddleware);
|
||||
// ── Infra: conectar Dragonfly e Prisma ───────────────────────────────────
|
||||
await dragonfly_1.dragonfly.connect();
|
||||
await prisma_1.prisma.$connect();
|
||||
logger_1.logger.info('PostgreSQL conectado via Prisma');
|
||||
// ── Plugin System ─────────────────────────────────────────────────────────
|
||||
await (0, db_1.ensurePluginTables)(db_1.db);
|
||||
const loadedPlugins = await (0, plugin_loader_1.loadPlugins)();
|
||||
const pluginNames = loadedPlugins.map((p) => p.manifest.name);
|
||||
await plugin_config_1.pluginConfig.loadAll(pluginNames);
|
||||
const pluginCtx = {
|
||||
app,
|
||||
db: db_1.db,
|
||||
prisma: prisma_1.prisma,
|
||||
logger: logger_1.logger,
|
||||
hooks: hook_bus_1.hookBus,
|
||||
config: plugin_config_1.pluginConfig,
|
||||
io,
|
||||
httpServer,
|
||||
};
|
||||
plugin_registry_1.pluginRegistry.setContext(pluginCtx);
|
||||
plugin_registry_1.pluginRegistry.register(loadedPlugins);
|
||||
await plugin_registry_1.pluginRegistry.activateAll();
|
||||
logger_1.logger.info({ count: pluginNames.length }, '🔌 Plugins carregados');
|
||||
// ── Auto-reconectar instâncias ativas após reinício ──────────────────────
|
||||
// Reseta CONNECTING/QR_PENDING para DISCONNECTED antes de reconectar:
|
||||
// impede que a API retorne um status "fantasma" de antes do crash.
|
||||
await prisma_1.prisma.instance.updateMany({
|
||||
where: { status: { in: ['CONNECTING', 'QR_PENDING'] } },
|
||||
data: { status: 'DISCONNECTED' },
|
||||
});
|
||||
// Broadcasts travados em RUNNING (processo morreu durante o loop de envio):
|
||||
// marca como FAILED para não aparecerem eternamente como "Enviando…" no frontend.
|
||||
const stuckCount = await prisma_1.prisma.broadcast.updateMany({
|
||||
where: { status: 'RUNNING' },
|
||||
data: { status: 'FAILED', finishedAt: new Date() },
|
||||
});
|
||||
if (stuckCount.count > 0) {
|
||||
logger_1.logger.warn({ count: stuckCount.count }, '[Broadcast] Jobs RUNNING marcados como FAILED após reinício');
|
||||
}
|
||||
// Reconecta toda instância não-banida que tenha credenciais persistidas
|
||||
// no disco (creds.json). O filesystem é a fonte de verdade para "essa
|
||||
// instância já foi pareada" — o status no DB pode estar DISCONNECTED
|
||||
// temporariamente porque o watchdog matou a conexão ANTES do timer de
|
||||
// reconexão rodar (ex: restart do tsx watch durante o backoff de 3s).
|
||||
// Sem esta lógica, a instância fica órfã até alguém chamar connect()
|
||||
// pela API, causando perda de mensagens durante o hiato.
|
||||
const candidates = await prisma_1.prisma.instance.findMany({
|
||||
where: { status: { notIn: ['BANNED'] } },
|
||||
});
|
||||
for (const inst of candidates) {
|
||||
const credsPath = path_1.default.join(env_1.env.BAILEYS_SESSIONS_PATH, inst.id, 'creds.json');
|
||||
if (!(0, fs_1.existsSync)(credsPath)) {
|
||||
// Nunca foi pareada — pular pra não disparar QR indesejado no boot
|
||||
continue;
|
||||
}
|
||||
logger_1.logger.info({ instanceId: inst.id, previousStatus: inst.status }, 'Reconectando instância após reinício (creds persistidas)');
|
||||
whatsAppManager.connect(inst.id, inst.tenantId).catch((err) => logger_1.logger.error({ err, instanceId: inst.id }, 'Falha ao reconectar instância'));
|
||||
}
|
||||
// ── Scheduler de mensagens ────────────────────────────────────────────────
|
||||
scheduler.start();
|
||||
// ── Iniciar servidor ──────────────────────────────────────────────────────
|
||||
const port = parseInt(env_1.env.PORT, 10);
|
||||
httpServer.listen(port, () => {
|
||||
logger_1.logger.info(`🚀 NewWhats backend rodando em http://localhost:${port}`);
|
||||
logger_1.logger.info(` Socket.IO pronto — frontend: ${env_1.env.FRONTEND_URL}`);
|
||||
});
|
||||
// ── Graceful shutdown ─────────────────────────────────────────────────────
|
||||
const shutdown = async (signal) => {
|
||||
logger_1.logger.info({ signal }, 'Encerrando servidor...');
|
||||
scheduler.stop();
|
||||
httpServer.close();
|
||||
await prisma_1.prisma.$disconnect();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
}
|
||||
bootstrap().catch((err) => {
|
||||
console.error('Falha fatal ao iniciar servidor:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
//# sourceMappingURL=server.js.map
|
||||
File diff suppressed because one or more lines are too long
+15
@@ -0,0 +1,15 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.adminMiddleware = adminMiddleware;
|
||||
/**
|
||||
* Garante que apenas usuários com role ADMIN acessem a rota.
|
||||
* Deve ser usado APÓS o authMiddleware.
|
||||
*/
|
||||
function adminMiddleware(req, res, next) {
|
||||
if (req.userRole !== 'ADMIN') {
|
||||
res.status(403).json({ error: 'Acesso restrito a administradores' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
//# sourceMappingURL=admin.middleware.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"admin.middleware.js","sourceRoot":"","sources":["admin.middleware.ts"],"names":[],"mappings":";;AAMA,0CAMC;AAVD;;;GAGG;AACH,SAAgB,eAAe,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB;IAC7E,IAAI,GAAG,CAAC,QAAQ,KAAK,OAAO,EAAE,CAAC;QAC7B,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,mCAAmC,EAAE,CAAC,CAAA;QACpE,OAAM;IACR,CAAC;IACD,IAAI,EAAE,CAAA;AACR,CAAC"}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.errorMiddleware = errorMiddleware;
|
||||
const logger_1 = require("../../config/logger");
|
||||
function errorMiddleware(err, _req, res, _next) {
|
||||
logger_1.logger.error(err, 'Unhandled error');
|
||||
res.status(500).json({ error: 'Erro interno do servidor' });
|
||||
}
|
||||
//# sourceMappingURL=error.middleware.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"error.middleware.js","sourceRoot":"","sources":["error.middleware.ts"],"names":[],"mappings":";;AAGA,0CAQC;AAVD,gDAA4C;AAE5C,SAAgB,eAAe,CAC7B,GAAU,EACV,IAAa,EACb,GAAa,EACb,KAAmB;IAEnB,eAAM,CAAC,KAAK,CAAC,GAAG,EAAE,iBAAiB,CAAC,CAAA;IACpC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,0BAA0B,EAAE,CAAC,CAAA;AAC7D,CAAC"}
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* chatStore.ts — Store de Inbox: chats e mensagens.
|
||||
* Instâncias foram extraídas para instanceStore.ts.
|
||||
* Usa Zustand + Immer para mutações diretas e legíveis.
|
||||
*/
|
||||
import { create } from 'zustand'
|
||||
import { immer } from 'zustand/middleware/immer'
|
||||
import { socketService } from '../services/socketService'
|
||||
import { chatApi, messageApi } from '../services/chatApiService'
|
||||
|
||||
// ─── Mapper: resposta aninhada do backend → Chat plano do store ────────────────
|
||||
|
||||
function mapBackendChat(raw: any): Chat {
|
||||
return {
|
||||
id: raw.id,
|
||||
tenantId: raw.tenantId ?? '',
|
||||
instanceId: raw.instanceId,
|
||||
jid: raw.jid,
|
||||
name: raw.name ?? null,
|
||||
unreadCount: raw.unreadCount ?? 0,
|
||||
lastMessageAt: raw.lastMessageAt ?? null,
|
||||
createdAt: raw.createdAt ?? null,
|
||||
isPinned: raw.isPinned ?? false,
|
||||
isArchived: raw.isArchived ?? false,
|
||||
contactName: raw.contact?.name ?? raw.contactName ?? null,
|
||||
contactAvatarUrl: raw.contact?.avatarUrl ?? raw.contactAvatarUrl ?? null,
|
||||
contactPhone: raw.contact?.phone ?? raw.contactPhone ?? null,
|
||||
scoreReputacao: raw.contact?.scoreReputacao ?? raw.scoreReputacao ?? 0,
|
||||
flagRestricao: raw.contact?.flagRestricao ?? raw.flagRestricao ?? false,
|
||||
lastMessageBody: raw.lastMessage?.body ?? raw.lastMessageBody ?? null,
|
||||
lastMessageFromMe: raw.lastMessage?.fromMe ?? raw.lastMessageFromMe ?? false,
|
||||
lastMessageStatus: raw.lastMessage?.status ?? raw.lastMessageStatus ?? null,
|
||||
lastMessageType: raw.lastMessage?.type ?? raw.lastMessageType ?? null,
|
||||
lastMessageSender: raw.lastMessage?.pushName ?? raw.lastMessageSender ?? null,
|
||||
protocolNumber: raw.protocol?.number ?? raw.protocolNumber ?? null,
|
||||
botPaused: raw.botPaused ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tipos ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Chat {
|
||||
id: string
|
||||
tenantId: string
|
||||
instanceId: string
|
||||
jid: string
|
||||
name: string | null // subject do grupo; null para individuais
|
||||
unreadCount: number
|
||||
lastMessageAt: string | null
|
||||
createdAt: string | null // quando o chat foi registrado (fallback de ordenação)
|
||||
isPinned: boolean
|
||||
isArchived: boolean
|
||||
contactName: string | null
|
||||
contactAvatarUrl: string | null
|
||||
contactPhone: string | null
|
||||
scoreReputacao: number
|
||||
flagRestricao: boolean
|
||||
lastMessageBody: string | null
|
||||
lastMessageFromMe: boolean
|
||||
lastMessageStatus: string | null
|
||||
lastMessageType: string | null
|
||||
lastMessageSender: string | null // pushName do remetente (grupos: quem enviou a última msg)
|
||||
protocolNumber: string | null
|
||||
botPaused: boolean
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
chatId: string
|
||||
messageId: string
|
||||
fromMe: boolean
|
||||
type: string
|
||||
body: string | null
|
||||
mediaUrl: string | null
|
||||
mimeType: string | null
|
||||
fileName: string | null
|
||||
replyToId: string | null
|
||||
replyTo?: {
|
||||
id: string
|
||||
messageId: string
|
||||
body: string | null
|
||||
fromMe: boolean
|
||||
type: string
|
||||
mediaUrl: string | null
|
||||
} | null
|
||||
status: 'PENDING' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED'
|
||||
timestamp: string
|
||||
pushName?: string | null
|
||||
senderJid?: string | null
|
||||
}
|
||||
|
||||
// ─── Estado ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ChatState {
|
||||
chats: Chat[]
|
||||
chatsLoading: boolean
|
||||
activeChatId: string | null
|
||||
messages: Record<string, Message[]>
|
||||
messagesLoading: boolean
|
||||
|
||||
// Sync
|
||||
setChats: (chats: Chat[]) => void
|
||||
setChatsLoading: (v: boolean) => void
|
||||
upsertChat: (chat: Chat) => void
|
||||
patchChat: (chatId: string, patch: Partial<Chat>) => void
|
||||
setActiveChat: (chatId: string | null) => void
|
||||
clearUnread: (chatId: string) => void
|
||||
setMessages: (chatId: string, msgs: Message[]) => void
|
||||
setMessagesLoading: (v: boolean) => void
|
||||
appendMessage: (msg: Message) => void
|
||||
patchMessage: (msgId: string, patch: Partial<Message>) => void
|
||||
updateMediaReady: (msgId: string, mediaUrl: string) => void
|
||||
|
||||
// Async
|
||||
loadChats: (instanceId: string, opts?: { search?: string; archived?: boolean }) => Promise<void>
|
||||
loadMessages: (chatId: string, opts?: { before?: string }) => Promise<{ hasMore: boolean }>
|
||||
selectChat: (chatId: string) => Promise<void>
|
||||
sendMessage: (instanceId: string, jid: string, text: string, replyToMessageId?: string, mentions?: string[]) => Promise<void>
|
||||
|
||||
// Socket
|
||||
initSocketListeners: () => void
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function chatSortKey(c: Chat): number {
|
||||
// WhatsApp ordena por: timestamp da última msg; se não houver msg, usa hora de criação do chat
|
||||
if (c.lastMessageAt) return new Date(c.lastMessageAt).getTime()
|
||||
if (c.createdAt) return new Date(c.createdAt).getTime()
|
||||
return 0
|
||||
}
|
||||
|
||||
function sortChats(chats: Chat[]): Chat[] {
|
||||
return [...chats].sort((a, b) => {
|
||||
// Fixados sempre primeiro (mesma lógica do WhatsApp oficial)
|
||||
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1
|
||||
return chatSortKey(b) - chatSortKey(a)
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Store ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const useChatStore = create<ChatState>()(
|
||||
immer((set, get) => ({
|
||||
chats: [],
|
||||
chatsLoading: false,
|
||||
activeChatId: null,
|
||||
messages: {},
|
||||
messagesLoading: false,
|
||||
|
||||
// ── Chats ─────────────────────────────────────────────────────────────────
|
||||
|
||||
setChats: (chats) =>
|
||||
set((s) => { s.chats = sortChats(chats) }),
|
||||
|
||||
setChatsLoading: (v) =>
|
||||
set((s) => { s.chatsLoading = v }),
|
||||
|
||||
upsertChat: (chat) =>
|
||||
set((s) => {
|
||||
const idx = s.chats.findIndex((c) => c.id === chat.id)
|
||||
if (idx !== -1) s.chats[idx] = chat
|
||||
else s.chats.unshift(chat)
|
||||
s.chats = sortChats(s.chats as Chat[])
|
||||
}),
|
||||
|
||||
patchChat: (chatId, patch) =>
|
||||
set((s) => {
|
||||
const idx = s.chats.findIndex((c) => c.id === chatId)
|
||||
if (idx !== -1) {
|
||||
Object.assign(s.chats[idx], patch)
|
||||
s.chats = sortChats(s.chats as Chat[])
|
||||
}
|
||||
}),
|
||||
|
||||
setActiveChat: (chatId) =>
|
||||
set((s) => { s.activeChatId = chatId }),
|
||||
|
||||
clearUnread: (chatId) =>
|
||||
set((s) => {
|
||||
const c = s.chats.find((c) => c.id === chatId)
|
||||
if (c) c.unreadCount = 0
|
||||
}),
|
||||
|
||||
// ── Mensagens ─────────────────────────────────────────────────────────────
|
||||
|
||||
setMessages: (chatId, msgs) =>
|
||||
set((s) => { s.messages[chatId] = msgs }),
|
||||
|
||||
setMessagesLoading: (v) =>
|
||||
set((s) => { s.messagesLoading = v }),
|
||||
|
||||
appendMessage: (msg) =>
|
||||
set((s) => {
|
||||
const list = s.messages[msg.chatId]
|
||||
if (!list) { s.messages[msg.chatId] = [msg]; return }
|
||||
const exists = list.some((m) => m.id === msg.id || m.messageId === msg.messageId)
|
||||
if (!exists) list.push(msg)
|
||||
}),
|
||||
|
||||
patchMessage: (msgId, patch) =>
|
||||
set((s) => {
|
||||
for (const list of Object.values(s.messages)) {
|
||||
const idx = list.findIndex((m) => m.id === msgId || m.messageId === msgId)
|
||||
if (idx !== -1) { Object.assign(list[idx], patch); break }
|
||||
}
|
||||
}),
|
||||
|
||||
updateMediaReady: (msgId, mediaUrl) =>
|
||||
set((s) => {
|
||||
for (const list of Object.values(s.messages)) {
|
||||
const msg = list.find((m) => m.id === msgId)
|
||||
if (msg) { msg.mediaUrl = mediaUrl; break }
|
||||
}
|
||||
}),
|
||||
|
||||
// ── Async ─────────────────────────────────────────────────────────────────
|
||||
|
||||
loadChats: async (instanceId, opts = {}) => {
|
||||
set((s) => { s.chatsLoading = true })
|
||||
try {
|
||||
const raw = await chatApi.list(instanceId, opts)
|
||||
const data: Chat[] = (Array.isArray(raw) ? raw : []).map(mapBackendChat)
|
||||
set((s) => { s.chats = sortChats(data); s.chatsLoading = false })
|
||||
} catch {
|
||||
set((s) => { s.chatsLoading = false })
|
||||
}
|
||||
},
|
||||
|
||||
loadMessages: async (chatId, opts = {}) => {
|
||||
console.log('[chatStore] loadMessages chamado:', chatId, opts)
|
||||
set((s) => { s.messagesLoading = true })
|
||||
try {
|
||||
const res = await messageApi.list(chatId, opts)
|
||||
console.log('[chatStore] API retornou:', { hasMore: res.hasMore, count: res.messages?.length, raw: typeof res })
|
||||
const { messages, hasMore } = res
|
||||
if (!Array.isArray(messages)) {
|
||||
console.error('[chatStore] messages NÃO é array!', messages)
|
||||
set((s) => { s.messagesLoading = false })
|
||||
return { hasMore: false }
|
||||
}
|
||||
set((s) => {
|
||||
if (opts.before) {
|
||||
const existing = s.messages[chatId] ?? []
|
||||
const ids = new Set(existing.map((m) => m.id))
|
||||
s.messages[chatId] = [...messages.filter((m: Message) => !ids.has(m.id)), ...existing]
|
||||
} else {
|
||||
s.messages[chatId] = messages
|
||||
}
|
||||
s.messagesLoading = false
|
||||
})
|
||||
console.log('[chatStore] messages armazenadas:', get().messages[chatId]?.length)
|
||||
return { hasMore }
|
||||
} catch (err) {
|
||||
console.error('[chatStore] loadMessages ERRO:', err)
|
||||
set((s) => { s.messagesLoading = false })
|
||||
return { hasMore: false }
|
||||
}
|
||||
},
|
||||
|
||||
selectChat: async (chatId) => {
|
||||
console.log('[chatStore] selectChat:', chatId)
|
||||
const prev = get().activeChatId
|
||||
if (prev && prev !== chatId) socketService.leaveChat(prev)
|
||||
set((s) => { s.activeChatId = chatId })
|
||||
socketService.joinChat(chatId)
|
||||
get().clearUnread(chatId)
|
||||
chatApi.markRead(chatId).catch(() => {})
|
||||
await get().loadMessages(chatId)
|
||||
console.log('[chatStore] selectChat completo. Messages:', get().messages[chatId]?.length)
|
||||
},
|
||||
|
||||
sendMessage: async (instanceId, jid, text, replyToMessageId, mentions) => {
|
||||
const chatId = get().chats.find((c) => c.jid === jid && c.instanceId === instanceId)?.id
|
||||
if (!chatId) return
|
||||
|
||||
// Resolve replyTo a partir das mensagens já no store
|
||||
const replyToMsg = replyToMessageId
|
||||
? get().messages[chatId]?.find((m) => m.messageId === replyToMessageId) ?? null
|
||||
: null
|
||||
|
||||
const optimistic: Message = {
|
||||
id: `opt-${Date.now()}`,
|
||||
chatId,
|
||||
messageId: `opt-${Date.now()}`,
|
||||
fromMe: true,
|
||||
type: 'TEXT',
|
||||
body: text,
|
||||
mediaUrl: null,
|
||||
mimeType: null,
|
||||
fileName: null,
|
||||
replyToId: replyToMsg?.id ?? null,
|
||||
replyTo: replyToMsg
|
||||
? { id: replyToMsg.id, messageId: replyToMsg.messageId, body: replyToMsg.body, fromMe: replyToMsg.fromMe, type: replyToMsg.type, mediaUrl: replyToMsg.mediaUrl }
|
||||
: null,
|
||||
status: 'PENDING',
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
get().appendMessage(optimistic)
|
||||
|
||||
try {
|
||||
const saved = await messageApi.send(instanceId, jid, text, replyToMessageId, mentions)
|
||||
get().patchMessage(optimistic.id, { id: saved.id, messageId: saved.messageId, status: 'SENT' })
|
||||
get().patchChat(chatId, {
|
||||
lastMessageBody: text,
|
||||
lastMessageFromMe: true,
|
||||
lastMessageAt: saved.timestamp,
|
||||
lastMessageStatus: 'SENT',
|
||||
lastMessageType: 'TEXT',
|
||||
})
|
||||
} catch {
|
||||
get().patchMessage(optimistic.id, { status: 'FAILED' })
|
||||
}
|
||||
},
|
||||
|
||||
// ── Socket ────────────────────────────────────────────────────────────────
|
||||
|
||||
initSocketListeners: () => {
|
||||
// IMPORTANTE: sempre usar get() dentro dos callbacks para ler estado atual.
|
||||
// Capturar `const store = get()` fora criaria uma closure stale.
|
||||
|
||||
socketService.on('message:new', (msg: Message) => {
|
||||
console.log('[socket] message:new recebida:', { chatId: msg.chatId, body: msg.body?.slice(0, 40), fromMe: msg.fromMe })
|
||||
get().appendMessage(msg)
|
||||
const current = get()
|
||||
const isActive = current.activeChatId === msg.chatId
|
||||
current.patchChat(msg.chatId, {
|
||||
lastMessageBody: msg.body,
|
||||
lastMessageFromMe: msg.fromMe,
|
||||
lastMessageAt: msg.timestamp,
|
||||
lastMessageStatus: msg.status,
|
||||
lastMessageType: msg.type,
|
||||
lastMessageSender: msg.fromMe ? null : (msg.pushName ?? null),
|
||||
unreadCount: msg.fromMe || isActive
|
||||
? 0
|
||||
: (current.chats.find((c) => c.id === msg.chatId)?.unreadCount ?? 0) + 1,
|
||||
})
|
||||
})
|
||||
|
||||
socketService.on('message:media_ready', ({ messageId, mediaUrl }: { messageId: string; mediaUrl: string }) => {
|
||||
get().updateMediaReady(messageId, mediaUrl)
|
||||
})
|
||||
|
||||
socketService.on('message:status', ({ messageId, status }: { messageId: string; status: Message['status'] }) => {
|
||||
get().patchMessage(messageId, { status })
|
||||
})
|
||||
|
||||
socketService.on('chat:upsert', (raw: any) => {
|
||||
get().upsertChat(mapBackendChat(raw))
|
||||
})
|
||||
|
||||
socketService.on('bot:escalated', ({ chatId }: { chatId: string }) => {
|
||||
get().patchChat(chatId, { botPaused: true })
|
||||
})
|
||||
|
||||
socketService.on('contact:avatar', ({ instanceId, jid, avatarUrl }: { instanceId: string; jid: string; avatarUrl: string }) => {
|
||||
set((s) => {
|
||||
const chat = s.chats.find((c) => c.instanceId === instanceId && c.jid === jid)
|
||||
if (chat) chat.contactAvatarUrl = avatarUrl
|
||||
})
|
||||
})
|
||||
|
||||
// Reconciliação de nomes concluída — re-busca lista de chats
|
||||
socketService.on('chats:refresh', ({ instanceId }: { instanceId: string }) => {
|
||||
const chats = get().chats
|
||||
if (chats.length > 0 && chats[0]?.instanceId === instanceId) {
|
||||
get().loadChats(instanceId)
|
||||
}
|
||||
})
|
||||
|
||||
// Reconciliação via ContactHandler (pós-sync)
|
||||
socketService.on('contacts:reconciled', ({ instanceId }: { instanceId: string }) => {
|
||||
const chats = get().chats
|
||||
if (chats.length > 0 && chats[0]?.instanceId === instanceId) {
|
||||
get().loadChats(instanceId)
|
||||
}
|
||||
})
|
||||
},
|
||||
}))
|
||||
)
|
||||
Reference in New Issue
Block a user