chore(ops): migrate clube67 to newwhats.clube67.com directory
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)

This commit is contained in:
VPS 4 Deploy Agent
2026-05-18 03:26:41 +02:00
parent 298b1c64b0
commit 5ec6bd6354
576 changed files with 29016 additions and 235673 deletions
@@ -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":";;;AAAA,6CAAuD;AAWvD;;;GAGG;AACH,MAAM,cAAc;IAApB;QACU,YAAO,GAAG,IAAI,GAAG,EAAyB,CAAA;IAuGpD,CAAC;IApGC,oEAAoE;IACpE,UAAU,CAAC,GAAkB;QAC3B,IAAI,CAAC,GAAG,GAAG,GAAG,CAAA;IAChB,CAAC;IAED,qDAAqD;IACrD,QAAQ,CAAC,OAAuB;QAC9B,KAAK,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,GAAG,EAAE,IAAI,OAAO,EAAE,CAAC;YAClD,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,EAAE,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,CAAA;QAC7E,CAAC;IACH,CAAC;IAED;8FAC0F;IAC1F,KAAK,CAAC,WAAW;QACf,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;YACzC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,OAAO;gBAAE,SAAQ;YACrC,IAAI,CAAC;gBACH,MAAM,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAA;YAC3B,CAAC;YAAC,OAAO,GAAQ,EAAE,CAAC;gBAClB,eAAU,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,GAAG,CAAC,OAAO,EAAE,EAAE,iDAAiD,CAAC,CAAA;YAChG,CAAC;QACH,CAAC;IACH,CAAC;IAED,KAAK,CAAC,QAAQ,CAAC,IAAY;QACzB,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;QACpC,IAAI,CAAC,KAAK;YAAE,MAAM,IAAI,KAAK,CAAC,WAAW,IAAI,kBAAkB,CAAC,CAAA;QAC9D,IAAI,KAAK,CAAC,MAAM;YAAE,OAAM;QAExB,wBAAwB;QACxB,KAAK,MAAM,GAAG,IAAI,KAAK,CAAC,QAAQ,CAAC,YAAY,EAAE,CAAC;YAC9C,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;YACtC,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,CAAC;gBACtB,MAAM,IAAI,KAAK,CAAC,gBAAgB,GAAG,gBAAgB,IAAI,kBAAkB,CAAC,CAAA;YAC5E,CAAC;QACH,CAAC;QAED,IAAI,CAAC;YACH,MAAM,KAAK,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;YACvC,KAAK,CAAC,MAAM,GAAG,IAAI,CAAA;YACnB,KAAK,CAAC,QAAQ,CAAC,OAAO,GAAG,IAAI,CAAA;YAC7B,eAAU,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,EAAE,iCAAiC,CAAC,CAAA;QAC9D,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,eAAU,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,yCAAyC,CAAC,CAAA;YAC1E,MAAM,GAAG,CAAA;QACX,CAAC;IACH,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,IAAY;QAC3B,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;QACpC,IAAI,CAAC,KAAK;YAAE,MAAM,IAAI,KAAK,CAAC,WAAW,IAAI,kBAAkB,CAAC,CAAA;QAC9D,IAAI,CAAC,KAAK,CAAC,MAAM;YAAE,OAAM;QACzB,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,UAAU;YAAE,MAAM,IAAI,KAAK,CAAC,WAAW,IAAI,2BAA2B,CAAC,CAAA;QAE3F,+CAA+C;QAC/C,KAAK,MAAM,CAAC,OAAO,EAAE,QAAQ,CAAC,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;YAC/C,IAAI,QAAQ,CAAC,MAAM,IAAI,QAAQ,CAAC,QAAQ,CAAC,YAAY,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;gBACrE,MAAM,IAAI,KAAK,CAAC,WAAW,OAAO,iBAAiB,IAAI,yBAAyB,CAAC,CAAA;YACnF,CAAC;QACH,CAAC;QAED,IAAI,CAAC;YACH,MAAM,KAAK,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;YACzC,KAAK,CAAC,MAAM,GAAG,KAAK,CAAA;YACpB,KAAK,CAAC,QAAQ,CAAC,OAAO,GAAG,KAAK,CAAA;YAC9B,eAAU,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,EAAE,oCAAoC,CAAC,CAAA;QACjE,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,eAAU,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,4CAA4C,CAAC,CAAA;YAC7E,MAAM,GAAG,CAAA;QACX,CAAC;IACH,CAAC;IAED,oEAAoE;IACpE,IAAI;QACF,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,GAAG,KAAK,EAAE,CAAC,CAAC,CAAA;IACxF,CAAC;IAED,QAAQ,CAAC,IAAY;QACnB,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;IAC/B,CAAC;IAED;;;OAGG;IACH,KAAK,CAAC,UAAU,CAAC,IAAY;QAC3B,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;QACpC,IAAI,CAAC,KAAK;YAAE,MAAM,IAAI,KAAK,CAAC,WAAW,IAAI,kBAAkB,CAAC,CAAA;QAC9D,KAAK,CAAC,MAAM,GAAG,KAAK,CAAA;QACpB,MAAM,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAA;QACzB,eAAU,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,EAAE,mDAAmD,CAAC,CAAA;IAChF,CAAC;IAED,oDAAoD;IACpD,kBAAkB;QAChB,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;aACrC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC,QAAQ,CAAC,QAAQ,EAAE,SAAS,EAAE,MAAM,CAAC;aACjE,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC,CAAC,QAAQ,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,QAAQ,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAA;IAC9E,CAAC;CACF;AAEY,QAAA,cAAc,GAAG,IAAI,cAAc,EAAE,CAAA"}
@@ -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":";;;;;AAeA,8CA8DC;AA7ED,yCAAoD;AAEpD,gEAA8B;AAC9B,0CAAsC;AACtC,gDAA4C;AAC5C,kDAA8C;AAE9C,kEAAkE;AAClE,MAAM,YAAY,GAAG,EAAE,CAAA;AAOvB,SAAgB,iBAAiB,CAAC,UAAsB;IACtD,MAAM,EAAE,GAAG,IAAI,kBAAc,CAAC,UAAU,EAAE;QACxC,IAAI,EAAE;YACJ,MAAM,EAAE,SAAG,CAAC,YAAY;YACxB,WAAW,EAAE,IAAI;SAClB;QACD,UAAU,EAAE,CAAC,WAAW,EAAE,SAAS,CAAC;KACrC,CAAC,CAAA;IAEF,0CAA0C;IAC1C,EAAE,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,IAAI,EAAE,EAAE;QACtB,MAAM,KAAK,GAAG,MAAM,CAAC,SAAS,CAAC,IAAI,EAAE,KAA2B,CAAA;QAChE,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,IAAI,CAAC,IAAI,KAAK,CAAC,qBAAqB,CAAC,CAAC,CAAA;YACtC,OAAM;QACR,CAAC;QACD,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,sBAAG,CAAC,MAAM,CAAC,KAAK,EAAE,SAAG,CAAC,UAAU,CAAe,CAAA;YAC/D,MAAM,CAAC,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC,GAAG,CAAA;YAChC,MAAM,CAAC,IAAI,CAAC,IAAI,GAAG,OAAO,CAAC,IAAI,CAAA;YAC/B,IAAI,EAAE,CAAA;QACR,CAAC;QAAC,MAAM,CAAC;YACP,IAAI,CAAC,IAAI,KAAK,CAAC,gBAAgB,CAAC,CAAC,CAAA;QACnC,CAAC;IACH,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,EAAE,CAAC,YAAY,EAAE,CAAC,MAAM,EAAE,EAAE;QAC7B,MAAM,MAAM,GAAW,MAAM,CAAC,IAAI,CAAC,MAAM,CAAA;QACzC,eAAM,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,kBAAkB,CAAC,CAAA;QAEjE,sDAAsD;QACtD,MAAM,CAAC,IAAI,CAAC,UAAU,MAAM,EAAE,CAAC,CAAA;QAE/B,oDAAoD;QACpD,qBAAS,CAAC,GAAG,CAAC,iBAAiB,MAAM,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,YAAY,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAA;QAEjF,gEAAgE;QAChE,MAAM,CAAC,EAAE,CAAC,eAAe,EAAE,CAAC,UAAkB,EAAE,EAAE;YAChD,MAAM,CAAC,IAAI,CAAC,YAAY,UAAU,EAAE,CAAC,CAAA;QACvC,CAAC,CAAC,CAAA;QAEF,MAAM,CAAC,EAAE,CAAC,WAAW,EAAE,CAAC,MAAc,EAAE,EAAE;YACxC,MAAM,CAAC,IAAI,CAAC,QAAQ,MAAM,EAAE,CAAC,CAAA;QAC/B,CAAC,CAAC,CAAA;QAEF,MAAM,CAAC,EAAE,CAAC,YAAY,EAAE,CAAC,MAAc,EAAE,EAAE;YACzC,MAAM,CAAC,KAAK,CAAC,QAAQ,MAAM,EAAE,CAAC,CAAA;QAChC,CAAC,CAAC,CAAA;QAEF,4DAA4D;QAC5D,MAAM,CAAC,EAAE,CAAC,gBAAgB,EAAE,GAAG,EAAE;YAC/B,qBAAS,CAAC,GAAG,CAAC,iBAAiB,MAAM,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,YAAY,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAA;QACnF,CAAC,CAAC,CAAA;QAEF,MAAM,CAAC,EAAE,CAAC,YAAY,EAAE,CAAC,MAAM,EAAE,EAAE;YACjC,eAAM,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,qBAAqB,CAAC,CAAA;YACvD,+CAA+C;YAC/C,qBAAS,CAAC,GAAG,CAAC,iBAAiB,MAAM,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAA;QAC1D,CAAC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;IAEF,OAAO,EAAE,CAAA;AACX,CAAC"}
@@ -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"}
@@ -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
@@ -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"}
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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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"}
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
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":";;AAWA,gDAuFC;AAlGD;;;;;;GAMG;AACH,qCAA6D;AAC7D,mCAAmC;AACnC,wDAAoD;AAEpD,SAAgB,kBAAkB;IAChC,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAA;IAEvB,6EAA6E;IAC7E,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;QACpD,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,GAAG,CAAC,QAAS,CAAA;YAE9B,MAAM,QAAQ,GAAG,MAAM,eAAM,CAAC,OAAO,CAAC,QAAQ,CAAC;gBAC7C,KAAK,EAAE,EAAE,QAAQ,EAAE;gBACnB,OAAO,EAAE,CAAC,EAAE,UAAU,EAAE,MAAM,EAAE,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE,CAAC;gBACzD,MAAM,EAAE;oBACN,EAAE,EAAU,IAAI;oBAChB,MAAM,EAAM,IAAI;oBAChB,UAAU,EAAE,IAAI;oBAChB,UAAU,EAAE,IAAI;oBAChB,UAAU,EAAE,IAAI;oBAChB,QAAQ,EAAI,IAAI;oBAChB,UAAU,EAAE,IAAI;iBACjB;aACF,CAAC,CAAA;YAEF,MAAM,aAAa,GAAG,QAAQ,CAAC,MAAM,KAAK,CAAC;gBACzC,CAAC,CAAC,OAAO;gBACT,CAAC,CAAC,GAAG,QAAQ,CAAC,MAAM,IAAI,QAAQ,CAAC,CAAC,CAAE,CAAC,MAAM,IAAI,QAAQ,CAAC,CAAC,CAAE,CAAC,UAAU,CAAC,OAAO,EAAE,IAAI,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,MAAM,EAAE,CAAA;YACjI,MAAM,WAAW,GAAG,IAAA,mBAAU,EAAC,KAAK,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA;YAErF,GAAG,CAAC,IAAI,CAAC,EAAE,WAAW,EAAE,QAAQ,EAAE,CAAC,CAAA;QACrC,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,yBAAyB,EAAE,CAAC,CAAA;QAC5D,CAAC;IACH,CAAC,CAAC,CAAA;IAEF,6EAA6E;IAC7E,MAAM,CAAC,GAAG,CAAC,SAAS,EAAE,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;QAC1D,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,GAAG,CAAC,QAAS,CAAA;YAE9B,MAAM,QAAQ,GAAG,MAAM,eAAM,CAAC,OAAO,CAAC,QAAQ,CAAC;gBAC7C,KAAK,EAAE,EAAE,QAAQ,EAAE;gBACnB,OAAO,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE;gBAC/B,IAAI,EAAE,EAAE;gBACR,MAAM,EAAE;oBACN,EAAE,EAAU,IAAI;oBAChB,MAAM,EAAM,IAAI;oBAChB,UAAU,EAAE,IAAI;oBAChB,UAAU,EAAE,IAAI;oBAChB,UAAU,EAAE,IAAI;iBACjB;aACF,CAAC,CAAA;YAEF,GAAG,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAA;QACxB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,kCAAkC,EAAE,CAAC,CAAA;QACrE,CAAC;IACH,CAAC,CAAC,CAAA;IAEF,6EAA6E;IAC7E,MAAM,CAAC,KAAK,CAAC,eAAe,EAAE,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;QAClE,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,GAAG,CAAC,QAAS,CAAA;YAC9B,MAAM,OAAO,GAAG,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;YAChC,MAAM,EAAE,GAAG,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAE,CAAC,CAAC,CAAC,OAAO,CAAA;YAEzD,MAAM,OAAO,GAAG,MAAM,eAAM,CAAC,OAAO,CAAC,SAAS,CAAC;gBAC7C,KAAK,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE;gBACvB,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE;aACvC,CAAC,CAAA;YAEF,IAAI,CAAC,OAAO,EAAE,CAAC;gBACb,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,wBAAwB,EAAE,CAAC,CAAA;gBACzD,OAAM;YACR,CAAC;YAED,MAAM,OAAO,GAAG,MAAM,eAAM,CAAC,OAAO,CAAC,MAAM,CAAC;gBAC1C,KAAK,EAAE,EAAE,EAAE,EAAE,OAAO,CAAC,EAAE,EAAE;gBACzB,IAAI,EAAG,EAAE,UAAU,EAAE,CAAC,OAAO,CAAC,UAAU,EAAE;gBAC1C,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE;aACvC,CAAC,CAAA;YAEF,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;QACnB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,4BAA4B,EAAE,CAAC,CAAA;QAC/D,CAAC;IACH,CAAC,CAAC,CAAA;IAEF,OAAO,MAAM,CAAA;AACf,CAAC"}
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
@@ -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 (15 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
@@ -0,0 +1 @@
{"version":3,"file":"WhatsAppWarmer.js","sourceRoot":"","sources":["WhatsAppWarmer.ts"],"names":[],"mappings":"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"}
@@ -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
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
File diff suppressed because one or more lines are too long
@@ -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
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
@@ -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
@@ -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
@@ -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)
}
})
},
}))
)