feat: restaura o projeto original do clube67.com (Next.js + Express API + MySQL + Redis) livre de gambiarras e dockerizado

This commit is contained in:
VPS 4 Builder
2026-05-18 20:57:05 +02:00
parent cb3a71c082
commit b4b0ad0a53
244 changed files with 28512 additions and 16680 deletions
+22 -15
View File
@@ -1,28 +1,35 @@
# Node modules
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Builds e Compilados
dist/
build/
out/
# Next.js
.next/
out/
# Variáveis de ambiente sensíveis
# TypeScript
dist/
*.tsbuildinfo
# Env / local configs
.env
.env.production
.env.local
.env.development
.env.development.local
.env.test.local
.env.production.local
.env.sync.local
# Logs e Dados de Estado locais
*.log
# Logs
logs/
*.log
# Uploads locais de mídia
# Uploads / storage
uploads/
storage/db/
storage/sessions/
# Sistema
# OS Files
.DS_Store
Thumbs.db
# Legado / Projeto NewWhats (Migrado e Isolado)
newwhats.local/
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
open_basedir=/www/wwwroot/clube67.com/:/tmp/
+74
View File
File diff suppressed because one or more lines are too long
+74
View File
File diff suppressed because one or more lines are too long
@@ -1,214 +0,0 @@
"use strict";
// ============================================================
// Clube67 — Unified Storage Provider (Wasabi)
// ============================================================
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const aws_sdk_1 = __importDefault(require("aws-sdk"));
const StorageProvider_1 = require("../../backend/src/core/StorageProvider");
const plugin_config_1 = require("../../backend/src/core/plugin-config");
const VALID_CATEGORIES = ['cdi', 'xrays', 'documents', 'exports', 'posts', 'cards', 'partners', 'whatsapp', 'media'];
class UnifiedStorageProviderPlugin {
constructor() {
this.s3 = null;
this.bucket = '';
}
/** Activation logic */
async activate(ctx) {
const storedConfig = plugin_config_1.pluginConfig.get('UnifiedStorageProvider') || {};
const accessKey = storedConfig.wasabiAccessKey || process.env.WASABI_ACCESS_KEY;
const secretKey = storedConfig.wasabiSecretKey || process.env.WASABI_SECRET_KEY;
const region = storedConfig.wasabiRegion || process.env.WASABI_REGION || 'us-east-1';
this.bucket = storedConfig.wasabiBucket || process.env.WASABI_BUCKET || '';
if (!accessKey || !secretKey || !this.bucket) {
ctx.logger.error('Wasabi credentials or bucket missing in .env or plugin config');
return;
}
aws_sdk_1.default.config.update({
accessKeyId: accessKey,
secretAccessKey: secretKey,
region: region,
});
this.s3 = new aws_sdk_1.default.S3({
endpoint: 'https://s3.wasabisys.com',
s3ForcePathStyle: true, // Recommended for Wasabi/Minio
});
// Register this plugin as the central storage provider
StorageProvider_1.storageProvider.register(this);
ctx.logger.info('UnifiedStorageProvider activated and registered in core');
// Add route to fetch buckets (for admin config)
ctx.app.post('/api/storage/fetch-buckets', async (req, res) => {
console.log(`[UnifiedStorageProvider] POST /api/storage/fetch-buckets - Body: ${JSON.stringify(req.body)}`);
const { accessKey, secretKey } = req.body;
if (!accessKey || !secretKey) {
return res.status(400).json({ error: 'Access Key e Secret Key são obrigatórios.' });
}
try {
const s3 = new aws_sdk_1.default.S3({
accessKeyId: accessKey,
secretAccessKey: secretKey,
endpoint: 'https://s3.wasabisys.com',
s3ForcePathStyle: true,
});
const data = await s3.listBuckets().promise();
const buckets = data.Buckets?.map(b => b.Name).filter(Boolean) || [];
console.log(`[UnifiedStorageProvider] Found ${buckets.length} buckets`);
res.json({ buckets });
}
catch (err) {
console.error('[UnifiedStorageProvider] Fetch buckets failed:', err);
res.status(500).json({ error: `Erro ao buscar buckets: ${err.message}` });
}
});
// Add route to create bucket
ctx.app.post('/api/storage/create-bucket', async (req, res) => {
console.log(`[UnifiedStorageProvider] POST /api/storage/create-bucket - Body: ${JSON.stringify(req.body)}`);
const { accessKey, secretKey, bucketName, region } = req.body;
if (!accessKey || !secretKey || !bucketName) {
return res.status(400).json({ error: 'Parâmetros incompletos para criação de bucket.' });
}
try {
const s3 = new aws_sdk_1.default.S3({
accessKeyId: accessKey,
secretAccessKey: secretKey,
endpoint: 'https://s3.wasabisys.com',
s3ForcePathStyle: true,
region: region || 'us-east-1'
});
await s3.createBucket({ Bucket: bucketName }).promise();
console.log(`[UnifiedStorageProvider] Bucket ${bucketName} created successfully`);
res.json({ success: true, message: `Bucket ${bucketName} criado com sucesso.` });
}
catch (err) {
console.error('[UnifiedStorageProvider] Create bucket failed:', err);
res.status(500).json({ error: `Erro ao criar bucket: ${err.message}` });
}
});
// Add route to list files (for search tab)
ctx.app.post('/api/storage/list-files', async (req, res) => {
console.log(`[UnifiedStorageProvider] POST /api/storage/list-files - Body: ${JSON.stringify(req.body)}`);
const { accessKey, secretKey, bucket, prefix } = req.body;
if (!accessKey || !secretKey || !bucket) {
return res.status(400).json({ error: 'Parâmetros incompletos.' });
}
try {
const s3 = new aws_sdk_1.default.S3({
accessKeyId: accessKey,
secretAccessKey: secretKey,
endpoint: 'https://s3.wasabisys.com',
s3ForcePathStyle: true,
});
const data = await s3.listObjectsV2({
Bucket: bucket,
Prefix: prefix || '',
MaxKeys: 20
}).promise();
const files = data.Contents?.map(item => ({
key: item.Key,
size: item.Size,
lastModified: item.LastModified
})) || [];
console.log(`[UnifiedStorageProvider] Found ${files.length} files in bucket ${bucket}`);
res.json({ files });
}
catch (err) {
console.error('[UnifiedStorageProvider] List files failed:', err);
res.status(500).json({ error: `Erro ao listar arquivos: ${err.message}` });
}
});
// Start periodic health check (every 30 seconds)
setInterval(() => {
StorageProvider_1.storageProvider.updateHealth();
}, 30000);
}
/** Deactivation logic */
async deactivate(ctx) {
ctx.logger.info('UnifiedStorageProvider deactivated');
}
/** Health Check Implementation */
async checkHealth() {
if (!this.s3 || !this.bucket)
return false;
try {
// headBucket is a cheap way to verify connectivity and credentials
await this.s3.headBucket({ Bucket: this.bucket }).promise();
return true;
}
catch (err) {
console.error('[UnifiedStorageProvider] Health check failed:', err);
return false;
}
}
/** Centralized Upload Logic */
async upload(payload) {
if (!this.s3) {
throw new Error('[UnifiedStorageProvider] S3 client not initialized. Plugin error?');
}
const storedConfig = plugin_config_1.pluginConfig.get('UnifiedStorageProvider') || {};
const { clinicId, patientId, partnerId, benefitId, postId, whatsappId, userId, category, file } = payload;
if (!VALID_CATEGORIES.includes(category)) {
throw new Error(`Categoria inválida: ${category}`);
}
const timestamp = Date.now();
const safeFilename = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
let path = '';
// Routing logic based on category
switch (category) {
case 'posts':
path = `posts/${timestamp}-${safeFilename}`;
break;
case 'cards':
path = `benefits/${benefitId || 'unclassified'}/${timestamp}-${safeFilename}`;
break;
case 'partners':
path = `partners/${partnerId || 'unclassified'}/${timestamp}-${safeFilename}`;
break;
case 'whatsapp':
path = `whatsapp/${timestamp}-${safeFilename}`;
break;
case 'media':
path = `media/${timestamp}-${safeFilename}`;
break;
// Legacy/Clinical Clinical logic
case 'cdi':
case 'xrays':
case 'documents':
case 'exports':
if (!clinicId || !patientId)
throw new Error('clinicId/patientId obrigatórios para dados clínicos');
path = `clinics/${clinicId}/patients/${patientId}/${category}/${timestamp}-${safeFilename}`;
break;
default:
path = `${category}/${timestamp}-${safeFilename}`;
}
// Apply USER separation if userId is present (for general categories)
if (userId && !['cdi', 'xrays', 'documents', 'exports'].includes(category)) {
path = `users/${userId}/${path}`;
}
// Determine destination bucket (Mapping Overrides)
// category_bucket_whatsapp, category_bucket_posts, etc.
const mappedBucket = storedConfig[`category_bucket_${category}`] || this.bucket;
const params = {
Bucket: mappedBucket,
Key: path,
Body: file.buffer,
ContentType: file.mimetype,
};
try {
await this.s3.upload(params).promise();
return {
provider: 'wasabi',
path,
};
}
catch (err) {
console.error('[UnifiedStorageProvider] Upload failed:', err);
throw new Error(`Upload storage error: ${err.message}`);
}
}
}
const instance = new UnifiedStorageProviderPlugin();
exports.default = instance;
//# sourceMappingURL=index.js.map
-30
View File
@@ -1,30 +0,0 @@
import { db } from './src/config/database';
async function check() {
try {
const id = 1; // Assuming ID 1 exists from previous run
console.log('Testing update on ID:', id);
// 1. Update to TRUE
console.log('Updating hide_partner_name to TRUE...');
await db('benefits').where({ id }).update({ hide_partner_name: true });
let row = await db('benefits').where({ id }).first();
console.log('After TRUE update:', row.hide_partner_name, typeof row.hide_partner_name);
// 2. Update to FALSE
console.log('Updating hide_partner_name to FALSE...');
await db('benefits').where({ id }).update({ hide_partner_name: false });
row = await db('benefits').where({ id }).first();
console.log('After FALSE update:', row.hide_partner_name, typeof row.hide_partner_name);
process.exit(0);
} catch (e) {
console.error('Error:', e);
process.exit(1);
}
}
check();
-23
View File
@@ -1,23 +0,0 @@
import { db } from './src/config/database';
async function check() {
try {
const id = 1;
console.log('Testing update on ID:', id);
// 1. Update to TRUE
console.log('Updating hide_partner_name to TRUE...');
await db('benefits').where({ id }).update({ hide_partner_name: true });
let row = await db('benefits').where({ id }).first();
console.log('After TRUE update:', row.hide_partner_name, typeof row.hide_partner_name);
process.exit(0);
} catch (e) {
console.error('Error:', e);
process.exit(1);
}
}
check();
-20
View File
@@ -1,20 +0,0 @@
import { db } from './src/config/database';
async function checkUser() {
try {
const user = await db('users').where({ email: 'ruibto@gmail.com' }).first();
console.log('User Record:', user);
if (user) {
const partner = await db('partners').where({ owner_user_id: user.id }).first();
console.log('Partner Record:', partner);
}
process.exit(0);
} catch (error) {
console.error(error);
process.exit(1);
}
}
checkUser();
-22
View File
@@ -1,22 +0,0 @@
import { db } from './src/config/database';
async function fixUser() {
try {
// Hardcoded fix for ruibto@gmail.com based on previous check
const userEmail = 'ruibto@gmail.com';
const partnerId = 4; // Consultt Clinic
await db('users')
.where({ email: userEmail })
.update({ partner_id: partnerId });
console.log(`Updated user ${userEmail} with partner_id ${partnerId}`);
process.exit(0);
} catch (error) {
console.error(error);
process.exit(1);
}
}
fixUser();
-24
View File
@@ -1,24 +0,0 @@
import dotenv from 'dotenv';
import path from 'path';
// Enforce loading the .env from the knexfile directory (project root)
dotenv.config({ path: path.join(__dirname, '.env') });
const config = {
client: 'pg',
connection: {
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '5432', 10),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
},
pool: { min: 2, max: 10 },
migrations: {
directory: './src/database/migrations',
tableName: 'knex_migrations'
}
};
export default config;
module.exports = config;
+3225 -5591
View File
File diff suppressed because it is too large Load Diff
+54 -51
View File
@@ -1,53 +1,56 @@
{
"name": "clube67-backend",
"version": "1.0.0",
"main": "../dist/backend/src/index.js",
"scripts": {
"start": "node dist/backend/src/index.js",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "node node_modules/typescript/bin/tsc -p tsconfig.json && cp -r dist/backend/src/infra dist/ 2>/dev/null && cp -r dist/backend/src/modules dist/ 2>/dev/null && cp -r dist/backend/src/config dist/ 2>/dev/null || true"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.996.0",
"@aws-sdk/lib-storage": "^3.996.0",
"@aws-sdk/s3-request-presigner": "^3.996.0",
"@opentelemetry/api": "^1.9.0",
"@types/qrcode": "^1.5.6",
"aws-sdk": "^2.1693.0",
"baileys": "^7.0.0-rc.9",
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "^7.2.0",
"helmet": "^7.1.0",
"hpp": "^0.2.3",
"ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.3",
"knex": "^3.1.0",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.18.0",
"openai": "^6.22.0",
"pg": "^8.11.5",
"protobufjs": "^8.0.0",
"qrcode": "^1.5.4",
"socket.io": "^4.7.5",
"uuid": "^9.0.1",
"winston": "^3.13.0",
"winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/multer": "^1.4.11",
"@types/node": "^20.12.7",
"@types/pg": "^8.11.5",
"@types/uuid": "^9.0.8",
"ts-node-dev": "^2.0.0",
"typescript": "^5.4.5"
}
"name": "clube67-backend",
"version": "1.0.0",
"description": "Clube de Benefícios - Backend API",
"main": "dist/index.js",
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"migrate": "knex migrate:latest --knexfile src/config/knexfile.ts",
"migrate:rollback": "knex migrate:rollback --knexfile src/config/knexfile.ts",
"seed": "knex seed:run --knexfile src/config/knexfile.ts",
"lint": "eslint src --ext .ts"
},
"dependencies": {
"express": "^4.21.0",
"mysql2": "^3.11.0",
"knex": "^3.1.0",
"jsonwebtoken": "^9.0.2",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"helmet": "^7.1.0",
"express-rate-limit": "^7.4.0",
"multer": "^1.4.5-lts.1",
"dotenv": "^16.4.5",
"winston": "^3.14.0",
"ioredis": "^5.4.1",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"express-validator": "^7.2.0",
"xss-clean": "^0.1.4",
"hpp": "^0.2.3",
"compression": "^1.7.4",
"socket.io": "^4.7.5",
"uuid": "^10.0.0",
"qrcode": "^1.5.4"
},
"devDependencies": {
"typescript": "^5.5.0",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/multer": "^1.4.12",
"@types/passport": "^1.0.16",
"@types/passport-google-oauth20": "^2.0.16",
"@types/hpp": "^0.2.6",
"@types/compression": "^1.7.5",
"@types/uuid": "^10.0.0",
"@types/qrcode": "^1.5.5",
"@types/node": "^22.0.0"
}
}
-3
View File
@@ -1,3 +0,0 @@
PORT=8787
API_KEY=ACFH4RFOTME4RU50R4FKGNW34LDFG8DSQ
AUTH_FOLDER=auth
-6
View File
@@ -1,6 +0,0 @@
node_modules/
dist/
auth/
.env
*.log
.DS_Store
-115
View File
@@ -1,115 +0,0 @@
# rscara
API em **TypeScript** para múltiplas contas WhatsApp usando [InfiniteAPI](https://github.com/rsalcara/InfiniteAPI) (fork do Baileys), com geração de QR code, gerenciamento de conexões e disparo de componentes especiais (botões, listas, carrossel, enquete). Inclui interface web para conectar números e enviar mensagens.
## Requisitos
- Node.js >= 20
- npm ou yarn
## Instalação
```bash
npm install
```
## Configuração
Copie o arquivo de exemplo e ajuste:
```bash
cp .env.example .env
```
Variáveis em `.env`:
| Variável | Descrição | Padrão |
|-------------|------------------------|--------|
| `PORT` | Porta do servidor | 8787 |
| `API_KEY` | Chave para header `x-api-key` (deixe vazio para desativar) | - |
| `AUTH_FOLDER` | Pasta onde salvar credenciais por instância | auth |
## Desenvolvimento
```bash
npm run dev
```
## Build e produção
```bash
npm run build
npm start
```
## Interface web
Com o servidor rodando, acesse **http://localhost:8787**. A página inicial e os arquivos estáticos não exigem API key; apenas as rotas `/v1/*` usam o header `x-api-key` quando `API_KEY` está definida.
- **Conexões**: listar conexões salvas (clique em Conectar), conectar por nome, ver QR (atualizado automaticamente), listar instâncias ativas com ações (Desconectar, Novo QR, Deletar). Status e QR são atualizados a cada 2 segundos enquanto a aba estiver aberta.
- **Disparos**: escolher instância, colar **lista de mailing** (um número por linha, com DDI), definir **intervalo mínimo e máximo** (em segundos) entre cada envio, escolher tipo de mensagem e preencher os campos (formulários dinâmicos com adicionar/remover). O envio é feito em lote com espera aleatória entre os números.
## Endpoints
O header **`x-api-key`** é obrigatório apenas nas rotas `/v1/*` quando `API_KEY` está definida. A rota `/health` e a interface em `/` não exigem key.
### Instâncias
| Método | Rota | Descrição |
|--------|------|-----------|
| POST | `/v1/instances` | Cria/conecta instância e retorna QR (body: `{ "instance": "main" }`) |
| GET | `/v1/instances` | Lista instâncias ativas e nomes das conexões salvas (`saved`) |
| GET | `/v1/instances/saved` | Lista apenas nomes das conexões salvas (pastas em `auth/`) |
| GET | `/v1/instances/:name` | Status de uma instância |
| GET | `/v1/instances/:name/qr` | QR em base64 (quando status = qr) |
| POST | `/v1/instances/:name/disconnect` | Desconecta e remove da memória (credenciais ficam em disco) |
| POST | `/v1/instances/:name/logout` | Logout e apaga sessão em disco (próxima conexão gera novo QR) |
| DELETE | `/v1/instances/:name` | Remove instância da memória (fecha socket) |
### Mensagens (componentes especiais)
| Método | Rota | Descrição |
|--------|------|-----------|
| POST | `/v1/messages/send_menu` | Menu texto (opções numeradas) |
| POST | `/v1/messages/send_buttons_helpers` | Botões quick reply (até 3) |
| POST | `/v1/messages/send_interactive_helpers` | Botões CTA (URL, Copiar, Ligar) |
| POST | `/v1/messages/send_list_helpers` | Lista dropdown (nativeList) |
| POST | `/v1/messages/send_poll` | Enquete |
| POST | `/v1/messages/send_carousel_helpers` | Carrossel com cards (imagem + botões) |
Em todos os endpoints de mensagens o body deve incluir **`instance`** (nome da instância) e **`to`** (número no formato `5511999999999`).
## Exemplo rápido
1. Subir a API e abrir a interface em http://localhost:8787 (ou criar instância via API):
```bash
curl -X POST http://localhost:8787/v1/instances \
-H "Content-Type: application/json" \
-H "x-api-key: SUA_API_KEY" \
-d '{"instance": "main"}'
```
2. A resposta pode trazer `qr` em base64. Exiba a imagem ou use `GET /v1/instances/main/qr` até conectar. Na interface, o QR e o status são atualizados automaticamente.
3. Enviar botões:
```bash
curl -X POST http://localhost:8787/v1/messages/send_buttons_helpers \
-H "Content-Type: application/json" \
-H "x-api-key: SUA_API_KEY" \
-d '{
"instance": "main",
"to": "553598828503",
"text": "Como posso ajudar?",
"footer": "Atendimento 24h",
"buttons": [
{"id": "vendas", "text": "Fazer Pedido"},
{"id": "suporte", "text": "Suporte"}
]
}'
```
## Licença
MIT.
File diff suppressed because it is too large Load Diff
-28
View File
@@ -1,28 +0,0 @@
{
"name": "rsalcara",
"version": "1.0.0",
"description": "API Node.js para múltiplas contas WhatsApp com InfiniteAPI (Baileys) e disparo de componentes especiais",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts"
},
"engines": {
"node": ">=20.0.0"
},
"dependencies": {
"express": "^4.21.0",
"qrcode": "^1.5.4",
"dotenv": "^16.4.5",
"baileys": "github:rsalcara/InfiniteAPI"
},
"devDependencies": {
"typescript": "^5.6.0",
"tsx": "^4.19.0",
"@types/node": "^22.0.0",
"@types/express": "^4.17.21",
"@types/qrcode": "^1.5.5"
}
}
-628
View File
@@ -1,628 +0,0 @@
(function () {
const API = '';
function headers() {
const h = { 'Content-Type': 'application/json' };
const key = document.getElementById('apiKey').value.trim() || localStorage.getItem('rscara_api_key');
if (key) {
h['x-api-key'] = key;
localStorage.setItem('rscara_api_key', key);
}
return h;
}
function show(el, visible) {
el.classList.toggle('hidden', !visible);
}
// Tabs
document.querySelectorAll('.tab').forEach((tab) => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
document.querySelectorAll('.panel').forEach((p) => p.classList.remove('active'));
tab.classList.add('active');
document.getElementById(tab.getAttribute('data-tab')).classList.add('active');
});
});
// Tipo de disparo
const dispatchForms = {
menu: document.getElementById('formMenu'),
buttons: document.getElementById('formButtons'),
interactive: document.getElementById('formInteractive'),
list: document.getElementById('formList'),
poll: document.getElementById('formPoll'),
carousel: document.getElementById('formCarousel'),
};
document.getElementById('dispatchType').addEventListener('change', () => {
const type = document.getElementById('dispatchType').value;
Object.values(dispatchForms).forEach((f) => f && f.classList.add('hidden'));
if (dispatchForms[type]) dispatchForms[type].classList.remove('hidden');
if (type === 'list' && !document.getElementById('listSectionsList').querySelector('.block-section')) addListSection();
if (type === 'carousel' && !document.getElementById('carouselCardsList').querySelector('.block-section')) addCarouselCard();
});
dispatchForms.menu.classList.remove('hidden');
// Instância que estamos conectando (para atualizar QR e status em tempo real)
let connectingInstanceName = null;
// --- Conexões: listar salvas e conectar ao clicar ---
function renderSavedList(saved) {
const ul = document.getElementById('savedList');
if (!saved || saved.length === 0) {
ul.innerHTML = '<li class="text-muted">Nenhuma conexão salva. Conecte uma vez por nome e ela aparecerá aqui.</li>';
return;
}
ul.innerHTML = saved
.map(
(name) =>
`<li class="saved-item-row">
<span class="instance-name">${name}</span>
<div class="saved-item-actions">
<button type="button" class="btn btn-primary btn-connect-saved" data-connect-name="${name}">Conectar</button>
<button type="button" class="btn btn-small btn-danger" data-delete-saved-name="${name}" title="Excluir sessão salva (será necessário novo QR para conectar)">Deletar</button>
</div>
</li>`
)
.join('');
ul.querySelectorAll('[data-connect-name]').forEach((btn) => {
btn.addEventListener('click', () => {
const name = btn.getAttribute('data-connect-name');
document.getElementById('connectInstanceSelect').value = name;
document.getElementById('instanceName').value = name;
connectNewNameRow.style.display = 'none';
doConnect(name);
});
});
ul.querySelectorAll('[data-delete-saved-name]').forEach((btn) => {
btn.addEventListener('click', async () => {
const name = btn.getAttribute('data-delete-saved-name');
if (!name || !confirm(`Excluir a conexão salva "${name}"? Será necessário escanear o QR de novo para conectar.`)) return;
try {
const res = await fetch(`${API}/v1/instances/${encodeURIComponent(name)}/logout`, {
method: 'POST',
headers: headers(),
});
const data = await res.json();
if (data.ok) refreshInstanceList();
} catch (_) {
refreshInstanceList();
}
});
});
}
async function doConnect(name) {
connectingInstanceName = name;
const statusEl = document.getElementById('connectStatus');
const qrContainer = document.getElementById('qrContainer');
const qrImage = document.getElementById('qrImage');
show(statusEl, false);
try {
const res = await fetch(`${API}/v1/instances`, {
method: 'POST',
headers: headers(),
body: JSON.stringify({ instance: name }),
});
const data = await res.json();
if (!res.ok) {
statusEl.textContent = data.error || 'Erro ao conectar';
statusEl.className = 'status error';
show(statusEl, true);
connectingInstanceName = null;
return;
}
if (data.qr) {
qrImage.src = data.qr;
show(qrContainer, true);
statusEl.textContent = 'Escaneie o QR no WhatsApp.';
statusEl.className = 'status success';
} else if (data.status === 'connected') {
show(qrContainer, false);
statusEl.textContent = 'Conectado.';
statusEl.className = 'status success';
connectingInstanceName = null;
} else {
statusEl.textContent = 'Aguardando QR...';
statusEl.className = 'status';
show(qrContainer, false);
}
show(statusEl, true);
refreshInstanceList();
} catch (e) {
statusEl.textContent = e.message || 'Erro de rede';
statusEl.className = 'status error';
show(statusEl, true);
connectingInstanceName = null;
}
}
const connectInstanceSelect = document.getElementById('connectInstanceSelect');
const connectNewNameRow = document.getElementById('connectNewNameRow');
connectInstanceSelect.addEventListener('change', () => {
const isNew = connectInstanceSelect.value === '';
connectNewNameRow.style.display = isNew ? '' : 'none';
});
document.getElementById('btnConnect').addEventListener('click', () => {
const selected = connectInstanceSelect.value;
const name = selected ? selected : (document.getElementById('instanceName').value.trim() || 'main');
doConnect(name);
});
async function fetchQrAndShow(name, qrImage, qrContainer) {
try {
const res = await fetch(`${API}/v1/instances/${encodeURIComponent(name)}/qr`, { headers: headers() });
const data = await res.json();
if (data.qr) {
qrImage.src = data.qr;
show(qrContainer, true);
}
} catch (_) {}
}
function renderInstanceList(list) {
const ul = document.getElementById('instanceList');
if (!list.length) {
ul.innerHTML = '<li>Nenhuma instância ativa.</li>';
return;
}
ul.innerHTML = list
.map(
(i) =>
`<li class="instance-row">
<span class="instance-name">${i.instance}</span>
<span class="badge ${i.status}">${i.status}</span>
<div class="instance-actions">
${i.status === 'qr' ? `<button type="button" class="btn btn-small btn-ghost" data-action="qr" data-name="${i.instance}">Ver QR</button>` : ''}
${i.status === 'connected' ? `<button type="button" class="btn btn-small btn-ghost" data-action="disconnect" data-name="${i.instance}">Desconectar</button>` : ''}
<button type="button" class="btn btn-small btn-ghost" data-action="logout" data-name="${i.instance}" title="Novo QR na próxima conexão">Novo QR</button>
<button type="button" class="btn btn-small btn-danger" data-action="delete" data-name="${i.instance}">Deletar</button>
</div>
</li>`
)
.join('');
ul.querySelectorAll('[data-action]').forEach((btn) => {
btn.addEventListener('click', async () => {
const action = btn.getAttribute('data-action');
const name = btn.getAttribute('data-name');
if (!name) return;
const base = `${API}/v1/instances/${encodeURIComponent(name)}`;
try {
if (action === 'qr') {
const res = await fetch(`${base}/qr`, { headers: headers() });
const data = await res.json();
if (data.qr) {
document.getElementById('qrImage').src = data.qr;
document.getElementById('instanceName').value = name;
show(document.getElementById('qrContainer'), true);
show(document.getElementById('connectStatus'), false);
}
} else if (action === 'disconnect') {
await fetch(`${base}/disconnect`, { method: 'POST', headers: headers() });
refreshInstanceList();
} else if (action === 'logout') {
await fetch(`${base}/logout`, { method: 'POST', headers: headers() });
refreshInstanceList();
} else if (action === 'delete') {
await fetch(base, { method: 'DELETE', headers: headers() });
refreshInstanceList();
}
} catch (_) {}
refreshInstanceList();
});
});
}
function updateConnectSelect(saved) {
const sel = document.getElementById('connectInstanceSelect');
const current = sel.value;
const options = ['— Nova conexão —', ...(saved || [])];
sel.innerHTML = '<option value="">— Nova conexão —</option>' +
(saved || []).map((n) => `<option value="${n}" ${n === current ? 'selected' : ''}>${n}</option>`).join('');
connectNewNameRow.style.display = sel.value === '' ? '' : 'none';
}
async function refreshInstanceList() {
const statusEl = document.getElementById('connectStatus');
const qrContainer = document.getElementById('qrContainer');
const qrImage = document.getElementById('qrImage');
try {
const res = await fetch(`${API}/v1/instances`, { headers: headers() });
const data = await res.json();
if (data.saved) {
renderSavedList(data.saved);
updateConnectSelect(data.saved);
} else {
renderSavedList([]);
updateConnectSelect([]);
}
if (data.instances) {
renderInstanceList(data.instances);
const sel = document.getElementById('dispatchInstance');
const current = sel.value;
const names = [...new Set([...data.instances.map((i) => i.instance), ...(data.saved || [])])];
sel.innerHTML = names.map((n) => `<option value="${n}" ${n === current ? 'selected' : ''}>${n}</option>`).join('');
if (!names.includes(current)) sel.selectedIndex = 0;
// Atualização ativa: se estamos conectando uma instância, atualizar QR e status
if (connectingInstanceName) {
const inst = data.instances.find((i) => i.instance === connectingInstanceName);
if (inst) {
if (inst.status === 'qr') {
try {
const qrRes = await fetch(`${API}/v1/instances/${encodeURIComponent(connectingInstanceName)}/qr`, { headers: headers() });
const qrData = await qrRes.json();
if (qrData.qr) {
qrImage.src = qrData.qr;
show(qrContainer, true);
statusEl.textContent = 'Escaneie o QR no WhatsApp.';
statusEl.className = 'status success';
show(statusEl, true);
}
} catch (_) {}
} else if (inst.status === 'connected') {
show(qrContainer, false);
statusEl.textContent = 'Conectado.';
statusEl.className = 'status success';
show(statusEl, true);
connectingInstanceName = null;
} else if (inst.status === 'disconnected') {
statusEl.textContent = 'Desconectado. Clique em Conectar novamente.';
statusEl.className = 'status error';
show(statusEl, true);
}
}
}
}
} catch (_) {
renderSavedList([]);
renderInstanceList([]);
updateConnectSelect([]);
}
}
document.getElementById('btnRefreshList').addEventListener('click', refreshInstanceList);
refreshInstanceList();
// Polling ativo: atualizar lista, QR e status a cada 2s quando a aba Conexões estiver visível
setInterval(() => {
if (document.getElementById('conexoes').classList.contains('active')) {
refreshInstanceList();
}
}, 2000);
// --- Formulários dinâmicos (add/remove e montagem do payload) ---
function addRow(containerId, html, removeClass) {
const container = document.getElementById(containerId);
if (!container) return;
const div = document.createElement('div');
div.className = removeClass || 'item-row';
div.innerHTML = html + (removeClass ? '' : ' <button type="button" class="btn btn-small btn-ghost btn-remove">Remover</button>');
const removeBtn = div.querySelector('.btn-remove');
if (removeBtn) removeBtn.addEventListener('click', () => div.remove());
container.appendChild(div);
}
function addMenuOption() {
addRow('menuOptionsList', '<input type="text" placeholder="Texto da opção" data-field="opt">');
}
function addButtonRow() {
addRow('buttonsList', '<input type="text" placeholder="ID do botão" data-field="id"><input type="text" placeholder="Texto do botão" data-field="text">');
}
function addInteractiveRow() {
addRow(
'interactiveList',
`<select data-field="type"><option value="url">URL</option><option value="copy">Copiar</option><option value="call">Ligar</option></select>
<input type="text" placeholder="Texto do botão" data-field="text">
<input type="text" placeholder="URL / Código / Telefone" data-field="extra">`
);
}
function addPollOption() {
addRow('pollOptionsList', '<input type="text" placeholder="Opção" data-field="opt">');
}
function addListSection() {
const container = document.getElementById('listSectionsList');
const block = document.createElement('div');
block.className = 'block-section';
block.innerHTML = `
<div class="block-title">Seção</div>
<input type="text" class="section-title" placeholder="Título da seção">
<div class="sub-list section-rows"></div>
<button type="button" class="btn btn-small btn-ghost add-row-in-section">+ Adicionar item</button>
<button type="button" class="btn btn-small btn-danger btn-remove-block">Remover seção</button>
`;
block.querySelector('.add-row-in-section').addEventListener('click', () => {
const row = document.createElement('div');
row.className = 'item-row';
row.innerHTML = `
<input type="text" placeholder="ID" data-field="id">
<input type="text" placeholder="Título" data-field="title">
<input type="text" placeholder="Descrição" data-field="desc">
<button type="button" class="btn btn-small btn-ghost btn-remove">Remover</button>
`;
row.querySelector('.btn-remove').onclick = () => row.remove();
block.querySelector('.section-rows').appendChild(row);
});
block.querySelector('.btn-remove-block').onclick = () => block.remove();
container.appendChild(block);
}
function addCarouselCard() {
const container = document.getElementById('carouselCardsList');
const block = document.createElement('div');
block.className = 'block-section';
block.innerHTML = `
<div class="block-title">Card</div>
<div class="form-row"><input type="text" placeholder="Título" data-field="title"></div>
<div class="form-row"><input type="text" placeholder="Corpo/descrição" data-field="body"></div>
<div class="form-row"><input type="text" placeholder="Rodapé" data-field="footer"></div>
<div class="form-row"><input type="text" placeholder="URL da imagem" data-field="imageUrl"></div>
<div class="sub-list card-buttons"></div>
<button type="button" class="btn btn-small btn-ghost add-card-btn">+ Botão no card</button>
<button type="button" class="btn btn-small btn-danger btn-remove-block">Remover card</button>
`;
block.querySelector('.add-card-btn').addEventListener('click', () => {
const row = document.createElement('div');
row.className = 'item-row';
row.innerHTML = `
<input type="text" placeholder="ID" data-field="id">
<input type="text" placeholder="Texto" data-field="text">
<button type="button" class="btn btn-small btn-ghost btn-remove">Remover</button>
`;
row.querySelector('.btn-remove').onclick = () => row.remove();
block.querySelector('.card-buttons').appendChild(row);
});
block.querySelector('.btn-remove-block').onclick = () => block.remove();
container.appendChild(block);
}
document.querySelectorAll('.add-item').forEach((btn) => {
btn.addEventListener('click', () => {
const forId = btn.getAttribute('data-for');
if (forId === 'menuOptions') addMenuOption();
else if (forId === 'buttons') addButtonRow();
else if (forId === 'interactive') addInteractiveRow();
else if (forId === 'listSections') addListSection();
else if (forId === 'pollOptions') addPollOption();
else if (forId === 'carouselCards') addCarouselCard();
});
});
// Inicializar um item vazio por tipo
addMenuOption();
addButtonRow();
addInteractiveRow();
addPollOption();
// Coletar dados dos formulários e montar payload
function getMenuPayload() {
const options = [];
document.querySelectorAll('#menuOptionsList .item-row input[data-field="opt"]').forEach((inp) => {
const v = inp.value.trim();
if (v) options.push(v);
});
return {
url: '/v1/messages/send_menu',
body: {
instance: document.getElementById('dispatchInstance').value,
to: document.getElementById('dispatchTo').value.trim(),
title: document.getElementById('menuTitle').value.trim() || 'Menu',
text: document.getElementById('menuText').value.trim() || 'Escolha uma opção:',
options: options.length ? options : ['Opção 1'],
footer: document.getElementById('menuFooter').value.trim() || undefined,
},
};
}
function getButtonsPayload() {
const buttons = [];
document.querySelectorAll('#buttonsList .item-row').forEach((row) => {
const id = row.querySelector('[data-field="id"]')?.value?.trim();
const text = row.querySelector('[data-field="text"]')?.value?.trim();
if (id && text) buttons.push({ id, text });
});
return {
url: '/v1/messages/send_buttons_helpers',
body: {
instance: document.getElementById('dispatchInstance').value,
to: document.getElementById('dispatchTo').value.trim(),
text: document.getElementById('buttonsText').value.trim() || 'Escolha:',
footer: document.getElementById('buttonsFooter').value.trim() || undefined,
buttons: buttons.length ? buttons.slice(0, 3) : [{ id: 'btn1', text: 'Opção 1' }],
},
};
}
function getInteractivePayload() {
const buttons = [];
document.querySelectorAll('#interactiveList .item-row').forEach((row) => {
const type = row.querySelector('[data-field="type"]')?.value || 'url';
const text = row.querySelector('[data-field="text"]')?.value?.trim();
const extra = row.querySelector('[data-field="extra"]')?.value?.trim();
if (!text || !extra) return;
const btn = { type, text };
if (type === 'url') btn.url = extra;
else if (type === 'copy') btn.copyCode = extra;
else if (type === 'call') btn.phoneNumber = extra;
buttons.push(btn);
});
return {
url: '/v1/messages/send_interactive_helpers',
body: {
instance: document.getElementById('dispatchInstance').value,
to: document.getElementById('dispatchTo').value.trim(),
text: document.getElementById('interactiveText').value.trim() || 'Confira:',
footer: document.getElementById('interactiveFooter').value.trim() || undefined,
buttons,
},
};
}
function getListPayload() {
const sections = [];
document.querySelectorAll('#listSectionsList .block-section').forEach((block) => {
const title = block.querySelector('.section-title')?.value?.trim() || 'Seção';
const rows = [];
block.querySelectorAll('.section-rows .item-row').forEach((row) => {
const id = row.querySelector('[data-field="id"]')?.value?.trim();
const titleR = row.querySelector('[data-field="title"]')?.value?.trim();
const desc = row.querySelector('[data-field="desc"]')?.value?.trim();
if (id && titleR) rows.push({ id, title: titleR, description: desc || '' });
});
if (rows.length) sections.push({ title, rows });
});
return {
url: '/v1/messages/send_list_helpers',
body: {
instance: document.getElementById('dispatchInstance').value,
to: document.getElementById('dispatchTo').value.trim(),
text: document.getElementById('listText').value.trim() || 'Escolha:',
buttonText: document.getElementById('listButtonText').value.trim() || 'Ver opções',
footer: document.getElementById('listFooter').value.trim() || undefined,
sections: sections.length ? sections : [{ title: 'Opções', rows: [{ id: 'opt1', title: 'Opção 1', description: '' }] }],
},
};
}
function getPollPayload() {
const options = [];
document.querySelectorAll('#pollOptionsList .item-row input[data-field="opt"]').forEach((inp) => {
const v = inp.value.trim();
if (v) options.push(v);
});
return {
url: '/v1/messages/send_poll',
body: {
instance: document.getElementById('dispatchInstance').value,
to: document.getElementById('dispatchTo').value.trim(),
name: document.getElementById('pollName').value.trim() || 'Enquete',
options: options.length >= 2 ? options : ['Sim', 'Não'],
selectableCount: parseInt(document.getElementById('pollSelectable').value, 10) || 1,
},
};
}
function getCarouselPayload() {
const cards = [];
document.querySelectorAll('#carouselCardsList .block-section').forEach((block) => {
const title = block.querySelector('[data-field="title"]')?.value?.trim();
const body = block.querySelector('[data-field="body"]')?.value?.trim();
const footer = block.querySelector('[data-field="footer"]')?.value?.trim();
const imageUrl = block.querySelector('[data-field="imageUrl"]')?.value?.trim();
const buttons = [];
block.querySelectorAll('.card-buttons .item-row').forEach((row) => {
const id = row.querySelector('[data-field="id"]')?.value?.trim();
const text = row.querySelector('[data-field="text"]')?.value?.trim();
if (id && text) buttons.push({ id, text });
});
cards.push({
title: title || '',
body: body || '',
footer: footer || undefined,
imageUrl: imageUrl || undefined,
buttons: buttons.length ? buttons : [{ id: 'btn1', text: 'Ver' }],
});
});
return {
url: '/v1/messages/send_carousel_helpers',
body: {
instance: document.getElementById('dispatchInstance').value,
to: document.getElementById('dispatchTo').value.trim(),
text: document.getElementById('carouselText').value.trim() || undefined,
footer: document.getElementById('carouselFooter').value.trim() || undefined,
cards: cards.length ? cards : [{ title: 'Card', body: '', buttons: [{ id: 'b1', text: 'Botão' }] }],
},
};
}
/**
* Lê destinatários do campo e normaliza: aceita +55, espaços, traços, vírgulas etc.
* Ex: "+55 35 9882-8503," vira "553598828503".
*/
function getRecipients() {
const raw = document.getElementById('dispatchTo').value.trim();
if (!raw) return [];
return raw
.split(/[\r\n,;]+/)
.map((s) => s.replace(/\D/g, ''))
.filter((n) => n.length >= 10);
}
function delayMs(minSec, maxSec) {
const min = Math.max(0, Number(minSec) || 0);
const max = Math.max(min, Number(maxSec) || min);
const sec = min + Math.random() * (max - min);
return Math.round(sec * 1000);
}
document.getElementById('btnSend').addEventListener('click', async () => {
const recipients = getRecipients();
const resultEl = document.getElementById('sendResult');
const btnSend = document.getElementById('btnSend');
if (!recipients.length) {
resultEl.textContent = 'Informe ao menos um número (um por linha, com DDI).';
resultEl.className = 'result error';
show(resultEl, true);
return;
}
const type = document.getElementById('dispatchType').value;
let payload;
switch (type) {
case 'menu': payload = getMenuPayload(); break;
case 'buttons': payload = getButtonsPayload(); break;
case 'interactive': payload = getInteractivePayload(); break;
case 'list': payload = getListPayload(); break;
case 'poll': payload = getPollPayload(); break;
case 'carousel': payload = getCarouselPayload(); break;
default:
resultEl.textContent = 'Tipo não implementado.';
resultEl.className = 'result error';
show(resultEl, true);
return;
}
if (type === 'interactive' && (!payload.body.buttons || payload.body.buttons.length === 0)) {
resultEl.textContent = 'Adicione ao menos um botão CTA.';
resultEl.className = 'result error';
show(resultEl, true);
return;
}
const delayMin = document.getElementById('dispatchDelayMin').value;
const delayMax = document.getElementById('dispatchDelayMax').value;
let sent = 0;
let failed = 0;
btnSend.disabled = true;
show(resultEl, true);
resultEl.className = 'result';
for (let i = 0; i < recipients.length; i++) {
const to = recipients[i];
payload.body.to = to;
resultEl.textContent = `Enviando ${i + 1}/${recipients.length}... (${to})`;
try {
const res = await fetch(`${API}${payload.url}`, {
method: 'POST',
headers: headers(),
body: JSON.stringify(payload.body),
});
const data = await res.json();
if (res.ok) {
sent++;
} else {
failed++;
}
} catch (_) {
failed++;
}
if (i < recipients.length - 1) {
const wait = delayMs(delayMin, delayMax);
resultEl.textContent = `Aguardando ${wait / 1000}s antes do próximo... (${i + 1}/${recipients.length})`;
await new Promise((r) => setTimeout(r, wait));
}
}
resultEl.textContent = `Concluído: ${sent} enviados${failed ? `, ${failed} falhas` : ''}.`;
resultEl.className = failed === 0 ? 'result success' : failed === recipients.length ? 'result error' : 'result';
btnSend.disabled = false;
});
const savedKey = localStorage.getItem('rscara_api_key');
if (savedKey) document.getElementById('apiKey').placeholder = '••••••••';
})();
@@ -1,169 +0,0 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>rsalcara — WhatsApp API</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="app">
<header class="header">
<h1 class="logo">rsalcara</h1>
<p class="tagline">WhatsApp · Múltiplas contas e disparos</p>
<div class="api-key-wrap">
<label for="apiKey">API Key (opcional)</label>
<input type="password" id="apiKey" name="apiKey" placeholder="x-api-key" autocomplete="off">
</div>
</header>
<nav class="tabs">
<button type="button" class="tab active" data-tab="conexoes">Conexões</button>
<button type="button" class="tab" data-tab="disparos">Disparos</button>
</nav>
<main class="main">
<section id="conexoes" class="panel active">
<div class="card">
<h2>Conexões salvas</h2>
<p class="card-hint">Clique em Conectar para abrir a sessão (QR se necessário).</p>
<ul id="savedList" class="saved-list"></ul>
</div>
<div class="card">
<h2>Conectar por nome</h2>
<div class="form-row">
<label for="connectInstanceSelect">Escolha uma conexão</label>
<select id="connectInstanceSelect" name="connectInstanceSelect">
<option value="">— Nova conexão —</option>
</select>
</div>
<div class="form-row" id="connectNewNameRow">
<label for="instanceName">Nome da nova instância</label>
<input type="text" id="instanceName" name="instanceName" value="main" placeholder="ex: main, vendas">
</div>
<button type="button" id="btnConnect" class="btn btn-primary">Criar / Conectar</button>
<div id="qrContainer" class="qr-container hidden">
<p>Escaneie o QR no WhatsApp:</p>
<img id="qrImage" alt="QR Code" class="qr-image">
<p class="qr-hint">Ou abra WhatsApp → Aparelhos conectados → Conectar aparelho</p>
</div>
<div id="connectStatus" class="status hidden"></div>
</div>
<div class="card">
<h2>Instâncias ativas</h2>
<ul id="instanceList" class="instance-list"></ul>
<button type="button" id="btnRefreshList" class="btn btn-ghost">Atualizar lista</button>
</div>
</section>
<section id="disparos" class="panel">
<div class="card form-card">
<div class="form-row">
<label for="dispatchInstance">Instância</label>
<select id="dispatchInstance" name="dispatchInstance"><option value="main">main</option></select>
</div>
<div class="form-row">
<label for="dispatchTo">Destinatários</label>
<textarea id="dispatchTo" name="dispatchTo" rows="4" placeholder="Um número por linha (com DDI)&#10;Ex: 553598828503&#10;5511999999999"></textarea>
<span class="field-hint">Use um número por linha. Para um único número, digite uma linha.</span>
</div>
<div class="form-row mailing-delay">
<label for="dispatchDelayMin">Intervalo entre envios (segundos)</label>
<div class="delay-inputs">
<input type="number" id="dispatchDelayMin" name="dispatchDelayMin" value="2" min="0" max="300" placeholder="Mín"> <span>a</span>
<input type="number" id="dispatchDelayMax" name="dispatchDelayMax" value="5" min="0" max="300" placeholder="Máx">
</div>
<span class="field-hint">Tempo aleatório entre cada envio (evita bloqueio).</span>
</div>
<div class="form-row">
<label for="dispatchType">Tipo de mensagem</label>
<select id="dispatchType" name="dispatchType">
<option value="menu">Menu (texto numerado)</option>
<option value="buttons">Botões (quick reply)</option>
<option value="interactive">Botões CTA (URL / Copiar / Ligar)</option>
<option value="list">Lista dropdown</option>
<option value="poll">Enquete</option>
<option value="carousel">Carrossel</option>
</select>
</div>
<!-- Menu -->
<div id="formMenu" class="dispatch-form">
<div class="form-row"><label>Título</label><input type="text" id="menuTitle" name="menuTitle" placeholder="Menu de Opções"></div>
<div class="form-row"><label>Texto</label><textarea id="menuText" name="menuText" rows="2" placeholder="Escolha uma opção:"></textarea></div>
<div class="form-row">
<label>Opções</label>
<div id="menuOptionsList" class="dynamic-list"></div>
<button type="button" class="btn btn-small btn-ghost add-item" data-for="menuOptions">+ Adicionar opção</button>
</div>
<div class="form-row"><label>Rodapé</label><input type="text" id="menuFooter" name="menuFooter" placeholder="Responda com número"></div>
</div>
<!-- Botões -->
<div id="formButtons" class="dispatch-form hidden">
<div class="form-row"><label>Texto</label><textarea id="buttonsText" name="buttonsText" rows="2" placeholder="Como posso ajudar?"></textarea></div>
<div class="form-row"><label>Rodapé</label><input type="text" id="buttonsFooter" name="buttonsFooter" placeholder="Atendimento 24h"></div>
<div class="form-row">
<label>Botões (máx 3)</label>
<div id="buttonsList" class="dynamic-list"></div>
<button type="button" class="btn btn-small btn-ghost add-item" data-for="buttons">+ Adicionar botão</button>
</div>
</div>
<!-- Interactive CTA -->
<div id="formInteractive" class="dispatch-form hidden">
<div class="form-row"><label>Texto</label><textarea id="interactiveText" name="interactiveText" rows="3" placeholder="Mensagem com CTAs"></textarea></div>
<div class="form-row"><label>Rodapé</label><input type="text" id="interactiveFooter" name="interactiveFooter"></div>
<div class="form-row">
<label>Botões CTA</label>
<div id="interactiveList" class="dynamic-list"></div>
<button type="button" class="btn btn-small btn-ghost add-item" data-for="interactive">+ Adicionar botão</button>
</div>
</div>
<!-- Lista -->
<div id="formList" class="dispatch-form hidden">
<div class="form-row"><label>Texto</label><textarea id="listText" name="listText" rows="2" placeholder="Escolha uma categoria:"></textarea></div>
<div class="form-row"><label>Texto do botão</label><input type="text" id="listButtonText" name="listButtonText" value="Ver Cardápio" placeholder="Ver opções"></div>
<div class="form-row"><label>Rodapé</label><input type="text" id="listFooter" name="listFooter" placeholder="Delivery grátis acima de R$ 50"></div>
<div class="form-row">
<label>Seções</label>
<div id="listSectionsList" class="dynamic-list"></div>
<button type="button" class="btn btn-small btn-ghost add-item" data-for="listSections">+ Adicionar seção</button>
</div>
</div>
<!-- Enquete -->
<div id="formPoll" class="dispatch-form hidden">
<div class="form-row"><label>Pergunta</label><input type="text" id="pollName" name="pollName" placeholder="Qual sua linguagem favorita?"></div>
<div class="form-row"><label>Quantas pode escolher</label><input type="number" id="pollSelectable" name="pollSelectable" value="1" min="1"></div>
<div class="form-row">
<label>Opções</label>
<div id="pollOptionsList" class="dynamic-list"></div>
<button type="button" class="btn btn-small btn-ghost add-item" data-for="pollOptions">+ Adicionar opção</button>
</div>
</div>
<!-- Carrossel -->
<div id="formCarousel" class="dispatch-form hidden">
<div class="form-row"><label>Texto (acima dos cards)</label><input type="text" id="carouselText" name="carouselText" placeholder="Ofertas especiais"></div>
<div class="form-row"><label>Rodapé</label><input type="text" id="carouselFooter" name="carouselFooter"></div>
<div class="form-row">
<label>Cards</label>
<div id="carouselCardsList" class="dynamic-list"></div>
<button type="button" class="btn btn-small btn-ghost add-item" data-for="carouselCards">+ Adicionar card</button>
</div>
</div>
<button type="button" id="btnSend" class="btn btn-primary btn-send">Enviar</button>
<div id="sendResult" class="result hidden"></div>
</div>
</section>
</main>
</div>
<script src="/app.js"></script>
</body>
</html>
@@ -1,499 +0,0 @@
:root {
--bg: #0f0f12;
--bg-card: #18181c;
--bg-input: #222228;
--border: #2d2d35;
--text: #e4e4e7;
--text-muted: #71717a;
--accent: #22c55e;
--accent-hover: #16a34a;
--danger: #ef4444;
--radius: 12px;
--font: 'DM Sans', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: var(--font);
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
.app {
max-width: 720px;
margin: 0 auto;
padding: 1.5rem;
}
.header {
text-align: center;
margin-bottom: 1.5rem;
}
.logo {
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.02em;
margin: 0 0 0.25rem 0;
}
.tagline {
color: var(--text-muted);
font-size: 0.9rem;
margin: 0 0 1rem 0;
}
.api-key-wrap {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 0.35rem;
}
.api-key-wrap label {
font-size: 0.75rem;
color: var(--text-muted);
}
#apiKey {
width: 220px;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-input);
color: var(--text);
font-family: var(--font-mono);
font-size: 0.85rem;
}
#apiKey:focus {
outline: none;
border-color: var(--accent);
}
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.tab {
padding: 0.6rem 1.2rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
color: var(--text);
font-family: var(--font);
font-size: 0.95rem;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.tab:hover {
background: var(--bg-input);
}
.tab.active {
background: var(--accent);
border-color: var(--accent);
color: #0a0a0c;
}
.panel {
display: none;
}
.panel.active {
display: block;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
margin-bottom: 1rem;
}
.card h2 {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.5rem 0;
}
.card-hint {
font-size: 0.85rem;
color: var(--text-muted);
margin: 0 0 0.75rem 0;
}
.saved-list {
list-style: none;
padding: 0;
margin: 0 0 0.75rem 0;
}
.saved-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
}
.saved-list li:last-child { border-bottom: none; }
.saved-list .saved-item-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
}
.saved-list .saved-item-actions {
display: flex;
align-items: center;
gap: 0.4rem;
}
.saved-list .btn-connect-saved {
padding: 0.4rem 0.9rem;
font-size: 0.85rem;
}
.dynamic-list {
margin-bottom: 0.5rem;
}
.dynamic-list .item-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.text-muted {
color: var(--text-muted);
font-size: 0.9rem;
}
.dynamic-list .item-row input,
.dynamic-list .item-row select {
flex: 1;
min-width: 100px;
padding: 0.45rem 0.6rem;
font-size: 0.9rem;
}
.dynamic-list .item-row .btn-remove {
flex-shrink: 0;
}
.block-section {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem;
margin-bottom: 0.75rem;
background: var(--bg-input);
}
.block-section .block-title {
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.block-section .sub-list {
margin-top: 0.5rem;
}
.block-section .sub-list .item-row { margin-bottom: 0.35rem; }
.block-section .btn-remove-block { margin-top: 0.5rem; }
.form-row {
margin-bottom: 0.9rem;
}
.form-row label {
display: block;
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 0.35rem;
}
.form-row input,
.form-row select,
.form-row textarea {
width: 100%;
padding: 0.55rem 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-input);
color: var(--text);
font-family: var(--font);
font-size: 0.9rem;
}
.form-row textarea {
min-height: 80px;
resize: vertical;
}
.form-row input:focus,
.form-row select:focus,
.form-row textarea:focus {
outline: none;
border-color: var(--accent);
}
.field-hint {
display: block;
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.35rem;
}
.delay-inputs {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.delay-inputs input {
width: 5rem;
}
.delay-inputs span {
color: var(--text-muted);
font-size: 0.9rem;
}
.form-row input[type="number"] {
max-width: 6rem;
}
.btn {
padding: 0.6rem 1.2rem;
border-radius: 8px;
font-family: var(--font);
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
border: none;
transition: background 0.15s, opacity 0.15s;
}
.btn-primary {
background: var(--accent);
color: #0a0a0c;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-ghost {
background: transparent;
color: var(--text-muted);
border: 1px solid var(--border);
}
.btn-ghost:hover {
color: var(--text);
border-color: var(--text-muted);
}
.btn-send {
margin-top: 0.5rem;
}
.btn-small {
padding: 0.35rem 0.6rem;
font-size: 0.8rem;
}
.btn-danger {
background: transparent;
color: #f87171;
border: 1px solid rgba(248, 113, 113, 0.4);
}
.btn-danger:hover {
background: rgba(239, 68, 68, 0.15);
color: #fca5a5;
border-color: var(--danger);
}
.qr-container {
margin-top: 1rem;
text-align: center;
}
.qr-container p {
margin: 0 0 0.5rem 0;
font-size: 0.9rem;
color: var(--text-muted);
}
.qr-image {
max-width: 280px;
width: 100%;
height: auto;
border-radius: 8px;
border: 1px solid var(--border);
}
.qr-hint {
margin-top: 0.75rem !important;
font-size: 0.8rem !important;
}
.status {
margin-top: 0.75rem;
padding: 0.5rem;
border-radius: 8px;
font-size: 0.85rem;
}
.status.error {
background: rgba(239, 68, 68, 0.15);
color: #fca5a5;
}
.status.success {
background: rgba(34, 197, 94, 0.15);
color: #86efac;
}
.hidden {
display: none !important;
}
.instance-list {
list-style: none;
padding: 0;
margin: 0 0 0.75rem 0;
}
.instance-list li {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
}
.instance-list .instance-name {
font-weight: 500;
}
.instance-list .instance-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.instance-list li:last-child {
border-bottom: none;
}
.instance-list .badge {
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
border-radius: 6px;
text-transform: uppercase;
}
.instance-list .badge.connected {
background: rgba(34, 197, 94, 0.2);
color: var(--accent);
}
.instance-list .badge.qr {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
.instance-list .badge.disconnected {
background: rgba(239, 68, 68, 0.2);
color: var(--danger);
}
#btnRefreshList {
margin-top: 0.25rem;
}
.dispatch-form {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.dispatch-form.hidden {
display: none;
}
.form-card .form-row:first-of-type {
margin-top: 0;
}
.result {
margin-top: 1rem;
padding: 0.75rem;
border-radius: 8px;
font-size: 0.9rem;
font-family: var(--font-mono);
}
.result.success {
background: rgba(34, 197, 94, 0.15);
color: #86efac;
}
.result.error {
background: rgba(239, 68, 68, 0.15);
color: #fca5a5;
}
/* CTA row: tipo + texto + extra */
#formInteractive .form-row select,
#formInteractive .form-row input {
width: auto;
min-width: 0;
}
#formInteractive .form-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
#formInteractive .form-row label {
margin-bottom: 0;
min-width: 3rem;
}
#formInteractive .form-row input:nth-of-type(2) {
flex: 1;
min-width: 100px;
}
#formInteractive .form-row input:nth-of-type(3) {
flex: 1;
min-width: 120px;
}
-18
View File
@@ -1,18 +0,0 @@
import dotenv from 'dotenv';
dotenv.config();
export const config = {
port: parseInt(process.env.PORT ?? '8787', 10),
apiKey: process.env.API_KEY ?? 'ACFH4RFOTME4RU50R4FKGNW34LDFG8DSQ',
authFolder: process.env.AUTH_FOLDER ?? 'auth',
limits: {
maxButtons: 3,
maxCarouselCards: 10,
maxListSections: 10,
maxListRowsPerSection: 10,
maxPollOptions: 12,
},
} as const;
export type Config = typeof config;
@@ -1,267 +0,0 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { WASocketLike } from '../types/whatsapp.js';
type MenuKey = 'main' | 'users' | 'admins' | 'system' | 'session' | 'logs' | 'database';
type MenuOption = {
label: string;
next?: MenuKey;
action?: () => Promise<string>;
exit?: boolean;
back?: boolean;
};
type MenuDefinition = {
title: string;
options: MenuOption[];
};
const SESSION_TTL_MS = 5 * 60 * 1000;
const sessions = new Map<string, { menu: MenuKey; updatedAt: number }>();
function sanitizePhone(input: string): string {
return input.replace(/\D/g, '');
}
function getSenderJid(msg: any): string | null {
return msg?.key?.remoteJid ?? null;
}
function getSenderNumber(jid: string): string {
const raw = jid.includes('@') ? jid.split('@')[0] : jid;
return sanitizePhone(raw);
}
function isSuperAdmin(jid: string): boolean {
const admin = sanitizePhone(process.env.SUPER_ADMIN || '');
if (!admin) return false;
const sender = getSenderNumber(jid);
return sender === admin;
}
function extractText(msg: any): string | null {
const message = msg?.message;
if (!message) return null;
if (message.conversation) return message.conversation;
if (message.extendedTextMessage?.text) return message.extendedTextMessage.text;
if (message.imageMessage?.caption) return message.imageMessage.caption;
if (message.videoMessage?.caption) return message.videoMessage.caption;
if (message.buttonsResponseMessage?.selectedButtonId) return message.buttonsResponseMessage.selectedButtonId;
if (message.listResponseMessage?.singleSelectReply?.selectedRowId) return message.listResponseMessage.singleSelectReply.selectedRowId;
return null;
}
function buildMenuText(menu: MenuDefinition): string {
let text = `*${menu.title}*\n\n`;
menu.options.forEach((opt, idx) => {
text += `*${idx + 1}.* ${opt.label}\n`;
});
text += '\nResponda com o número desejado.';
return text.trim();
}
function isExpired(ts: number): boolean {
return Date.now() - ts > SESSION_TTL_MS;
}
function setSession(jid: string, menu: MenuKey) {
sessions.set(jid, { menu, updatedAt: Date.now() });
}
function clearSession(jid: string) {
sessions.delete(jid);
}
function getSession(jid: string): { menu: MenuKey; updatedAt: number } | null {
const session = sessions.get(jid);
if (!session) return null;
if (isExpired(session.updatedAt)) {
sessions.delete(jid);
return null;
}
return session;
}
function tailFile(filePath: string, maxLines: number): string {
try {
if (!fs.existsSync(filePath)) return 'Arquivo de log nao encontrado.';
const data = fs.readFileSync(filePath, 'utf8');
const lines = data.split(/\r?\n/).filter(Boolean);
return lines.slice(-maxLines).join('\n') || 'Sem logs recentes.';
} catch {
return 'Erro ao ler logs.';
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
}
function getPm2LogPath(kind: 'out' | 'error'): string {
const pm2Name = process.env.WHATSAPP_PM2_NAME || 'whatsapp-v1';
return path.join('/root/.pm2/logs', `${pm2Name}-${kind}.log`);
}
export function attachSuperAdminMenu(sock: WASocketLike, actions: {
getSessionStatus: () => string;
resetSession: () => Promise<string>;
logoutSession: () => Promise<string>;
}): void {
if ((sock as any).__superAdminMenuAttached) return;
(sock as any).__superAdminMenuAttached = true;
const menus: Record<MenuKey, MenuDefinition> = {
main: {
title: '🧠 PAINEL SUPER ADMIN',
options: [
{ label: 'Usuarios', next: 'users' },
{ label: 'Admins', next: 'admins' },
{ label: 'Sistema', next: 'system' },
{ label: 'Sessao WhatsApp', next: 'session' },
{ label: 'Logs', next: 'logs' },
{ label: 'Banco de Dados', next: 'database' },
{ label: 'Sair', exit: true },
],
},
users: {
title: '1️⃣ Usuarios',
options: [
{ label: 'Listar usuarios', action: async () => 'Funcionalidade em desenvolvimento.' },
{ label: 'Buscar usuario', action: async () => 'Funcionalidade em desenvolvimento.' },
{ label: 'Remover usuario', action: async () => 'Funcionalidade em desenvolvimento.' },
{ label: 'Voltar', back: true },
],
},
admins: {
title: '2️⃣ Admins',
options: [
{ label: 'Listar admins', action: async () => 'Funcionalidade em desenvolvimento.' },
{ label: 'Adicionar admin', action: async () => 'Funcionalidade em desenvolvimento.' },
{ label: 'Remover admin', action: async () => 'Funcionalidade em desenvolvimento.' },
{ label: 'Voltar', back: true },
],
},
system: {
title: '3️⃣ Sistema',
options: [
{ label: 'Status do servidor', action: async () => {
const uptime = Math.floor(os.uptime() / 60);
return `Servidor online. Uptime: ${uptime} min. Node: ${process.version}.`;
}},
{ label: 'Uso de memoria', action: async () => {
const mem = process.memoryUsage();
return `Memoria RSS: ${formatBytes(mem.rss)} | Heap: ${formatBytes(mem.heapUsed)} / ${formatBytes(mem.heapTotal)}`;
}},
{ label: 'Reiniciar bot', action: async () => {
setTimeout(() => process.exit(0), 500);
return 'Reiniciando bot...';
}},
{ label: 'Voltar', back: true },
],
},
session: {
title: '4️⃣ Sessao WhatsApp',
options: [
{ label: 'Status conexao', action: async () => `Status: ${actions.getSessionStatus()}` },
{ label: 'Resetar sessao', action: actions.resetSession },
{ label: 'Deslogar WhatsApp', action: actions.logoutSession },
{ label: 'Voltar', back: true },
],
},
logs: {
title: '5️⃣ Logs',
options: [
{ label: 'Ultimos 50 logs', action: async () => tailFile(getPm2LogPath('out'), 50) },
{ label: 'Erros recentes', action: async () => tailFile(getPm2LogPath('error'), 50) },
{ label: 'Exportar log', action: async () => tailFile(getPm2LogPath('out'), 50) },
{ label: 'Voltar', back: true },
],
},
database: {
title: '6️⃣ Banco de Dados',
options: [
{ label: 'Exportar users.json', action: async () => 'Funcionalidade em desenvolvimento.' },
{ label: 'Backup completo', action: async () => 'Funcionalidade em desenvolvimento.' },
{ label: 'Limpar registros inativos', action: async () => 'Funcionalidade em desenvolvimento.' },
{ label: 'Voltar', back: true },
],
},
};
sock.ev.on('messages.upsert', async (payload: any) => {
const list = payload?.messages || [];
for (const msg of list) {
if (!msg?.message) continue;
if (msg?.key?.fromMe) continue;
const jid = getSenderJid(msg);
if (!jid || jid.endsWith('@g.us')) continue;
const text = extractText(msg);
if (!text) continue;
const body = text.trim();
if (!body) continue;
const lower = body.toLowerCase();
if (lower === '.menu' || lower === '.panel') {
if (!isSuperAdmin(jid)) {
await sock.sendMessage(jid, { text: 'Acesso restrito.' });
continue;
}
setSession(jid, 'main');
await sock.sendMessage(jid, { text: buildMenuText(menus.main) });
continue;
}
if (!isSuperAdmin(jid)) continue;
const session = getSession(jid);
if (!session) continue;
if (isExpired(session.updatedAt)) {
clearSession(jid);
await sock.sendMessage(jid, { text: 'Sessao expirada. Envie .menu novamente.' });
continue;
}
const menu = menus[session.menu];
const choice = parseInt(body, 10);
if (Number.isNaN(choice) || choice < 1 || choice > menu.options.length) {
await sock.sendMessage(jid, { text: 'Opcao invalida. Responda com um numero da lista.' });
continue;
}
const option = menu.options[choice - 1];
if (option.exit) {
clearSession(jid);
await sock.sendMessage(jid, { text: 'Sessao encerrada.' });
continue;
}
if (option.back) {
setSession(jid, 'main');
await sock.sendMessage(jid, { text: buildMenuText(menus.main) });
continue;
}
if (option.next) {
setSession(jid, option.next);
await sock.sendMessage(jid, { text: buildMenuText(menus[option.next]) });
continue;
}
if (option.action) {
setSession(jid, session.menu);
const reply = await option.action();
await sock.sendMessage(jid, { text: reply });
continue;
}
}
});
}
-42
View File
@@ -1,42 +0,0 @@
import express from 'express';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { config } from './config.js';
import instancesRouter from './routes/instances.js';
import messagesRouter from './routes/messages.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
app.use(express.json({ limit: '2mb' }));
function apiKeyMiddleware(req: express.Request, res: express.Response, next: express.NextFunction) {
const key = req.headers['x-api-key'];
if (!config.apiKey || config.apiKey === '') {
return next();
}
if (key !== config.apiKey) {
return res.status(401).json({ ok: false, error: 'invalid_api_key' });
}
next();
}
app.get('/health', (_req, res) => {
res.json({ ok: true, service: 'rsalcara' });
});
// API key só nas rotas /v1 (a interface em / carrega sem key)
app.use('/v1/instances', apiKeyMiddleware, instancesRouter);
app.use('/v1/messages', apiKeyMiddleware, messagesRouter);
const publicDir = path.join(__dirname, '..', 'public');
app.use(express.static(publicDir));
app.get('/', (_req, res) => res.sendFile(path.join(publicDir, 'index.html')));
app.listen(config.port, () => {
console.log(`[rsalcara] API rodando em http://localhost:${config.port}`);
console.log(`[rsalcara] Interface: http://localhost:${config.port}`);
if (config.apiKey) {
console.log('[rsalcara] API Key ativa. Use header: x-api-key');
}
});
@@ -1,170 +0,0 @@
import { Router, type Request, type Response } from 'express';
import fs from 'node:fs';
import path from 'node:path';
import QRCode from 'qrcode';
import {
createInstance,
getInstance,
getAllInstances,
removeInstance,
disconnectInstance,
logoutInstance,
} from '../services/whatsapp.js';
import { config } from '../config.js';
const router = Router();
/**
* POST /v1/instances
* Cria uma nova instância e retorna o QR code em base64 (ou status se já conectada).
*/
router.post('/', async (req: Request, res: Response) => {
try {
const { instance = 'main' } = req.body as { instance?: string };
const name = String(instance).trim() || 'main';
const result = await createInstance(name, config.authFolder);
if (!result.ok) {
return res.status(500).json({ ok: false, error: result.error });
}
let qrBase64: string | undefined;
if (result.qr) {
qrBase64 = await QRCode.toDataURL(result.qr, { width: 400, margin: 2 });
}
const ctx = getInstance(name);
return res.json({
ok: true,
instance: name,
status: ctx?.status ?? 'connecting',
qr: qrBase64 ?? null,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return res.status(500).json({ ok: false, error: message });
}
});
/**
* GET /v1/instances
* Lista instâncias ativas e salvas (pastas em auth/).
*/
router.get('/', (_req: Request, res: Response) => {
const list = getAllInstances().map((ctx) => ({
instance: ctx.name,
status: ctx.status,
hasQr: Boolean(ctx.qr),
createdAt: ctx.createdAt.toISOString(),
}));
let saved: string[] = [];
const authDir = path.resolve(process.cwd(), config.authFolder);
try {
if (fs.existsSync(authDir)) {
saved = fs.readdirSync(authDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
}
} catch {
saved = [];
}
return res.json({ ok: true, instances: list, saved });
});
/**
* GET /v1/instances/saved
* Lista apenas nomes das conexões salvas (pastas em auth/).
*/
router.get('/saved', (_req: Request, res: Response) => {
const authDir = path.resolve(process.cwd(), config.authFolder);
let saved: string[] = [];
try {
if (fs.existsSync(authDir)) {
saved = fs.readdirSync(authDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
}
} catch {
saved = [];
}
return res.json({ ok: true, saved });
});
/**
* GET /v1/instances/:name/qr
* Retorna o QR code da instância em base64 (se estiver em estado qr).
*/
router.get('/:name/qr', async (req: Request, res: Response) => {
const { name } = req.params;
const ctx = getInstance(name);
if (!ctx) {
return res.status(404).json({ ok: false, error: 'instance_not_found' });
}
if (ctx.status !== 'qr' || !ctx.qr) {
return res.status(400).json({ ok: false, error: 'no_qr_available', status: ctx.status });
}
const qrBase64 = await QRCode.toDataURL(ctx.qr, { width: 400, margin: 2 });
return res.json({ ok: true, instance: name, qr: qrBase64 });
});
/**
* GET /v1/instances/:name
* Status de uma instância.
*/
router.get('/:name', (req: Request, res: Response) => {
const { name } = req.params;
const ctx = getInstance(name);
if (!ctx) {
return res.status(404).json({ ok: false, error: 'instance_not_found' });
}
return res.json({
ok: true,
instance: ctx.name,
status: ctx.status,
hasQr: Boolean(ctx.qr),
createdAt: ctx.createdAt.toISOString(),
});
});
/**
* POST /v1/instances/:name/disconnect
* Desconecta e remove a instância da memória (credenciais ficam em disco; reconectar pode usar sessão salva).
*/
router.post('/:name/disconnect', (req: Request, res: Response) => {
const { name } = req.params;
const removed = disconnectInstance(name);
return res.json({ ok: removed, instance: name });
});
/**
* POST /v1/instances/:name/logout
* Logout + apaga pasta de auth. Próxima conexão gera novo QR.
*/
router.post('/:name/logout', async (req: Request, res: Response) => {
try {
const { name } = req.params;
const result = await logoutInstance(name, config.authFolder);
if (!result.ok) {
return res.status(500).json({ ok: false, instance: name, error: result.error });
}
return res.json({ ok: true, instance: name });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return res.status(500).json({ ok: false, error: message });
}
});
/**
* DELETE /v1/instances/:name
* Remove a instância (fecha socket, não apaga credenciais em disco).
*/
router.delete('/:name', (req: Request, res: Response) => {
const { name } = req.params;
const removed = removeInstance(name);
return res.json({ ok: removed, instance: name });
});
export default router;
@@ -1,291 +0,0 @@
import { Router, type Request, type Response } from 'express';
import { getInstance } from '../services/whatsapp.js';
import { toJid, isConnected } from '../utils/helpers.js';
import { config } from '../config.js';
const router = Router();
function validateInstance(instanceName: string, res: Response): ReturnType<typeof getInstance> {
const ctx = getInstance(instanceName);
if (!ctx) {
res.status(404).json({ ok: false, error: 'instance_not_found' });
return undefined;
}
if (!isConnected(ctx)) {
res.status(400).json({ ok: false, error: 'instance_not_connected', status: ctx.status });
return undefined;
}
return ctx;
}
// --- 1. MENU TEXTO (opções numeradas) ---
router.post('/send_menu', async (req: Request, res: Response) => {
try {
const { instance = 'main', to, title, text, options, footer } = req.body as {
instance?: string;
to: string;
title?: string;
text?: string;
options: string[];
footer?: string;
};
if (!to || !Array.isArray(options) || options.length === 0) {
return res.status(400).json({ ok: false, error: 'missing to/options' });
}
const ctx = validateInstance(instance, res);
if (!ctx) return;
const jid = toJid(to);
if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' });
let menuText = '';
if (title) menuText += `*${title}*\n\n`;
if (text) menuText += `${text}\n\n`;
options.forEach((opt, idx) => {
const label = typeof opt === 'string' ? opt : (opt as { text?: string }).text ?? `Opção ${idx + 1}`;
menuText += `*${idx + 1}.* ${label}\n`;
});
if (footer) menuText += `\n_${footer}_`;
await ctx.sock.sendMessage(jid, { text: menuText.trim() });
return res.json({ ok: true, hint: 'User should reply with the option number (1, 2, 3...)' });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return res.status(500).json({ ok: false, error: message });
}
});
// --- 2. BOTÕES QUICK REPLY (nativeButtons) ---
router.post('/send_buttons_helpers', async (req: Request, res: Response) => {
try {
const { instance = 'main', to, text, buttons, footer } = req.body as {
instance?: string;
to: string;
text: string;
buttons: Array<{ id: string; text: string }>;
footer?: string;
};
if (!to || !text || !Array.isArray(buttons) || buttons.length === 0) {
return res.status(400).json({ ok: false, error: 'missing to/text/buttons' });
}
const limited = buttons.slice(0, config.limits.maxButtons);
const ctx = validateInstance(instance, res);
if (!ctx) return;
const jid = toJid(to);
if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' });
const nativeButtons = limited.map((btn, idx) => ({
type: 'reply' as const,
id: btn.id ?? `btn_${idx}`,
text: btn.text ?? `Botão ${idx + 1}`,
}));
const result = await ctx.sock.sendMessage(jid, {
nativeButtons,
text: String(text),
footer: footer ? String(footer) : undefined,
});
return res.json({ ok: true, format: 'nativeButtons', messageId: result?.key?.id });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return res.status(500).json({ ok: false, error: message });
}
});
// --- 3. BOTÕES CTA (URL, COPY, CALL) ---
router.post('/send_interactive_helpers', async (req: Request, res: Response) => {
try {
const { instance = 'main', to, text, buttons, footer } = req.body as {
instance?: string;
to: string;
text: string;
buttons: Array<{
type: 'url' | 'copy' | 'call';
text: string;
url?: string;
copyCode?: string;
copyText?: string;
phoneNumber?: string;
}>;
footer?: string;
};
if (!to || !text || !Array.isArray(buttons) || buttons.length === 0) {
return res.status(400).json({ ok: false, error: 'missing to/text/buttons' });
}
const ctx = validateInstance(instance, res);
if (!ctx) return;
const jid = toJid(to);
if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' });
const nativeButtons = buttons.slice(0, config.limits.maxButtons).map((btn, idx) => {
const type = (btn.type ?? 'reply').toLowerCase();
if (type === 'url' || btn.url) {
return { type: 'url' as const, text: btn.text ?? 'Abrir', url: btn.url! };
}
if (type === 'copy' || btn.copyCode || btn.copyText) {
return { type: 'copy' as const, text: btn.text ?? 'Copiar', copyText: btn.copyCode ?? btn.copyText ?? '' };
}
if (type === 'call' || btn.phoneNumber) {
return { type: 'call' as const, text: btn.text ?? 'Ligar', phoneNumber: btn.phoneNumber! };
}
return { type: 'reply' as const, id: `btn_${idx}`, text: btn.text ?? 'Botão' };
});
const result = await ctx.sock.sendMessage(jid, {
nativeButtons,
text: String(text),
footer: footer ? String(footer) : undefined,
});
return res.json({ ok: true, format: 'nativeButtons', messageId: result?.key?.id });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return res.status(500).json({ ok: false, error: message });
}
});
// --- 4. LISTA DROPDOWN (nativeList) ---
router.post('/send_list_helpers', async (req: Request, res: Response) => {
try {
const { instance = 'main', to, text, footer, buttonText, sections } = req.body as {
instance?: string;
to: string;
text: string;
footer?: string;
buttonText: string;
sections: Array<{ title: string; rows: Array<{ id: string; title: string; description?: string }> }>;
};
if (!to || !text || !buttonText || !Array.isArray(sections) || sections.length === 0) {
return res.status(400).json({ ok: false, error: 'missing to/text/buttonText/sections' });
}
const ctx = validateInstance(instance, res);
if (!ctx) return;
const jid = toJid(to);
if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' });
const result = await ctx.sock.sendMessage(jid, {
nativeList: {
buttonText: String(buttonText),
sections: sections.map((s) => ({
title: s.title ?? 'Opções',
rows: (s.rows ?? []).map((row, idx) => ({
id: row.id ?? `row_${idx}`,
title: row.title ?? 'Item',
description: row.description ?? '',
})),
})),
},
text: String(text),
footer: footer ? String(footer) : undefined,
});
return res.json({ ok: true, format: 'nativeList', messageId: result?.key?.id });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return res.status(500).json({ ok: false, error: message });
}
});
// --- 5. ENQUETE / POLL ---
router.post('/send_poll', async (req: Request, res: Response) => {
try {
const { instance = 'main', to, name, options, selectableCount } = req.body as {
instance?: string;
to: string;
name: string;
options: string[];
selectableCount?: number;
};
if (!to || !name || !Array.isArray(options) || options.length < 2) {
return res.status(400).json({ ok: false, error: 'missing to/name/options (min 2)' });
}
const opts = options.slice(0, config.limits.maxPollOptions).map((o) => (typeof o === 'string' ? o : String(o)));
const ctx = validateInstance(instance, res);
if (!ctx) return;
const jid = toJid(to);
if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' });
// Formato que funciona no InfiniteAPI/Baileys: poll com name, values e selectableCount
const result = await ctx.sock.sendMessage(jid, {
poll: {
name: String(name),
values: opts,
selectableCount: Math.min(Math.max(1, selectableCount ?? 1), opts.length),
},
});
return res.json({ ok: true, messageId: result?.key?.id });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return res.status(500).json({ ok: false, error: message });
}
});
// --- 6. CARROSSEL (nativeCarousel) ---
router.post('/send_carousel_helpers', async (req: Request, res: Response) => {
try {
const { instance = 'main', to, text, footer, cards } = req.body as {
instance?: string;
to: string;
text?: string;
footer?: string;
cards: Array<{
title?: string;
body?: string;
footer?: string;
imageUrl?: string;
buttons?: Array<{ id: string; text: string }>;
}>;
};
if (!to || !Array.isArray(cards) || cards.length === 0) {
return res.status(400).json({ ok: false, error: 'missing to/cards' });
}
const limited = cards.slice(0, config.limits.maxCarouselCards);
const ctx = validateInstance(instance, res);
if (!ctx) return;
const jid = toJid(to);
if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' });
const formattedCards = limited.map((card, idx) => ({
title: card.title ?? `Card ${idx + 1}`,
body: card.body ?? '',
footer: card.footer,
image: card.imageUrl ? { url: card.imageUrl } : undefined,
buttons: (card.buttons ?? []).map((btn, bIdx) => ({
type: 'reply' as const,
id: btn.id ?? `card${idx}_btn${bIdx}`,
text: btn.text ?? 'Botão',
})),
}));
const result = await ctx.sock.sendMessage(jid, {
nativeCarousel: { cards: formattedCards },
text: text ? String(text) : undefined,
footer: footer ? String(footer) : undefined,
});
return res.json({ ok: true, format: 'nativeCarousel', messageId: result?.key?.id });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return res.status(500).json({ ok: false, error: message });
}
});
export default router;
@@ -1,198 +0,0 @@
import path from 'node:path';
import fs from 'node:fs';
import type { InstanceContext } from '../types/whatsapp.js';
import { attachSuperAdminMenu } from '../handlers/superAdminMenu.js';
const instances = new Map<string, InstanceContext>();
function closeSocket(sock: InstanceContext['sock']): void {
try {
(sock as InstanceContext['sock']).ws?.close?.();
} catch {
// ignore
}
}
/**
* Retorna o contexto da instância pelo nome, ou undefined se não existir.
*/
export function getInstance(name: string): InstanceContext | undefined {
return instances.get(name);
}
/**
* Retorna todas as instâncias.
*/
export function getAllInstances(): InstanceContext[] {
return Array.from(instances.values());
}
/**
* Cria e inicia uma nova instância WhatsApp (InfiniteAPI/Baileys).
* Gera QR code até o usuário escanear e conectar.
* Em 515 (restartRequired) recria o socket automaticamente após 2s.
*/
export async function createInstance(
name: string,
authFolder: string
): Promise<{ ok: boolean; instance: string; qr?: string; error?: string }> {
if (instances.has(name)) {
const ctx = instances.get(name)!;
if (ctx.status === 'connected') {
return { ok: true, instance: name };
}
if (ctx.status === 'qr' && ctx.qr) {
return { ok: true, instance: name, qr: ctx.qr };
}
// disconnected ou connecting: remove e recria para nova tentativa
closeSocket(ctx.sock);
instances.delete(name);
}
try {
const {
default: makeWASocket,
useMultiFileAuthState,
DisconnectReason,
fetchLatestWaWebVersion,
Browsers,
} = await import('baileys');
const authPath = path.resolve(process.cwd(), authFolder, name);
const { state, saveCreds } = await useMultiFileAuthState(authPath);
let version: [number, number, number];
try {
const wa = await fetchLatestWaWebVersion({});
const v = wa.version;
version = Array.isArray(v) && v.length >= 3 ? [v[0], v[1], v[2]] : [2, 3000, 1032884366];
} catch {
version = [2, 3000, 1032884366];
}
const sock = makeWASocket({
auth: state,
printQRInTerminal: false,
version,
browser: Browsers.windows('Chrome'),
}) as InstanceContext['sock'];
const ctx: InstanceContext = {
name,
sock,
status: 'connecting',
qr: null,
createdAt: new Date(),
authFolder,
};
instances.set(name, ctx);
sock.ev.on('creds.update', saveCreds);
sock.ev.on('connection.update', ((update: unknown) => {
const { connection, qr, lastDisconnect } = (update ?? {}) as {
connection?: string;
qr?: string;
lastDisconnect?: { error?: { output?: { statusCode?: number } } };
};
if (qr) {
ctx.status = 'qr';
ctx.qr = qr;
}
if (connection === 'open') {
ctx.status = 'connected';
ctx.qr = null;
}
if (connection === 'close') {
const code = (lastDisconnect?.error as { output?: { statusCode?: number } } | undefined)?.output?.statusCode;
ctx.status = 'disconnected';
ctx.qr = null;
if (code === DisconnectReason.loggedOut || code === DisconnectReason.connectionReplaced) {
closeSocket(ctx.sock);
instances.delete(name);
return;
}
// 515 = restartRequired: pairing concluído, WA pede reinício. Recriar socket com o mesmo auth.
if (code === DisconnectReason.restartRequired) {
const folder = ctx.authFolder;
closeSocket(ctx.sock);
instances.delete(name);
setTimeout(() => {
createInstance(name, folder).catch(() => {});
}, 2000);
}
}
}));
attachSuperAdminMenu(sock, {
getSessionStatus: () => ctx.status,
resetSession: async () => {
await logoutInstance(name, authFolder);
const created = await createInstance(name, authFolder);
if (!created.ok) return `Erro ao resetar: ${created.error || 'desconhecido'}`;
return created.qr ? 'Sessao resetada. Novo QR gerado.' : 'Sessao resetada. Aguarde novo QR.';
},
logoutSession: async () => {
const result = await logoutInstance(name, authFolder);
if (!result.ok) return `Erro ao deslogar: ${result.error || 'desconhecido'}`;
return 'WhatsApp deslogado. Novo QR sera necessario.';
},
});
return { ok: true, instance: name, qr: ctx.qr ?? undefined };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { ok: false, instance: name, error: message };
}
}
/**
* Desconecta e remove a instância da memória (credenciais permanecem em disco).
*/
export function disconnectInstance(name: string): boolean {
const ctx = instances.get(name);
if (!ctx) return false;
closeSocket(ctx.sock);
instances.delete(name);
return true;
}
/**
* Logout + apaga pasta de auth e remove instância. Próxima conexão gerará novo QR.
*/
export async function logoutInstance(name: string, authFolder: string): Promise<{ ok: boolean; error?: string }> {
const ctx = instances.get(name);
if (ctx) {
try {
if (typeof ctx.sock.logout === 'function') {
await ctx.sock.logout();
}
} catch {
// ignore
}
closeSocket(ctx.sock);
instances.delete(name);
}
const authPath = path.resolve(process.cwd(), authFolder, name);
try {
if (fs.existsSync(authPath)) {
fs.rmSync(authPath, { recursive: true });
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { ok: false, error: message };
}
return { ok: true };
}
/**
* Remove a instância (fecha socket e remove do mapa). Não apaga credenciais.
*/
export function removeInstance(name: string): boolean {
return disconnectInstance(name);
}
@@ -1,90 +0,0 @@
/**
* Tipos para o socket InfiniteAPI/Baileys.
* O socket expõe sendMessage com nativeButtons, nativeList, nativeCarousel, etc.
*/
export interface InstanceContext {
name: string;
sock: WASocketLike;
status: 'connecting' | 'connected' | 'disconnected' | 'qr';
qr: string | null;
createdAt: Date;
authFolder: string;
}
export interface WASocketLike {
sendMessage: (jid: string, content: MessageContent) => Promise<{ key?: { id?: string }; messageTimestamp?: number }>;
relayMessage?: (jid: string, content: unknown, opts?: unknown) => Promise<unknown>;
ev: { on: (event: string, handler: (...args: unknown[]) => void) => void };
logout?: () => Promise<void>;
ws?: { close: () => void };
}
export interface NativeButtonReply {
type: 'reply';
id: string;
text: string;
}
export interface NativeButtonUrl {
type: 'url';
text: string;
url: string;
}
export interface NativeButtonCopy {
type: 'copy';
text: string;
copyText: string;
}
export interface NativeButtonCall {
type: 'call';
text: string;
phoneNumber: string;
}
export type NativeButton = NativeButtonReply | NativeButtonUrl | NativeButtonCopy | NativeButtonCall;
export interface NativeListRow {
id: string;
title: string;
description?: string;
}
export interface NativeListSection {
title: string;
rows: NativeListRow[];
}
export interface NativeCarouselCard {
title?: string;
body?: string;
footer?: string;
image?: { url: string };
imageUrl?: string;
buttons?: Array<{ type?: string; id: string; text: string }>;
}
export interface MessageContent {
text?: string;
footer?: string;
nativeButtons?: NativeButton[];
nativeList?: {
buttonText: string;
sections: NativeListSection[];
};
nativeCarousel?: {
cards: NativeCarouselCard[];
};
poll?: {
name: string;
values: string[];
selectableCount?: number;
};
pollCreationMessage?: {
name: string;
options: Array<{ optionName: string }>;
selectableOptionsCount?: number;
};
[key: string]: unknown;
}
@@ -1,25 +0,0 @@
/**
* Formata número para JID do WhatsApp (5511999999999@s.whatsapp.net)
*/
export function toJid(phone: string | null | undefined): string | null {
if (!phone || typeof phone !== 'string') return null;
const cleaned = phone.replace(/\D/g, '');
if (cleaned.length < 10) return null;
if (phone.includes('@')) return phone;
return `${cleaned}@s.whatsapp.net`;
}
/**
* Verifica se a instância está conectada
*/
export function isConnected(ctx: { sock?: unknown; status?: string } | null): boolean {
return Boolean(ctx?.sock && ctx.status === 'connected');
}
/**
* Verifica se string é URL
*/
export function isUrl(str: unknown): str is string {
if (typeof str !== 'string') return false;
return /^https?:\/\//i.test(str.trim());
}
-20
View File
@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
+282
View File
@@ -0,0 +1,282 @@
-- Clube de Benefícios - Production Schema
-- Generated for MySQL (sis_cb67)
SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS benefit_partner_links;
DROP TABLE IF EXISTS benefit_share_requests;
DROP TABLE IF EXISTS webhooks;
DROP TABLE IF EXISTS plugins;
DROP TABLE IF EXISTS logs;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS favorites;
DROP TABLE IF EXISTS transactions;
DROP TABLE IF EXISTS benefits;
DROP TABLE IF EXISTS partners;
DROP TABLE IF EXISTS categories;
DROP TABLE IF EXISTS oauth_accounts;
DROP TABLE IF EXISTS knex_migrations;
DROP TABLE IF EXISTS knex_migrations_lock;
DROP TABLE IF EXISTS users;
SET FOREIGN_KEY_CHECKS = 1;
-- users
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NULL,
phone VARCHAR(30) NULL,
cpf VARCHAR(14) UNIQUE NULL DEFAULT NULL,
city VARCHAR(100) NULL,
neighborhood VARCHAR(100) NULL,
gender ENUM('M','F','O') NULL,
birth_date DATE NULL,
avatar_url VARCHAR(500) NULL,
role ENUM('user','partner_admin','secretary','clinical_manager','finance','super_admin') NOT NULL DEFAULT 'user',
status ENUM('lead','pending','active','inactive') NOT NULL DEFAULT 'active',
email_verified BOOLEAN DEFAULT FALSE,
refresh_token VARCHAR(500) NULL,
last_login TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_email (email),
INDEX idx_role (role),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- oauth_accounts
CREATE TABLE oauth_accounts (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
provider VARCHAR(50) NOT NULL,
provider_user_id VARCHAR(255) NOT NULL,
access_token VARCHAR(1000) NULL,
refresh_token VARCHAR(1000) NULL,
token_expires_at TIMESTAMP NULL,
profile_data JSON NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_provider_user (provider, provider_user_id),
INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- categories
CREATE TABLE categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
slug VARCHAR(100) NOT NULL UNIQUE,
icon VARCHAR(50) NULL,
color VARCHAR(50) NULL,
description TEXT NULL,
active BOOLEAN DEFAULT TRUE,
sort_order INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- partners
CREATE TABLE partners (
id INT AUTO_INCREMENT PRIMARY KEY,
company_name VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
cnpj VARCHAR(20) UNIQUE NULL DEFAULT NULL,
email VARCHAR(255) NULL,
phone VARCHAR(30) NULL,
address_street VARCHAR(255) NULL,
address_number VARCHAR(20) NULL,
address_neighborhood VARCHAR(100) NULL,
address_city VARCHAR(100) NULL,
address_state VARCHAR(2) NULL,
address_zip VARCHAR(10) NULL,
type VARCHAR(100) NOT NULL,
monthly_goal INT DEFAULT 0,
status ENUM('active','inactive') NOT NULL DEFAULT 'active',
gradient_from VARCHAR(50) NULL,
gradient_to VARCHAR(50) NULL,
icon VARCHAR(10) NULL,
description TEXT NULL,
logo_url VARCHAR(500) NULL,
images JSON NULL,
domain VARCHAR(255) NULL,
subdomain VARCHAR(255) NULL,
subscription_plan ENUM('basic','pro','enterprise') DEFAULT 'basic',
owner_user_id INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_slug (slug),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- benefits
CREATE TABLE benefits (
id INT AUTO_INCREMENT PRIMARY KEY,
partner_id INT NOT NULL,
category_id INT NULL,
title VARCHAR(255) NOT NULL,
description TEXT NULL,
rules TEXT NULL,
contact VARCHAR(255) NULL,
type VARCHAR(50) NOT NULL,
delivery_type ENUM('local','external') NOT NULL DEFAULT 'local',
active BOOLEAN DEFAULT TRUE,
is_global BOOLEAN DEFAULT FALSE,
approved_by_admin BOOLEAN DEFAULT FALSE,
global_request_status ENUM('none','pending','approved') DEFAULT 'none',
priority INT DEFAULT 0,
discount_percent DECIMAL(5,2) NULL,
valid_until DATE NULL,
featured_image VARCHAR(500) NULL,
redemption_type ENUM('coupon','qrcode','card','app') DEFAULT 'coupon',
usage_count INT DEFAULT 0,
savings_amount DECIMAL(12,2) DEFAULT 0,
badge VARCHAR(50) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_partner (partner_id),
INDEX idx_category (category_id),
INDEX idx_active (active),
INDEX idx_type (type),
FOREIGN KEY (partner_id) REFERENCES partners(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- transactions
CREATE TABLE transactions (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
benefit_id INT NOT NULL,
partner_id INT NOT NULL,
type ENUM('redemption','view','favorite','share') NOT NULL,
status ENUM('pending','completed','cancelled','expired') DEFAULT 'pending',
redemption_code VARCHAR(100) NULL,
qr_code_data VARCHAR(500) NULL,
original_value DECIMAL(12,2) NULL,
discount_value DECIMAL(12,2) NULL,
redeemed_at TIMESTAMP NULL,
expires_at TIMESTAMP NULL,
metadata JSON NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_benefit (benefit_id),
INDEX idx_status (status),
INDEX idx_code (redemption_code),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (benefit_id) REFERENCES benefits(id) ON DELETE CASCADE,
FOREIGN KEY (partner_id) REFERENCES partners(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- favorites
CREATE TABLE favorites (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
benefit_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_user_benefit (user_id, benefit_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (benefit_id) REFERENCES benefits(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- sessions
CREATE TABLE sessions (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token_hash VARCHAR(255) NOT NULL,
ip_address VARCHAR(45) NULL,
user_agent VARCHAR(500) NULL,
expires_at TIMESTAMP NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_token (token_hash),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- logs (audit)
CREATE TABLE logs (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL,
action VARCHAR(100) NOT NULL,
entity_type VARCHAR(100) NULL,
entity_id INT NULL,
description TEXT NULL,
old_data JSON NULL,
new_data JSON NULL,
ip_address VARCHAR(45) NULL,
user_agent VARCHAR(500) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_action (action),
INDEX idx_entity (entity_type),
INDEX idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- plugins
CREATE TABLE plugins (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
slug VARCHAR(255) NOT NULL UNIQUE,
version VARCHAR(20) NOT NULL,
description TEXT NULL,
author VARCHAR(255) NULL,
entry_point VARCHAR(255) NOT NULL,
active BOOLEAN DEFAULT FALSE,
dependencies JSON NULL,
config JSON NULL,
hooks JSON NULL,
directory VARCHAR(500) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_slug (slug),
INDEX idx_active (active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- webhooks
CREATE TABLE webhooks (
id INT AUTO_INCREMENT PRIMARY KEY,
partner_id INT NULL,
url VARCHAR(500) NOT NULL,
secret VARCHAR(255) NULL,
events JSON NOT NULL,
active BOOLEAN DEFAULT TRUE,
retry_count INT DEFAULT 0,
max_retries INT DEFAULT 3,
last_triggered TIMESTAMP NULL,
last_status ENUM('success','failed','pending') NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_partner (partner_id),
INDEX idx_active (active),
FOREIGN KEY (partner_id) REFERENCES partners(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- benefit_share_requests
CREATE TABLE benefit_share_requests (
id INT AUTO_INCREMENT PRIMARY KEY,
benefit_id INT NOT NULL,
requesting_partner_id INT NOT NULL,
owner_partner_id INT NOT NULL,
status ENUM('pending','approved','rejected') DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (benefit_id) REFERENCES benefits(id) ON DELETE CASCADE,
FOREIGN KEY (requesting_partner_id) REFERENCES partners(id) ON DELETE CASCADE,
FOREIGN KEY (owner_partner_id) REFERENCES partners(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- benefit_partner_links
CREATE TABLE benefit_partner_links (
id INT AUTO_INCREMENT PRIMARY KEY,
benefit_id INT NOT NULL,
partner_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_benefit_partner (benefit_id, partner_id),
FOREIGN KEY (benefit_id) REFERENCES benefits(id) ON DELETE CASCADE,
FOREIGN KEY (partner_id) REFERENCES partners(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-18
View File
@@ -1,18 +0,0 @@
import { db } from '../src/config/database';
async function run() {
try {
const benefits = await db('benefits')
.select('id', 'title', 'public_title', 'public_description', 'public_hide_values')
.limit(5)
.orderBy('updated_at', 'desc');
console.log('Recent Benefits Data:', JSON.stringify(benefits, null, 2));
} catch (error) {
console.error('Error:', error);
} finally {
await db.destroy();
}
}
run();
-26
View File
@@ -1,26 +0,0 @@
import { db } from '../src/config/database';
async function run() {
try {
const id = 1; // Assuming ID 1 exists
const payload = {
public_title: "Teste de Título Público",
public_description: "Descrição pública de teste via script",
public_hide_values: true
};
console.log(`Updating benefit ${id} with:`, payload);
await db('benefits').where({ id }).update(payload);
const updated = await db('benefits').where({ id }).first();
console.log('Updated Benefit:', JSON.stringify(updated, null, 2));
} catch (error) {
console.error('Error:', error);
} finally {
await db.destroy();
}
}
run();
+36
View File
@@ -0,0 +1,36 @@
-- Clube de Benefícios - Seed Data
-- Super Admin (password: Rc362514 hashed with bcrypt)
INSERT INTO users (name, email, password_hash, role, status, email_verified) VALUES
('Super Admin', 'ruibto@gmail.com', '$2a$12$PLACEHOLDER_HASH', 'super_admin', 'active', TRUE);
-- Categories
INSERT INTO categories (name, slug, icon, color, sort_order) VALUES
('Gastronomia', 'gastronomia', '🍽️', '#FF6B35', 1),
('Saúde', 'saude', '🏥', '#4CAF50', 2),
('Educação', 'educacao', '📚', '#2196F3', 3),
('Lazer', 'lazer', '🎭', '#9C27B0', 4),
('Esportes', 'esportes', '', '#FF9800', 5),
('Compras', 'compras', '🛍️', '#E91E63', 6),
('Serviços', 'servicos', '🔧', '#607D8B', 7),
('Viagens', 'viagens', '✈️', '#00BCD4', 8),
('Beleza & Estética', 'beleza-estetica', '💅', '#F06292', 9),
('Odontologia', 'odontologia', '🦷', '#26A69A', 10),
('Nutrição', 'nutricao', '🥗', '#8BC34A', 11),
('Farmácia', 'farmacia', '💊', '#EF5350', 12);
-- Partners
INSERT INTO partners (company_name, slug, cnpj, phone, address_street, address_number, address_neighborhood, address_city, address_state, address_zip, type, monthly_goal, status, gradient_from, gradient_to, icon, description, owner_user_id) VALUES
('Escola Coração de Maria', 'coracaodemaria', '11.222.333/0001-44', '(67) 3333-1111', 'Rua das Flores', '123', 'Centro', 'Campo Grande', 'MS', '79000-001', 'Escola', 50, 'active', '#3B82F6', '#1D4ED8', '🏫', 'Instituição de ensino fundamental e médio.', 1),
('Drogasil Filial Centro', 'drogasilcentro', '44.555.666/0001-77', '(67) 3333-2222', 'Avenida Principal', '456', 'Tiradentes', 'Campo Grande', 'MS', '79000-002', 'Farmácia', 75, 'active', '#EF4444', '#B91C1C', '⚕️', 'Rede de farmácias com ampla variedade.', 1),
('SmartFit Unidade Centro', 'smartfitcentro', NULL, '(67) 3333-3333', 'Travessa Esportiva', '789', 'Aero Rancho', 'Campo Grande', 'MS', '79000-003', 'Academia', 100, 'active', '#EAB308', '#F97316', '🏋️', 'Academia com equipamentos modernos.', 1),
('Consultt Clinic', 'consulttclinic', NULL, '(67) 9999-0000', 'Rua Odonto', '100', 'Centro', 'Campo Grande', 'MS', '79000-100', 'Odontologia', 30, 'active', '#06B6D4', '#3B82F6', '🦷', 'Clínica odontológica de referência.', 1),
('Escola de Música Som do Coração', 'somdocoracao', NULL, '(67) 9999-1112', 'Rua da Harmonia', '200', 'Centro', 'Campo Grande', 'MS', '79000-200', 'Curso', 20, 'active', '#A855F7', '#6366F1', '🎵', 'Aulas de música para todas as idades.', 1),
('Ótica Central', 'oticacentral', NULL, '(67) 9999-4444', 'Avenida da Visão', '300', 'Tiradentes', 'Campo Grande', 'MS', '79000-300', 'Ótica', 40, 'active', '#14B8A6', '#22C55E', '👓', 'Soluções para saúde visual.', 1);
-- Benefits
INSERT INTO benefits (partner_id, category_id, title, description, rules, contact, type, delivery_type, active, is_global, approved_by_admin, global_request_status, priority, discount_percent, valid_until, redemption_type) VALUES
(4, 10, 'Benefício Saúde Bucal', 'Cuidado odontológico completo com condições especiais para membros.', 'Benefício válido para membros ativos. Descontos não acumulativos.', '(67) 9999-0000', 'dental', 'external', TRUE, TRUE, TRUE, 'approved', 1, 30.00, '2026-12-31', 'qrcode'),
(5, 3, 'Aula de Violão Gratuita', 'Experimente uma aula de violão com nossos melhores professores.', 'Válido para novos alunos.', '(67) 9999-1112', 'education', 'local', TRUE, FALSE, TRUE, 'none', 3, 100.00, '2026-06-30', 'coupon'),
(5, 3, '20% de Desconto na Matrícula', 'Comece seus estudos musicais com um super desconto.', 'Válido para planos semestrais.', '(67) 9999-1112', 'education', 'local', TRUE, FALSE, FALSE, 'none', 3, 20.00, '2026-12-31', 'coupon'),
(6, 2, '25% OFF em Armações', 'Escolha qualquer armação da loja com desconto exclusivo.', 'Válido para pagamentos à vista.', '(67) 9999-4444', 'health', 'external', TRUE, FALSE, TRUE, 'none', 3, 25.00, '2026-12-31', 'coupon');
-67
View File
@@ -1,67 +0,0 @@
const { Client } = require('pg');
const bcrypt = require('bcryptjs');
const dotenv = require('dotenv');
const path = require('path');
dotenv.config({ path: path.join(__dirname, '.env') });
const client = new Client({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
});
async function main() {
try {
await client.connect();
console.log('Connected to PostgreSQL to seed Super Admin and initial configurations...');
const email = 'ruibto@gmail.com';
const password = 'Rc362514';
const name = 'Rui Dono';
const hash = await bcrypt.hash(password, 10);
// Check if user already exists
const check = await client.query('SELECT id FROM users WHERE email = $1', [email]);
if (check.rows.length > 0) {
console.log('Super Admin already exists!');
} else {
// Insert Super Admin
await client.query(
'INSERT INTO users (name, email, password_hash, role, status, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, NOW(), NOW())',
[name, email, hash, 'super_admin', 'active']
);
console.log('Super Admin seeded successfully!');
}
// Seed default categories for Clube67
const categories = [
{ name: 'Gastronomia', slug: 'gastronomia', icon: 'Utensils' },
{ name: 'Saúde & Bem-Estar', slug: 'saude-e-bem-estar', icon: 'HeartPulse' },
{ name: 'Educação', slug: 'educacao', icon: 'GraduationCap' },
{ name: 'Lazer & Viagens', slug: 'lazer-e-viagens', icon: 'Compass' },
{ name: 'Serviços', slug: 'servicos', icon: 'Wrench' },
{ name: 'Moda & Beleza', slug: 'moda-e-beleza', icon: 'Sparkles' }
];
for (const cat of categories) {
const cCheck = await client.query('SELECT id FROM categories WHERE slug = $1', [cat.slug]);
if (cCheck.rows.length === 0) {
await client.query(
'INSERT INTO categories (name, slug, icon, created_at, updated_at) VALUES ($1, $2, $3, NOW(), NOW())',
[cat.name, cat.slug, cat.icon]
);
console.log(`Category '${cat.name}' seeded!`);
}
}
await client.end();
console.log('Database seeding successfully finished!');
} catch (err) {
console.error('Seeding failed:', err);
}
}
main();
+19 -12
View File
@@ -1,23 +1,30 @@
import knex from 'knex';
import { config } from './index';
import path from 'path';
const knexConfig = {
client: 'pg',
const db = knex({
client: 'mysql2',
connection: {
host: config.db.host,
port: config.db.port,
user: config.db.user,
password: config.db.password,
database: config.db.database,
database: config.db.name,
charset: 'utf8mb4',
timezone: '+00:00',
},
pool: {
min: 2,
max: 20,
acquireTimeoutMillis: 30000,
idleTimeoutMillis: 30000,
},
pool: { min: 2, max: 10 },
migrations: {
directory: path.join(__dirname, '../database/migrations'),
tableName: 'knex_migrations'
}
};
directory: __dirname + '/../migrations',
tableName: 'knex_migrations',
},
seeds: {
directory: __dirname + '/../seeds',
},
});
export const db = knex(knexConfig);
export default knexConfig;
export default db;
+22 -20
View File
@@ -1,24 +1,26 @@
import dotenv from 'dotenv';
import path from 'path';
import dotenv from 'dotenv';
// Load .env with multiple fallback paths to support Knex CLI running in different subdirectories
dotenv.config({ path: path.join(process.cwd(), '.env') });
dotenv.config({ path: path.join(__dirname, '../../.env') });
dotenv.config({ path: path.join(__dirname, '../../../.env') });
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
export const config = {
env: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || process.env.APP_PORT || '3001', 10),
app: {
name: process.env.APP_NAME || 'Clube de Benefícios',
url: process.env.APP_URL || 'http://localhost:3001',
port: parseInt(process.env.APP_PORT || '3001', 10),
env: process.env.NODE_ENV || 'development',
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000',
},
db: {
host: process.env.DB_HOST,
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '3306', 10),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
user: process.env.DB_USER || 'sis_cb67',
password: process.env.DB_PASSWORD || '',
name: process.env.DB_NAME || 'sis_cb67',
},
jwt: {
secret: process.env.JWT_SECRET || 'secret',
refreshSecret: process.env.JWT_REFRESH_SECRET || 'refresh_secret',
secret: process.env.JWT_SECRET || 'change-me',
refreshSecret: process.env.JWT_REFRESH_SECRET || 'change-me-refresh',
expiresIn: process.env.JWT_EXPIRES_IN || '15m',
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
},
@@ -28,15 +30,15 @@ export const config = {
password: process.env.REDIS_PASSWORD || undefined,
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackUrl: process.env.GOOGLE_CALLBACK_URL,
clientId: process.env.GOOGLE_CLIENT_ID || '',
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
callbackUrl: process.env.GOOGLE_CALLBACK_URL || 'http://localhost:3001/api/auth/google/callback',
},
upload: {
dir: process.env.UPLOAD_DIR || path.join(__dirname, '../../../uploads'),
dir: process.env.UPLOAD_DIR || path.resolve(__dirname, '../../../uploads'),
maxSize: parseInt(process.env.MAX_FILE_SIZE || '5242880', 10),
},
logging: {
dir: process.env.LOG_DIR || path.join(__dirname, '../../../logs'),
}
log: {
dir: process.env.LOG_DIR || path.resolve(__dirname, '../../../logs'),
},
};
+39 -11
View File
@@ -1,16 +1,44 @@
import { config } from './index';
export default {
client: 'pg',
connection: {
host: config.db.host,
port: config.db.port,
user: config.db.user,
password: config.db.password,
database: config.db.database,
const knexConfig = {
development: {
client: 'mysql2',
connection: {
host: config.db.host,
port: config.db.port,
user: config.db.user,
password: config.db.password,
database: config.db.name,
charset: 'utf8mb4',
},
migrations: {
directory: __dirname + '/../migrations',
tableName: 'knex_migrations',
},
seeds: {
directory: __dirname + '/../seeds',
},
},
migrations: {
directory: '../database/migrations',
extension: 'ts',
production: {
client: 'mysql2',
connection: {
host: config.db.host,
port: config.db.port,
user: config.db.user,
password: config.db.password,
database: config.db.name,
charset: 'utf8mb4',
},
pool: { min: 2, max: 20 },
migrations: {
directory: __dirname + '/../migrations',
tableName: 'knex_migrations',
},
seeds: {
directory: __dirname + '/../seeds',
},
},
};
export default knexConfig;
module.exports = knexConfig;
-3
View File
@@ -1,3 +0,0 @@
import { logger } from '../utils/logger';
export { logger };
export default logger;
+15 -5
View File
@@ -1,13 +1,23 @@
import Redis from 'ioredis';
import { config } from './index';
import { logger } from '../utils/logger';
export const redis = new Redis({
const redis = new Redis({
host: config.redis.host,
port: config.redis.port,
password: config.redis.password,
retryStrategy: (times) => Math.min(times * 50, 2000),
retryStrategy(times) {
const delay = Math.min(times * 50, 2000);
return delay;
},
maxRetriesPerRequest: 3,
});
redis.on('connect', () => logger.info('✅ Redis connected'));
redis.on('error', (err) => logger.error(' Redis error:', err));
redis.on('connect', () => {
console.log(' Redis connected');
});
redis.on('error', (err) => {
console.error('❌ Redis error:', err.message);
});
export default redis;
+122
View File
@@ -0,0 +1,122 @@
import { Response } from 'express';
import db from '../config/database';
import redis from '../config/redis';
import { AuthRequest } from '../middleware/auth';
export const adminController = {
async getDashboard(req: AuthRequest, res: Response) {
try {
// Try cache first
const cacheKey = 'admin:dashboard';
const cached = await redis.get(cacheKey).catch(() => null);
if (cached) return res.json(JSON.parse(cached));
const [users] = await db('users').count('* as total');
const [activeUsers] = await db('users').where('status', 'active').count('* as total');
const [partners] = await db('partners').count('* as total');
const [activePartners] = await db('partners').where('status', 'active').count('* as total');
const [benefits] = await db('benefits').count('* as total');
const [activeBenefits] = await db('benefits').where('active', true).count('* as total');
const [transactions] = await db('transactions').count('* as total');
const [plugins] = await db('plugins').count('* as total');
const [activePlugins] = await db('plugins').where('active', true).count('* as total');
// Recent registrations (last 30 days by day)
const recentUsers = await db('users')
.select(db.raw('DATE(created_at) as date'))
.count('* as count')
.where('created_at', '>=', db.raw('DATE_SUB(NOW(), INTERVAL 30 DAY)'))
.groupByRaw('DATE(created_at)')
.orderBy('date');
// Top categories
const topCategories = await db('benefits')
.leftJoin('categories', 'benefits.category_id', 'categories.id')
.select('categories.name')
.count('* as count')
.groupBy('categories.name')
.orderBy('count', 'desc')
.limit(10);
const dashboard = {
stats: {
totalUsers: Number(users.total),
activeUsers: Number(activeUsers.total),
totalPartners: Number(partners.total),
activePartners: Number(activePartners.total),
totalBenefits: Number(benefits.total),
activeBenefits: Number(activeBenefits.total),
totalTransactions: Number(transactions.total),
totalPlugins: Number(plugins.total),
activePlugins: Number(activePlugins.total),
},
charts: {
recentUsers,
topCategories,
},
};
await redis.setex(cacheKey, 300, JSON.stringify(dashboard)).catch(() => { });
res.json(dashboard);
} catch (err: any) {
res.status(500).json({ error: 'Erro ao buscar dashboard', details: err.message });
}
},
async getAuditLogs(req: AuthRequest, res: Response) {
try {
const { page = 1, limit = 50, action, userId } = req.query;
const offset = (Number(page) - 1) * Number(limit);
let query = db('logs')
.leftJoin('users', 'logs.user_id', 'users.id')
.select('logs.*', 'users.name as user_name', 'users.email as user_email');
if (action) query = query.where('logs.action', action as string);
if (userId) query = query.where('logs.user_id', userId as string);
const [{ total }] = await db('logs').count('* as total');
const logs = await query.orderBy('logs.created_at', 'desc').limit(Number(limit)).offset(offset);
res.json({
logs,
pagination: { page: Number(page), limit: Number(limit), total: Number(total) },
});
} catch (err: any) {
res.status(500).json({ error: 'Erro ao buscar logs' });
}
},
async getMetrics(req: AuthRequest, res: Response) {
try {
const period = (req.query.period as string) || '30d';
const days = period === '7d' ? 7 : period === '90d' ? 90 : 30;
const newUsers = await db('users')
.select(db.raw('DATE(created_at) as date'))
.count('* as count')
.where('created_at', '>=', db.raw(`DATE_SUB(NOW(), INTERVAL ${days} DAY)`))
.groupByRaw('DATE(created_at)')
.orderBy('date');
const newPartners = await db('partners')
.select(db.raw('DATE(created_at) as date'))
.count('* as count')
.where('created_at', '>=', db.raw(`DATE_SUB(NOW(), INTERVAL ${days} DAY)`))
.groupByRaw('DATE(created_at)')
.orderBy('date');
const redemptions = await db('transactions')
.select(db.raw('DATE(created_at) as date'))
.count('* as count')
.where('type', 'redemption')
.where('created_at', '>=', db.raw(`DATE_SUB(NOW(), INTERVAL ${days} DAY)`))
.groupByRaw('DATE(created_at)')
.orderBy('date');
res.json({ metrics: { newUsers, newPartners, redemptions } });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao buscar métricas' });
}
},
};
+163 -135
View File
@@ -1,155 +1,183 @@
import { Request, Response } from 'express';
import { Response } from 'express';
import bcrypt from 'bcryptjs';
import { db } from '../config/database';
import { generateTokens } from '../middleware/auth';
import db from '../config/database';
import { AuthRequest, generateTokens } from '../middleware/auth';
import { auditLog } from '../utils/audit';
export async function register(req: Request, res: Response) {
const { name, email, password, type, whatsapp, address, pj_data } = req.body;
// Validations (before transaction)
if (!name || !email || !password) {
return res.status(400).json({ error: 'Campos obrigatórios faltando (nome, email, senha)' });
}
const trx = await db.transaction();
try {
const existingUser = await trx('users').where({ email }).first();
if (existingUser) {
await trx.rollback();
return res.status(400).json({ error: 'E-mail já cadastrado' });
}
const password_hash = await bcrypt.hash(password, 10);
// Create User
const [userId] = await trx('users').insert({
name,
email,
password_hash,
whatsapp,
role: type === 'partner' ? 'partner_admin' : 'user',
state: address?.state || null,
// city: address?.city, // Column doesn't exist yet, ignoring to avoid crash
// neighborhood: address?.neighborhood, // Column doesn't exist yet
status: 'active', // Users are active by default (partners rely on partner status)
created_at: new Date(),
updated_at: new Date()
});
// Create Partner (PJ)
if (type === 'partner' && pj_data) {
// Check existing CNPJ
if (pj_data.cnpj) {
const existingPartner = await trx('partners').where({ cnpj: pj_data.cnpj }).first();
if (existingPartner) {
await trx.rollback();
return res.status(400).json({ error: 'CNPJ já cadastrado' });
}
export const authController = {
async register(req: AuthRequest, res: Response) {
try {
const { name, email, password, phone, cpf } = req.body;
if (!name || !email || !password) {
return res.status(400).json({ error: 'Nome, email e senha são obrigatórios' });
}
const [partnerId] = await trx('partners').insert({
company_name: pj_data.fantasy_name,
slug: null, // Removed automatic random slug. User will choose via modal.
cnpj: pj_data.cnpj || null,
email, // Contact email
phone: whatsapp,
address_street: address?.street,
address_number: address?.number,
address_neighborhood: address?.neighborhood,
address_city: address?.city,
address_state: address?.state,
type: String(pj_data.category_id || 'Serviços'),
status: 'inactive', // 'pending' doesn't exist in enum ('active','inactive'), so using 'inactive' as pending approval
owner_user_id: userId,
created_at: new Date(),
updated_at: new Date(),
data_consent: 1 // Implicit consent
const existing = await db('users').where('email', email).first();
if (existing) {
return res.status(409).json({ error: 'Email já cadastrado' });
}
const passwordHash = await bcrypt.hash(password, 12);
const [userId] = await db('users').insert({
name, email, password_hash: passwordHash, phone, cpf,
role: 'user', status: 'active', email_verified: false,
});
// Link user to partner
await trx('users').where({ id: userId }).update({ partner_id: partnerId });
const user = await db('users').where('id', userId).first();
const tokens = generateTokens(user);
await db('users').where('id', userId).update({ refresh_token: tokens.refreshToken });
await auditLog(req, {
user_id: userId, action: 'user.register',
entity_type: 'users', entity_id: userId,
description: `Novo usuário registrado: ${email}`,
});
const { password_hash: _, refresh_token: __, ...safeUser } = user;
res.status(201).json({ user: safeUser, ...tokens });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao registrar', details: err.message });
}
},
await trx.commit();
async login(req: AuthRequest, res: Response) {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email e senha são obrigatórios' });
}
// Auto-login response
const user = await db('users').where({ id: userId }).first();
const tokens = generateTokens(user);
const redirectPath = getRedirectPath(user);
const user = await db('users').where('email', email).first();
if (!user || !user.password_hash) {
return res.status(401).json({ error: 'Credenciais inválidas' });
}
// Log action (outside transaction)
await auditLog(user.id, 'REGISTER', 'user', user.id, `Novo cadastro: ${type}`, req.ip);
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
return res.status(401).json({ error: 'Credenciais inválidas' });
}
res.status(201).json({ user, redirectPath, ...tokens });
const tokens = generateTokens(user);
await db('users').where('id', user.id).update({
refresh_token: tokens.refreshToken,
last_login: new Date(),
});
} catch (err: any) {
await trx.rollback();
console.error('Register Error:', err);
res.status(500).json({ error: 'Erro ao criar conta: ' + (err.message || 'Erro interno') });
}
}
await auditLog(req, {
user_id: user.id, action: 'user.login',
entity_type: 'users', entity_id: user.id,
description: `Login realizado: ${email}`,
});
export async function login(req: Request, res: Response) {
try {
const { email, password } = req.body;
const user = await db('users').where({ email }).first();
if (!user || user.deleted_at || user.status === 'banned') {
return res.status(401).json({ error: 'Credenciais inválidas ou conta desativada' });
const { password_hash: _, refresh_token: __, ...safeUser } = user;
res.json({ user: safeUser, ...tokens });
} catch (err: any) {
res.status(500).json({ error: 'Erro no login', details: err.message });
}
if (!(await bcrypt.compare(password, user.password_hash))) {
return res.status(401).json({ error: 'Credenciais inválidas' });
},
async refresh(req: AuthRequest, res: Response) {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({ error: 'Refresh token obrigatório' });
}
const jwt = require('jsonwebtoken');
const { config } = require('../config');
const decoded = jwt.verify(refreshToken, config.jwt.refreshSecret);
const user = await db('users').where('id', decoded.id).first();
if (!user || user.refresh_token !== refreshToken) {
return res.status(401).json({ error: 'Refresh token inválido' });
}
const tokens = generateTokens(user);
await db('users').where('id', user.id).update({ refresh_token: tokens.refreshToken });
res.json(tokens);
} catch {
res.status(401).json({ error: 'Refresh token inválido ou expirado' });
}
const tokens = generateTokens(user);
await auditLog(user.id, 'LOGIN', 'user', user.id, 'Usuário logou no sistema', req.ip);
const redirectPath = getRedirectPath(user);
res.json({ user, redirectPath, ...tokens });
} catch (err: any) {
res.status(500).json({ error: 'Erro no servidor' });
}
}
},
function getRedirectPath(user: any) {
if (user.role === 'super_admin') return '/admin/dashboard';
if (user.role === 'partner' || user.role === 'partner_admin') {
return '/partner/dashboard';
}
return '/user/dashboard';
}
export async function profile(req: Request, res: Response) {
try {
const user = await db('users')
.select('users.*', 'partners.slug as partner_slug')
.leftJoin('partners', 'users.partner_id', 'partners.id')
.where('users.id', req.user!.id)
.first();
res.json(user);
} catch (err) {
res.status(500).json({ error: 'Erro ao buscar perfil' });
}
}
export async function completeProfile(req: Request, res: Response) {
try {
const userId = req.user!.id;
const { whatsapp, state, city, neighborhood } = req.body || {};
if (!whatsapp || !state || !city || !neighborhood) {
return res.status(400).json({ error: 'Campos obrigatórios faltando' });
async me(req: AuthRequest, res: Response) {
try {
const user = await db('users').where('id', req.user!.id).first();
if (!user) return res.status(404).json({ error: 'Usuário não encontrado' });
const { password_hash: _, refresh_token: __, ...safeUser } = user;
res.json({ user: safeUser });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao buscar perfil', details: err.message });
}
},
await db('users').where({ id: userId }).update({
whatsapp,
state,
city,
neighborhood,
updated_at: new Date(),
});
async logout(req: AuthRequest, res: Response) {
try {
await db('users').where('id', req.user!.id).update({ refresh_token: null });
await auditLog(req, {
user_id: req.user!.id, action: 'user.logout',
entity_type: 'users', entity_id: req.user!.id,
});
res.json({ message: 'Logout realizado' });
} catch (err: any) {
res.status(500).json({ error: 'Erro no logout' });
}
},
const user = await db('users').where({ id: userId }).first();
res.json(user);
} catch (err: any) {
res.status(500).json({ error: 'Erro ao completar cadastro' });
}
}
async googleCallback(req: AuthRequest, res: Response) {
try {
const { token, profile } = req.body; // from frontend google SDK
if (!profile?.email) {
return res.status(400).json({ error: 'Perfil Google inválido' });
}
let user = await db('users').where('email', profile.email).first();
if (!user) {
const [userId] = await db('users').insert({
name: profile.name || profile.email.split('@')[0],
email: profile.email,
avatar_url: profile.picture || null,
role: 'user', status: 'active', email_verified: true,
});
user = await db('users').where('id', userId).first();
}
// Link OAuth account
const existingOauth = await db('oauth_accounts')
.where({ provider: 'google', provider_user_id: profile.sub || profile.id })
.first();
if (!existingOauth) {
await db('oauth_accounts').insert({
user_id: user.id,
provider: 'google',
provider_user_id: profile.sub || profile.id,
access_token: token,
profile_data: JSON.stringify(profile),
});
} else {
await db('oauth_accounts')
.where('id', existingOauth.id)
.update({ access_token: token, profile_data: JSON.stringify(profile) });
}
const tokens = generateTokens(user);
await db('users').where('id', user.id).update({
refresh_token: tokens.refreshToken,
last_login: new Date(),
});
await auditLog(req, {
user_id: user.id, action: 'user.google_login',
entity_type: 'users', entity_id: user.id,
description: `Login Google: ${profile.email}`,
});
const { password_hash: _, refresh_token: __, ...safeUser } = user;
res.json({ user: safeUser, ...tokens });
} catch (err: any) {
res.status(500).json({ error: 'Erro na autenticação Google', details: err.message });
}
},
};
+195 -143
View File
@@ -1,160 +1,212 @@
import { Request, Response } from 'express';
import { db } from '../config/database';
import { Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
import QRCode from 'qrcode';
import db from '../config/database';
import { AuthRequest } from '../middleware/auth';
import { auditLog } from '../utils/audit';
export async function getAll(req: Request, res: Response) {
// If user is super_admin, return all. If partner, return valid ones (frontend filters but backend should optionally filter too)
// For now, let's return all and let frontend/other controllers filter.
// Ideally we should check role here or receive query param.
// Given the task is for Admin Dashboard, let's return all.
const benefits = await db('benefits').select('benefits.*', 'partners.company_name as partner_name', 'partners.icon as partner_icon', 'partners.slug as partner_slug')
.leftJoin('partners', 'benefits.partner_id', 'partners.id')
.orderBy('created_at', 'desc');
res.json({ benefits });
}
export const benefitController = {
async list(req: AuthRequest, res: Response) {
try {
const { page = 1, limit = 20, search, category, type, partnerId, active } = req.query;
const offset = (Number(page) - 1) * Number(limit);
export async function getCategories(req: Request, res: Response) {
const categories = await db('categories');
res.json({ categories });
}
let query = db('benefits')
.leftJoin('categories', 'benefits.category_id', 'categories.id')
.leftJoin('partners', 'benefits.partner_id', 'partners.id')
.select(
'benefits.*',
'categories.name as category_name',
'categories.icon as category_icon',
'partners.company_name as partner_name',
'partners.slug as partner_slug'
);
export async function useBenefit(req: Request, res: Response) {
const { id } = req.params;
const userId = req.user!.id; // Assumes auth middleware populates req.user
if (search) query = query.where('benefits.title', 'like', `%${search}%`);
if (category) query = query.where('benefits.category_id', category);
if (type) query = query.where('benefits.type', type);
if (partnerId) query = query.where('benefits.partner_id', partnerId);
if (active !== undefined) query = query.where('benefits.active', active === 'true');
try {
const benefit = await db('benefits').where({ id }).first();
if (!benefit) {
return res.status(404).json({ error: 'Benefício não encontrado' });
}
const countQuery = db('benefits');
if (search) countQuery.where('title', 'like', `%${search}%`);
const [{ total }] = await countQuery.count('* as total');
// Check for existing usage today (optional rule, but good practice)
const today = new Date();
today.setHours(0, 0, 0, 0);
const benefits = await query
.orderBy('benefits.priority', 'asc')
.orderBy('benefits.created_at', 'desc')
.limit(Number(limit)).offset(offset);
const existing = await db('transactions')
.where({
user_id: userId,
benefit_id: id,
type: 'redemption'
})
.where('created_at', '>=', today)
.first();
if (existing) {
return res.status(409).json({ error: 'Você já utilizou este benefício hoje.' });
}
// Create transaction
await db('transactions').insert({
user_id: userId,
benefit_id: id,
partner_id: benefit.partner_id,
type: 'redemption',
status: 'completed',
created_at: new Date(),
updated_at: new Date()
});
// Create or update Lead for Partner
// We check if a lead already exists for this user/partner to avoid duplicates,
// or we just insert a new interaction.
// Admin dashboard counts by ID, so maybe unique leads?
// Let's check if lead exists first.
const existingLead = await db('leads')
.where({ user_id: userId, partner_id: benefit.partner_id })
.first();
if (!existingLead) {
// Fetch user details for lead record
const user = await db('users').where({ id: userId }).first();
await db('leads').insert({
user_id: userId,
partner_id: benefit.partner_id,
name: user.name,
email: user.email,
phone: user.phone,
lead_type: 'member', // As seen in admin.routes.ts
status: 'new',
created_at: new Date(),
updated_at: new Date()
res.json({
benefits,
pagination: { page: Number(page), limit: Number(limit), total: Number(total), pages: Math.ceil(Number(total) / Number(limit)) },
});
} catch (err: any) {
res.status(500).json({ error: 'Erro ao listar benefícios', details: err.message });
}
},
// Increment usage count on benefit
await db('benefits').where({ id }).increment('usage_count', 1);
async getById(req: AuthRequest, res: Response) {
try {
const benefit = await db('benefits')
.leftJoin('categories', 'benefits.category_id', 'categories.id')
.leftJoin('partners', 'benefits.partner_id', 'partners.id')
.where('benefits.id', req.params.id)
.select(
'benefits.*',
'categories.name as category_name',
'partners.company_name as partner_name',
'partners.slug as partner_slug',
'partners.phone as partner_phone'
)
.first();
res.json({ success: true, message: 'Benefício utilizado com sucesso' });
} catch (error) {
console.error('Erro ao utilizar benefício:', error);
res.status(500).json({ error: 'Erro ao registrar utilização do benefício' });
}
}
export async function create(req: Request, res: Response) {
try {
const benefit = await db('benefits').insert(req.body).returning('*');
res.json({ benefit: benefit[0] });
} catch (error) {
console.error('Erro ao criar benefício:', error);
res.status(500).json({ error: 'Erro ao criar benefício' });
}
}
export async function update(req: Request, res: Response) {
try {
const { id } = req.params;
console.log(`[DEBUG] Updating benefit ${id} with body:`, req.body);
const benefit = await db('benefits').where({ id }).update(req.body).returning('*');
if (!benefit.length) {
return res.status(404).json({ error: 'Benefício não encontrado' });
if (!benefit) return res.status(404).json({ error: 'Benefício não encontrado' });
res.json({ benefit });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao buscar benefício' });
}
res.json({ benefit: benefit[0] });
} catch (error) {
console.error('Erro ao atualizar benefício:', error);
res.status(500).json({ error: 'Erro ao atualizar benefício' });
}
}
},
export async function deleteBenefit(req: Request, res: Response) {
try {
const { id } = req.params;
async create(req: AuthRequest, res: Response) {
try {
const {
partner_id, category_id, title, description, rules, contact,
type, delivery_type, discount_percent, valid_until,
featured_image, redemption_type, priority, badge,
} = req.body;
await db.transaction(async (trx) => {
// Delete related transactions first (or you might want to Soft Delete the benefit instead?)
// Assuming Hard Delete is requested:
await trx('transactions').where({ benefit_id: id }).delete();
// Delete the benefit
const deleted = await trx('benefits').where({ id }).delete();
if (!deleted) {
throw new Error('Benefício não encontrado');
if (!partner_id || !title || !type) {
return res.status(400).json({ error: 'parceiro, título e tipo são obrigatórios' });
}
});
res.json({ success: true });
} catch (error: any) {
console.error('Erro ao deletar benefício:', error);
if (error.message === 'Benefício não encontrado') {
res.status(404).json({ error: 'Benefício não encontrado' });
} else {
res.status(500).json({ error: 'Erro ao deletar benefício: ' + error.message });
}
}
}
const [benefitId] = await db('benefits').insert({
partner_id, category_id, title, description, rules, contact,
type, delivery_type: delivery_type || 'local',
active: true, is_global: false, approved_by_admin: false,
global_request_status: 'none', priority: priority || 0,
discount_percent, valid_until, featured_image,
redemption_type: redemption_type || 'coupon', badge,
});
export async function toggle(req: Request, res: Response) {
try {
const { id } = req.params;
const benefit = await db('benefits').where({ id }).first();
if (!benefit) {
return res.status(404).json({ error: 'Benefício não encontrado' });
await auditLog(req, {
user_id: req.user!.id, action: 'benefit.create',
entity_type: 'benefits', entity_id: benefitId,
description: `Benefício criado: ${title}`,
});
const benefit = await db('benefits').where('id', benefitId).first();
res.status(201).json({ benefit });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao criar benefício', details: err.message });
}
await db('benefits').where({ id }).update({ active: !benefit.active });
res.json({ success: true, active: !benefit.active });
} catch (error) {
console.error('Erro ao alternar status do benefício:', error);
res.status(500).json({ error: 'Erro ao alternar status' });
}
}
},
async update(req: AuthRequest, res: Response) {
try {
const { id } = req.params;
const allowed = [
'title', 'description', 'rules', 'contact', 'type', 'category_id',
'delivery_type', 'active', 'priority', 'discount_percent',
'valid_until', 'featured_image', 'redemption_type', 'badge',
];
const updates: any = {};
for (const key of allowed) {
if (req.body[key] !== undefined) updates[key] = req.body[key];
}
await db('benefits').where('id', id).update(updates);
await auditLog(req, {
user_id: req.user!.id, action: 'benefit.update',
entity_type: 'benefits', entity_id: Number(id),
new_data: updates,
});
const benefit = await db('benefits').where('id', id).first();
res.json({ benefit });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao atualizar benefício' });
}
},
async delete(req: AuthRequest, res: Response) {
try {
await db('benefits').where('id', req.params.id).delete();
await auditLog(req, {
user_id: req.user!.id, action: 'benefit.delete',
entity_type: 'benefits', entity_id: Number(req.params.id),
});
res.json({ message: 'Benefício removido' });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao remover benefício' });
}
},
async approve(req: AuthRequest, res: Response) {
try {
await db('benefits').where('id', req.params.id).update({ approved_by_admin: true });
await auditLog(req, {
user_id: req.user!.id, action: 'benefit.approve',
entity_type: 'benefits', entity_id: Number(req.params.id),
});
res.json({ message: 'Benefício aprovado' });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao aprovar benefício' });
}
},
async promoteGlobal(req: AuthRequest, res: Response) {
try {
await db('benefits').where('id', req.params.id).update({
is_global: true, approved_by_admin: true, global_request_status: 'approved',
});
res.json({ message: 'Benefício promovido a global' });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao promover benefício' });
}
},
async redeem(req: AuthRequest, res: Response) {
try {
const benefitId = Number(req.params.id);
const benefit = await db('benefits').where('id', benefitId).first();
if (!benefit) return res.status(404).json({ error: 'Benefício não encontrado' });
const code = `CB67-${uuidv4().substring(0, 8).toUpperCase()}`;
const qrData = JSON.stringify({ code, benefitId, userId: req.user!.id, ts: Date.now() });
const qrCodeImage = await QRCode.toDataURL(qrData);
const [transactionId] = await db('transactions').insert({
user_id: req.user!.id,
benefit_id: benefitId,
partner_id: benefit.partner_id,
type: 'redemption',
status: 'pending',
redemption_code: code,
qr_code_data: qrData,
discount_value: benefit.discount_percent,
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000),
});
await db('benefits').where('id', benefitId).increment('usage_count', 1);
res.json({
transaction: {
id: transactionId, code, qrCode: qrCodeImage,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
},
});
} catch (err: any) {
res.status(500).json({ error: 'Erro ao resgatar benefício', details: err.message });
}
},
async getCategories(req: AuthRequest, res: Response) {
try {
const categories = await db('categories').where('active', true).orderBy('sort_order');
res.json({ categories });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao buscar categorias' });
}
},
};
@@ -1,90 +0,0 @@
import { Request, Response } from 'express';
import { db } from '../config/database';
export async function getAll(req: Request, res: Response) {
try {
const isSuperAdmin = req.user?.role === 'super_admin';
const partnerId = req.user?.partner_id;
if (!isSuperAdmin && !partnerId) {
return res.status(403).json({ error: 'Acesso restrito a parceiros' });
}
const query = db('leads');
if (!isSuperAdmin) {
query.where({ partner_id: partnerId });
}
const leads = await query.orderBy('created_at', 'desc');
// Enhance leads with details
const enhancedLeads = await Promise.all(leads.map(async (lead) => {
// Get user details if registered
let userDetails = {};
if (lead.user_id) {
const user = await db('users').where({ id: lead.user_id }).first();
if (user) {
userDetails = {
neighborhood: user.neighborhood,
avatar_url: user.avatar_url
};
}
}
// Get last benefit used
const transactionQuery = db('transactions')
.join('benefits', 'transactions.benefit_id', 'benefits.id')
.where({ 'transactions.user_id': lead.user_id });
if (!isSuperAdmin) {
transactionQuery.where({ 'transactions.partner_id': partnerId });
}
const lastTransaction = await transactionQuery
.orderBy('transactions.created_at', 'desc')
.select('benefits.title as benefit_title', 'benefits.id as benefit_id')
.first();
return {
...lead,
...userDetails,
benefit_title: lastTransaction?.benefit_title || 'N/A',
benefit_id: lastTransaction?.benefit_id
};
}));
res.json({ leads: enhancedLeads });
} catch (error) {
console.error('Erro ao buscar leads:', error);
res.status(500).json({ error: 'Erro ao buscar leads' });
}
}
export async function updateStatus(req: Request, res: Response) {
try {
const { id } = req.params;
const { status } = req.body;
const isSuperAdmin = req.user?.role === 'super_admin';
const partnerId = req.user?.partner_id;
if (!isSuperAdmin && !partnerId) {
return res.status(403).json({ error: 'Acesso restrito a parceiros' });
}
const query = db('leads').where({ id });
if (!isSuperAdmin) {
query.andWhere({ partner_id: partnerId });
}
const updated = await query.update({ status, updated_at: new Date() });
if (!updated) {
return res.status(404).json({ error: 'Lead não encontrado' });
}
res.json({ success: true });
} catch (error) {
console.error('Erro ao atualizar lead:', error);
res.status(500).json({ error: 'Erro ao atualizar lead' });
}
}
+147 -147
View File
@@ -1,167 +1,167 @@
import { Request, Response } from 'express';
import { db } from '../config/database';
import { Response } from 'express';
import db from '../config/database';
import { AuthRequest } from '../middleware/auth';
import { auditLog } from '../utils/audit';
export async function getAll(req: Request, res: Response) {
const partners = await db('partners').where({ status: 'active' });
res.json({ partners });
}
export const partnerController = {
async list(req: AuthRequest, res: Response) {
try {
const { page = 1, limit = 20, search, status, type } = req.query;
const offset = (Number(page) - 1) * Number(limit);
export async function getBySlug(req: Request, res: Response) {
const { slug } = req.params;
let query = db('partners');
if (search) query = query.where('company_name', 'like', `%${search}%`);
if (status) query = query.where('status', status as string);
if (type) query = query.where('type', type as string);
try {
const partner = await db('partners')
.where({ slug, status: 'active' })
.first();
const countQuery = query.clone();
const [{ total }] = await countQuery.count('* as total');
const partners = await query.orderBy('created_at', 'desc').limit(Number(limit)).offset(offset);
if (!partner) {
return res.status(404).json({ error: 'Parceiro não encontrado' });
res.json({
partners,
pagination: { page: Number(page), limit: Number(limit), total: Number(total), pages: Math.ceil(Number(total) / Number(limit)) },
});
} catch (err: any) {
res.status(500).json({ error: 'Erro ao listar parceiros', details: err.message });
}
},
const benefits = await db('benefits')
.where({ partner_id: partner.id, active: true })
.orderBy('created_at', 'desc');
async getById(req: AuthRequest, res: Response) {
try {
const partner = await db('partners').where('id', req.params.id).first();
if (!partner) return res.status(404).json({ error: 'Parceiro não encontrado' });
res.json({ partner, benefits });
} catch (error) {
console.error('Erro ao buscar parceiro por slug:', error);
res.status(500).json({ error: 'Erro ao buscar parceiro' });
}
}
export async function getById(req: Request, res: Response) {
const { id } = req.params;
try {
const partner = await db('partners')
.where({ id })
.first();
if (!partner) {
return res.status(404).json({ error: 'Parceiro não encontrado' });
const benefits = await db('benefits').where('partner_id', partner.id).where('active', true);
res.json({ partner, benefits });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao buscar parceiro', details: err.message });
}
},
// Optional: privacy check? Public profile vs Owner?
// For now, return public info + specific owner info if needed.
// The frontend uses this for "Edit Profile", so we need all editable fields.
async getBySlug(req: AuthRequest, res: Response) {
try {
const partner = await db('partners').where('slug', req.params.slug).first();
if (!partner) return res.status(404).json({ error: 'Parceiro não encontrado' });
res.json({ partner });
} catch (error) {
console.error('Erro ao buscar parceiro por ID:', error);
res.status(500).json({ error: 'Erro ao buscar parceiro' });
}
}
export async function updatePartner(req: Request, res: Response) {
const { id } = req.params;
const {
company_name,
description,
logo_url,
banner_url,
instagram,
website,
address_street,
address_number,
address_neighborhood,
address_city,
address_state,
phone, // WhatsApp
logo_bg_color
} = req.body;
try {
const userId = req.user!.id; // Authenticated user
const userRole = req.user!.role;
const partner = await db('partners').where({ id }).first();
if (!partner) {
return res.status(404).json({ error: 'Parceiro não encontrado' });
const benefits = await db('benefits').where('partner_id', partner.id).where('active', true);
res.json({ partner, benefits });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao buscar parceiro' });
}
},
// Authorization: Only Owner or Super Admin
if (userRole !== 'super_admin' && partner.owner_user_id !== userId) {
return res.status(403).json({ error: 'Você não tem permissão para editar este parceiro.' });
}
async create(req: AuthRequest, res: Response) {
try {
const {
company_name, slug, cnpj, email, phone,
address_street, address_number, address_neighborhood,
address_city, address_state, address_zip,
type, monthly_goal, description,
} = req.body;
// Generate Slug if company name changes?
// Better not to change slug automatically to avoid breaking links.
// If needed, we can add a check. For now, let's keep slug stable.
const updateData: any = {};
if (company_name !== undefined) updateData.company_name = company_name;
if (description !== undefined) updateData.description = description;
if (logo_url !== undefined) updateData.logo_url = logo_url;
if (logo_bg_color !== undefined) updateData.logo_bg_color = logo_bg_color;
if (banner_url !== undefined) updateData.banner_url = banner_url;
if (instagram !== undefined) updateData.instagram = instagram;
if (website !== undefined) updateData.website = website;
if (address_street !== undefined) updateData.address_street = address_street;
if (address_number !== undefined) updateData.address_number = address_number;
if (address_neighborhood !== undefined) updateData.address_neighborhood = address_neighborhood;
if (address_city !== undefined) updateData.address_city = address_city;
if (address_state !== undefined) updateData.address_state = address_state;
if (phone !== undefined) updateData.phone = phone;
updateData.updated_at = new Date();
if (Object.keys(updateData).length > 0) {
await db('partners').where({ id }).update(updateData);
}
res.json({ message: 'Parceiro atualizado com sucesso' });
} catch (error) {
console.error('Erro ao atualizar parceiro:', error);
res.status(500).json({ error: 'Erro ao atualizar parceiro' });
}
}
export async function deletePartner(req: Request, res: Response) {
const { id } = req.params;
try {
await db.transaction(async (trx) => {
// Delete related benefits (which deletes their dependent records like uses, clicks, etc.)
// We need to fetch benefit IDs first to delete their dependencies properly if not using CASCADE in DB
// However, our benefit delete logic is in another controller/plugin.
// For a direct DB delete here, we must clean up manually or trust DB constraints.
// Given the issues with benefits, let's delete benefit dependencies first.
const benefits = await trx('benefits').where({ partner_id: id }).select('id');
const benefitIds = benefits.map(b => b.id);
if (benefitIds.length > 0) {
await trx('benefit_matches').whereIn('benefit_id', benefitIds).delete();
await trx('benefit_clicks').whereIn('benefit_id', benefitIds).delete();
await trx('benefit_uses').whereIn('benefit_id', benefitIds).delete();
try {
await trx('transactions').whereIn('benefit_id', benefitIds).delete();
} catch (e) { /* ignore */ }
await trx('benefits').whereIn('id', benefitIds).delete();
if (!company_name || !slug || !type) {
return res.status(400).json({ error: 'Nome, slug e tipo são obrigatórios' });
}
// Delete Leads
await trx('leads').where({ partner_id: id }).delete();
const existingSlug = await db('partners').where('slug', slug).first();
if (existingSlug) return res.status(409).json({ error: 'Slug já existe' });
// Finally delete Partner
// Note: Does not delete the User account, just the Partner profile.
// If the user should revert to 'user' role, we should update that.
// Let's keep the user but unlink/delete partner profile.
const [partnerId] = await db('partners').insert({
company_name, slug, cnpj, email, phone,
address_street, address_number, address_neighborhood,
address_city, address_state, address_zip,
type, monthly_goal: monthly_goal || 0,
description, status: 'active',
owner_user_id: req.user!.id,
});
const partner = await trx('partners').where({ id }).first();
if (partner && partner.owner_user_id) {
await trx('users').where({ id: partner.owner_user_id }).update({ role: 'user' });
await auditLog(req, {
user_id: req.user!.id, action: 'partner.create',
entity_type: 'partners', entity_id: partnerId,
description: `Parceiro criado: ${company_name}`,
});
const partner = await db('partners').where('id', partnerId).first();
res.status(201).json({ partner });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao criar parceiro', details: err.message });
}
},
async update(req: AuthRequest, res: Response) {
try {
const { id } = req.params;
const allowed = [
'company_name', 'cnpj', 'email', 'phone', 'description', 'type',
'address_street', 'address_number', 'address_neighborhood',
'address_city', 'address_state', 'address_zip',
'monthly_goal', 'status', 'logo_url', 'images',
'gradient_from', 'gradient_to', 'icon', 'subscription_plan',
'domain', 'subdomain',
];
const updates: any = {};
for (const key of allowed) {
if (req.body[key] !== undefined) updates[key] = req.body[key];
}
const deleted = await trx('partners').where({ id }).delete();
if (!deleted) throw new Error('Parceiro não encontrado');
});
await db('partners').where('id', id).update(updates);
await auditLog(req, {
user_id: req.user!.id, action: 'partner.update',
entity_type: 'partners', entity_id: Number(id),
new_data: updates,
});
res.json({ message: 'Parceiro excluído com sucesso' });
} catch (error: any) {
console.error('Erro ao excluir parceiro:', error);
res.status(500).json({ error: error.message || 'Erro ao excluir parceiro' });
}
}
const partner = await db('partners').where('id', id).first();
res.json({ partner });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao atualizar parceiro' });
}
},
async delete(req: AuthRequest, res: Response) {
try {
const { id } = req.params;
await db('partners').where('id', id).delete();
await auditLog(req, {
user_id: req.user!.id, action: 'partner.delete',
entity_type: 'partners', entity_id: Number(id),
});
res.json({ message: 'Parceiro removido' });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao remover parceiro' });
}
},
async uploadLogo(req: AuthRequest, res: Response) {
try {
if (!req.file) return res.status(400).json({ error: 'Arquivo não fornecido' });
const logoUrl = `/uploads/${req.file.filename}`;
await db('partners').where('id', req.params.id).update({ logo_url: logoUrl });
res.json({ logo_url: logoUrl });
} catch (err: any) {
res.status(500).json({ error: 'Erro no upload' });
}
},
async getDashboardStats(req: AuthRequest, res: Response) {
try {
const partnerId = Number(req.params.id);
const [benefits] = await db('benefits').where('partner_id', partnerId).count('* as count');
const [activeB] = await db('benefits').where({ partner_id: partnerId, active: true }).count('* as count');
const [transactions] = await db('transactions').where('partner_id', partnerId).count('* as count');
const [totalUsage] = await db('benefits').where('partner_id', partnerId).sum('usage_count as total');
res.json({
stats: {
totalBenefits: Number(benefits.count),
activeBenefits: Number(activeB.count),
totalTransactions: Number(transactions.count),
totalUsage: Number(totalUsage.total) || 0,
},
});
} catch (err: any) {
res.status(500).json({ error: 'Erro ao buscar estatísticas do parceiro' });
}
},
};
@@ -1,146 +0,0 @@
import { Request, Response } from 'express';
import { db } from '../config/database';
// Registra um clique em link de indicacao
export async function trackClick(req: Request, res: Response) {
try {
const { ref, benefit_id } = req.body;
if (!ref || !benefit_id) {
return res.status(400).json({ error: 'ref e benefit_id sao obrigatorios' });
}
// Busca ou cria o registro de referral
const existing = await db('referrals')
.where({ referral_code: ref, benefit_id })
.first();
if (existing) {
await db('referrals')
.where({ id: existing.id })
.update({ status: 'clicked', clicked_at: new Date(), updated_at: new Date() });
} else {
// Decodifica o user id do codigo REF
const userIdStr = ref.replace('REF', '');
const referrerUserId = parseInt(userIdStr, 36);
if (isNaN(referrerUserId)) {
return res.status(400).json({ error: 'Codigo de referral invalido' });
}
await db('referrals').insert({
referrer_user_id: referrerUserId,
benefit_id,
referral_code: ref,
status: 'clicked',
clicked_at: new Date(),
created_at: new Date(),
updated_at: new Date(),
});
}
res.json({ success: true });
} catch (error) {
console.error('Erro ao registrar clique de referral:', error);
res.status(500).json({ error: 'Erro ao registrar clique' });
}
}
// Estatisticas de indicacoes do usuario logado
export async function getMyStats(req: Request, res: Response) {
try {
const userId = req.user!.id;
// Total de indicacoes
const [totalRow] = await db('referrals')
.where({ referrer_user_id: userId })
.count('id as total');
// Por status
const byStatus = await db('referrals')
.where({ referrer_user_id: userId })
.select('status')
.count('id as count')
.groupBy('status');
// Top beneficios indicados
const topBenefits = await db('referrals')
.where({ 'referrals.referrer_user_id': userId })
.join('benefits', 'referrals.benefit_id', 'benefits.id')
.leftJoin('partners', 'benefits.partner_id', 'partners.id')
.select(
'benefits.id as benefit_id',
'benefits.title',
'partners.company_name as partner_name',
'partners.icon as partner_icon'
)
.count('referrals.id as total_referrals')
.sum({ conversions: db.raw("CASE WHEN referrals.status = 'converted' THEN 1 ELSE 0 END") })
.groupBy('benefits.id', 'benefits.title', 'partners.company_name', 'partners.icon')
.orderBy('total_referrals', 'desc')
.limit(10);
// Historico recente
const recent = await db('referrals')
.where({ 'referrals.referrer_user_id': userId })
.join('benefits', 'referrals.benefit_id', 'benefits.id')
.leftJoin('partners', 'benefits.partner_id', 'partners.id')
.select(
'referrals.id',
'referrals.status',
'referrals.referral_code',
'referrals.clicked_at',
'referrals.converted_at',
'referrals.created_at',
'benefits.title as benefit_title',
'partners.company_name as partner_name'
)
.orderBy('referrals.created_at', 'desc')
.limit(50);
const statusMap: Record<string, number> = {};
byStatus.forEach((row: any) => {
statusMap[row.status] = Number(row.count);
});
res.json({
total: Number(totalRow.total),
clicked: statusMap.clicked || 0,
converted: statusMap.converted || 0,
pending: statusMap.pending || 0,
topBenefits,
recent,
});
} catch (error) {
console.error('Erro ao buscar stats de referral:', error);
res.status(500).json({ error: 'Erro ao buscar estatisticas' });
}
}
// Converte uma indicacao (chamado quando o indicado realiza acao)
export async function convert(req: Request, res: Response) {
try {
const { ref, benefit_id, referred_user_id } = req.body;
if (!ref || !benefit_id) {
return res.status(400).json({ error: 'ref e benefit_id sao obrigatorios' });
}
const updated = await db('referrals')
.where({ referral_code: ref, benefit_id })
.whereNot({ status: 'converted' })
.update({
status: 'converted',
referred_user_id: referred_user_id || null,
converted_at: new Date(),
updated_at: new Date(),
});
if (!updated) {
return res.status(404).json({ error: 'Referral nao encontrado ou ja convertido' });
}
res.json({ success: true });
} catch (error) {
console.error('Erro ao converter referral:', error);
res.status(500).json({ error: 'Erro ao converter indicacao' });
}
}
+161 -101
View File
@@ -1,120 +1,180 @@
import { Request, Response } from 'express';
import { db } from '../config/database';
import { Response } from 'express';
import db from '../config/database';
import { AuthRequest } from '../middleware/auth';
import { auditLog } from '../utils/audit';
export async function getAll(req: Request, res: Response) {
const users = await db('users')
.where('is_anonymized', false)
.select('id', 'name', 'email', 'role', 'status', 'created_at');
res.json({ users });
}
export const userController = {
async list(req: AuthRequest, res: Response) {
try {
const { page = 1, limit = 20, search, role, status } = req.query;
const offset = (Number(page) - 1) * Number(limit);
export async function getStats(req: Request, res: Response) {
const [totalBenefits] = await db('benefits').count('id as count');
const [totalFavorites] = await db('favorites').where({ user_id: req.user!.id }).count('id as count');
res.json({ totalBenefits: totalBenefits.count, totalFavorites: totalFavorites.count });
}
let query = db('users').select(
'id', 'name', 'email', 'phone', 'role', 'status', 'avatar_url',
'city', 'neighborhood', 'gender', 'last_login', 'created_at'
);
export async function getMyBenefits(req: Request, res: Response) {
try {
const history = await db('transactions')
.join('benefits', 'transactions.benefit_id', 'benefits.id')
.join('partners', 'benefits.partner_id', 'partners.id')
.leftJoin('leads', function () {
this.on('leads.user_id', '=', 'transactions.user_id')
.andOn('leads.partner_id', '=', 'partners.id');
})
.where({ 'transactions.user_id': req.user!.id })
.select(
'transactions.id as transaction_id',
'transactions.status as transaction_status',
'transactions.created_at as used_at',
'benefits.id as benefit_id',
'benefits.title',
'benefits.description',
'benefits.partner_id',
'partners.company_name as partner_name',
'partners.icon as partner_icon',
'leads.status as lead_status'
)
.orderBy('transactions.created_at', 'desc');
if (search) query = query.where(function () {
this.where('name', 'like', `%${search}%`)
.orWhere('email', 'like', `%${search}%`);
});
if (role) query = query.where('role', role as string);
if (status) query = query.where('status', status as string);
res.json({ history });
} catch (error) {
console.error('Erro ao buscar histórico:', error);
res.status(500).json({ error: 'Erro ao buscar histórico' });
}
}
const [{ total }] = await db('users').count('* as total');
const users = await query.orderBy('created_at', 'desc').limit(Number(limit)).offset(offset);
// --- Admin Actions ---
res.json({
users,
pagination: { page: Number(page), limit: Number(limit), total: Number(total), pages: Math.ceil(Number(total) / Number(limit)) },
});
} catch (err: any) {
res.status(500).json({ error: 'Erro ao listar usuários', details: err.message });
}
},
export async function updateUser(req: Request, res: Response) {
const { id } = req.params;
const { name, email, role } = req.body;
async getById(req: AuthRequest, res: Response) {
try {
const user = await db('users')
.where('id', req.params.id)
.select('id', 'name', 'email', 'phone', 'cpf', 'role', 'status', 'avatar_url',
'city', 'neighborhood', 'gender', 'birth_date', 'last_login', 'created_at')
.first();
if (!user) return res.status(404).json({ error: 'Usuário não encontrado' });
res.json({ user });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao buscar usuário', details: err.message });
}
},
try {
const targetUser = await db('users').where({ id }).first();
// 🛡️ SECURITY: Prevent Super Admin Demotion
if (targetUser.role === 'super_admin') {
// Cannot change role of a super_admin
if (role && role !== 'super_admin') {
return res.status(403).json({ error: 'NÃO É PERMITIDO REBAIXAR UM SUPER ADMIN.' });
async update(req: AuthRequest, res: Response) {
try {
const { id } = req.params;
const allowed = ['name', 'phone', 'city', 'neighborhood', 'gender', 'birth_date', 'avatar_url'];
const updates: any = {};
for (const key of allowed) {
if (req.body[key] !== undefined) updates[key] = req.body[key];
}
}
// 🛡️ SECURITY: Protect specific email
if (targetUser.email === 'ruibto@gmail.com') {
if (role && role !== 'super_admin') {
return res.status(403).json({ error: 'Este usuário é o Dono do Sistema e não pode ter funções alteradas.' });
if (Object.keys(updates).length === 0) {
return res.status(400).json({ error: 'Nenhum campo para atualizar' });
}
// Only allow users to update themselves, or admins to update anyone
if (req.user!.role !== 'super_admin' && req.user!.id !== Number(id)) {
return res.status(403).json({ error: 'Sem permissão' });
}
await db('users').where('id', id).update(updates);
await auditLog(req, {
user_id: req.user!.id, action: 'user.update',
entity_type: 'users', entity_id: Number(id),
new_data: updates,
});
const user = await db('users').where('id', id).select('id', 'name', 'email', 'phone', 'role', 'status', 'avatar_url', 'city').first();
res.json({ user });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao atualizar', details: err.message });
}
},
// Filter undefined fields to avoid SQL errors or accidental nulls
const updateData: any = {};
if (name !== undefined) updateData.name = name;
if (email !== undefined) updateData.email = email;
if (role !== undefined) updateData.role = role;
if (Object.keys(updateData).length > 0) {
await db('users').where({ id }).update(updateData);
async updateRole(req: AuthRequest, res: Response) {
try {
const { id } = req.params;
const { role } = req.body;
const validRoles = ['user', 'partner_admin', 'secretary', 'clinical_manager', 'finance', 'super_admin'];
if (!validRoles.includes(role)) {
return res.status(400).json({ error: 'Role inválida' });
}
await db('users').where('id', id).update({ role });
await auditLog(req, {
user_id: req.user!.id, action: 'user.role_change',
entity_type: 'users', entity_id: Number(id),
new_data: { role },
});
res.json({ message: 'Role atualizada' });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao atualizar role' });
}
},
res.json({ message: 'Usuário atualizado com sucesso' });
} catch (error) {
console.error('Erro ao atualizar usuário:', error);
res.status(500).json({ error: 'Erro ao atualizar usuário' });
}
}
async delete(req: AuthRequest, res: Response) {
try {
const { id } = req.params;
await db('users').where('id', id).delete();
await auditLog(req, {
user_id: req.user!.id, action: 'user.delete',
entity_type: 'users', entity_id: Number(id),
});
res.json({ message: 'Usuário removido' });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao remover usuário' });
}
},
export async function updateStatus(req: Request, res: Response) {
const { id } = req.params;
const { status } = req.body; // 'active', 'blocked', 'banned'
async getFavorites(req: AuthRequest, res: Response) {
try {
const favorites = await db('favorites')
.join('benefits', 'favorites.benefit_id', 'benefits.id')
.where('favorites.user_id', req.user!.id)
.select('benefits.*', 'favorites.id as favorite_id', 'favorites.created_at as favorited_at');
res.json({ favorites });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao buscar favoritos' });
}
},
if (!['active', 'blocked', 'banned'].includes(status)) {
return res.status(400).json({ error: 'Status inválido' });
}
async addFavorite(req: AuthRequest, res: Response) {
try {
const { benefitId } = req.body;
const exists = await db('favorites').where({ user_id: req.user!.id, benefit_id: benefitId }).first();
if (exists) return res.status(409).json({ error: 'Já favoritado' });
await db('favorites').insert({ user_id: req.user!.id, benefit_id: benefitId });
res.status(201).json({ message: 'Favorito adicionado' });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao adicionar favorito' });
}
},
try {
await db('users').where({ id }).update({ status });
res.json({ message: `Status alterado para ${status}` });
} catch (error) {
console.error('Erro ao atualizar status:', error);
res.status(500).json({ error: 'Erro ao atualizar status' });
}
}
async removeFavorite(req: AuthRequest, res: Response) {
try {
await db('favorites').where({ user_id: req.user!.id, benefit_id: req.params.benefitId }).delete();
res.json({ message: 'Favorito removido' });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao remover favorito' });
}
},
export async function deleteUser(req: Request, res: Response) {
const { id } = req.params;
const adminId = req.user?.id; // Assuming auth middleware provides logged in admin ID
async getHistory(req: AuthRequest, res: Response) {
try {
const transactions = await db('transactions')
.join('benefits', 'transactions.benefit_id', 'benefits.id')
.where('transactions.user_id', req.user!.id)
.select('transactions.*', 'benefits.title as benefit_title')
.orderBy('transactions.created_at', 'desc')
.limit(50);
res.json({ transactions });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao buscar histórico' });
}
},
try {
const { UserLifecycleService } = await import('../services/UserLifecycleService');
async getDashboardStats(req: AuthRequest, res: Response) {
try {
const userId = req.user!.id;
const [favorites] = await db('favorites').where('user_id', userId).count('* as count');
const [transactions] = await db('transactions').where('user_id', userId).count('* as count');
const [savings] = await db('transactions').where('user_id', userId).sum('discount_value as total');
await UserLifecycleService.anonymizeUser(Number(id), Number(adminId));
res.json({ message: 'Usuário excluído e anonimizado com sucesso conforme LGPD.' });
} catch (error: any) {
console.error('Erro ao excluir usuário:', error);
res.status(500).json({ error: error.message || 'Erro ao excluir usuário' });
}
}
res.json({
stats: {
totalFavorites: Number(favorites.count),
totalTransactions: Number(transactions.count),
totalSavings: Number(savings.total) || 0,
},
});
} catch (err: any) {
res.status(500).json({ error: 'Erro ao buscar estatísticas' });
}
},
};
@@ -0,0 +1,112 @@
import { Response } from 'express';
import db from '../config/database';
import { AuthRequest } from '../middleware/auth';
import { auditLog } from '../utils/audit';
import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto';
export const webhookController = {
async list(req: AuthRequest, res: Response) {
try {
const webhooks = await db('webhooks').orderBy('created_at', 'desc');
res.json({ webhooks });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao listar webhooks' });
}
},
async create(req: AuthRequest, res: Response) {
try {
const { url, events, partner_id } = req.body;
if (!url || !events) {
return res.status(400).json({ error: 'URL e eventos são obrigatórios' });
}
const secret = crypto.randomBytes(32).toString('hex');
const [id] = await db('webhooks').insert({
url, events: JSON.stringify(events), secret,
partner_id: partner_id || null, active: true,
});
await auditLog(req, {
user_id: req.user!.id, action: 'webhook.create',
entity_type: 'webhooks', entity_id: id,
});
res.status(201).json({ webhook: { id, url, events, secret } });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao criar webhook' });
}
},
async update(req: AuthRequest, res: Response) {
try {
const { id } = req.params;
const { url, events, active } = req.body;
const updates: any = {};
if (url) updates.url = url;
if (events) updates.events = JSON.stringify(events);
if (active !== undefined) updates.active = active;
await db('webhooks').where('id', id).update(updates);
res.json({ message: 'Webhook atualizado' });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao atualizar webhook' });
}
},
async delete(req: AuthRequest, res: Response) {
try {
await db('webhooks').where('id', req.params.id).delete();
res.json({ message: 'Webhook removido' });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao remover webhook' });
}
},
async test(req: AuthRequest, res: Response) {
try {
const webhook = await db('webhooks').where('id', req.params.id).first();
if (!webhook) return res.status(404).json({ error: 'Webhook não encontrado' });
// Send test event
const payload = {
event: 'test',
timestamp: new Date().toISOString(),
data: { message: 'Webhook test from Clube de Benefícios' },
};
const signature = crypto
.createHmac('sha256', webhook.secret)
.update(JSON.stringify(payload))
.digest('hex');
try {
const response = await fetch(webhook.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CB67-Signature': signature,
},
body: JSON.stringify(payload),
});
await db('webhooks').where('id', webhook.id).update({
last_triggered: new Date(),
last_status: response.ok ? 'success' : 'failed',
});
res.json({ success: response.ok, status: response.status });
} catch (error) {
await db('webhooks').where('id', webhook.id).update({
last_triggered: new Date(),
last_status: 'failed',
retry_count: webhook.retry_count + 1,
});
res.json({ success: false, error: 'Falha ao enviar webhook' });
}
} catch (err: any) {
res.status(500).json({ error: 'Erro ao testar webhook' });
}
},
};
-82
View File
@@ -1,82 +0,0 @@
// ============================================================
// Clube67 — Central Storage Provider Interface
// ============================================================
// This service acts as a bridge between the system and the
// active storage plugin (Wasabi, Local, etc).
import { logger } from '../utils/logger';
export interface StorageUploadPayload {
clinicId?: string;
patientId?: string;
partnerId?: string;
benefitId?: string;
postId?: string;
whatsappId?: string;
userId?: string;
category: 'cdi' | 'xrays' | 'documents' | 'exports' | 'posts' | 'cards' | 'partners' | 'whatsapp' | 'media';
file: {
originalname: string;
buffer: Buffer;
mimetype: string;
};
}
export interface StorageProviderInterface {
upload(payload: StorageUploadPayload): Promise<{ provider: string; path: string }>;
checkHealth(): Promise<boolean>;
}
class StorageProvider {
private provider: StorageProviderInterface | null = null;
private healthy: boolean = true;
/** Register a storage provider plugin */
register(provider: StorageProviderInterface): void {
this.provider = provider;
logger.info(`[StorageProvider] Central provider registered: ${provider.constructor.name}`);
// Initial health check
this.updateHealth();
}
/** Periodically update health status */
async updateHealth(): Promise<boolean> {
if (!this.provider) {
this.healthy = false;
return false;
}
try {
this.healthy = await this.provider.checkHealth();
if (!this.healthy) logger.warn('[StorageProvider] Provider reported UNHEALTHY status');
return this.healthy;
} catch (err) {
this.healthy = false;
logger.error('[StorageProvider] Health check failed with error');
return false;
}
}
/** Is the storage system available? */
isAvailable(): boolean {
return this.healthy && this.provider !== null;
}
/** Upload a file using the registered provider */
async uploadFile(payload: StorageUploadPayload) {
if (!this.provider) {
throw new Error('[StorageProvider] No storage provider registered. Did you enable the UnifiedStorageProvider plugin?');
}
if (!this.healthy) {
throw new Error('[StorageProvider] Storage system is currently UNAVAILABLE');
}
return this.provider.upload(payload);
}
}
// Singleton
export const storageProvider = new StorageProvider();
/** Helper function for easier imports */
export async function uploadFile(payload: StorageUploadPayload) {
return storageProvider.uploadFile(payload);
}
-69
View File
@@ -1,69 +0,0 @@
// ============================================================
// Clube67 — Hook System
// ============================================================
// Event-driven communication between plugins.
// Plugins register handlers for events and emit events.
// This enables decoupled, modular architecture.
import { HookSystem } from './types';
import { logger } from '../utils/logger';
type HookHandler = (...args: any[]) => Promise<any>;
class HookSystemImpl implements HookSystem {
private handlers: Map<string, Set<HookHandler>> = new Map();
register(event: string, handler: HookHandler): void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler);
logger.info(`[Hooks] Registered handler for "${event}"`);
}
async emit(event: string, ...args: any[]): Promise<any[]> {
const eventHandlers = this.handlers.get(event);
if (!eventHandlers || eventHandlers.size === 0) {
return [];
}
const results: any[] = [];
for (const handler of eventHandlers) {
try {
const result = await handler(...args);
results.push(result);
} catch (err: any) {
logger.error(`[Hooks] Error in handler for "${event}": ${err.message}`);
}
}
return results;
}
remove(event: string, handler: HookHandler): void {
const eventHandlers = this.handlers.get(event);
if (eventHandlers) {
eventHandlers.delete(handler);
if (eventHandlers.size === 0) {
this.handlers.delete(event);
}
}
}
/** List all registered events (for admin dashboard) */
listEvents(): string[] {
return Array.from(this.handlers.keys());
}
/** Count handlers for a specific event */
handlerCount(event: string): number {
return this.handlers.get(event)?.size ?? 0;
}
/** Clear all handlers (for testing / shutdown) */
clear(): void {
this.handlers.clear();
}
}
// Singleton — shared across all plugins
export const hooks = new HookSystemImpl();
-44
View File
@@ -1,44 +0,0 @@
import fs from 'fs';
import path from 'path';
const STORAGE_DIR = path.resolve(process.cwd(), 'storage');
const CONFIG_FILE = path.join(STORAGE_DIR, 'plugin-configs.json');
// Ensure storage dir exists
if (!fs.existsSync(STORAGE_DIR)) {
fs.mkdirSync(STORAGE_DIR, { recursive: true });
}
// Ensure config file exists
if (!fs.existsSync(CONFIG_FILE)) {
fs.writeFileSync(CONFIG_FILE, '{}', 'utf-8');
}
export const pluginConfig = {
getAll(): Record<string, any> {
try {
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
return JSON.parse(raw);
} catch (error) {
console.error('Failed to read plugin config:', error);
return {};
}
},
get(pluginName: string): any {
const all = this.getAll();
return all[pluginName] || {};
},
set(pluginName: string, config: any): void {
const all = this.getAll();
all[pluginName] = { ...all[pluginName], ...config };
try {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(all, null, 2), 'utf-8');
} catch (error) {
console.error('Failed to save plugin config:', error);
throw new Error('Failed to save configuration');
}
}
};
-719
View File
@@ -1,719 +0,0 @@
// ============================================================
// Clube67 — Plugin Loader
// ============================================================
// Scans /plugins directory, validates manifests, resolves
// dependency order, and activates each plugin.
import fs from 'fs';
import path from 'path';
import os from 'os';
import { Express } from 'express';
import multer from 'multer';
import { spawnSync } from 'child_process';
import { PluginManifest, PluginInstance, PluginContext } from './types';
import { pluginRegistry } from './plugin-registry';
import { hooks } from './hooks';
import { logger } from '../utils/logger';
import { db } from '../config/database';
import { redis } from '../config/redis';
import { authenticate } from '../middleware/auth';
import { authorize } from '../middleware/rbac';
// Source plugins dir (manifests + SQL migrations)
const PROJECT_ROOT = path.resolve(__dirname, '../../../../');
const PLUGINS_SOURCE_DIR = path.join(PROJECT_ROOT, 'plugins');
// Compiled plugins dir (JS modules)
const PLUGINS_COMPILED_DIR = path.resolve(__dirname, '../../../plugins');
// ── Manifest Loading ────────────────────────────────────────
function loadManifest(pluginDir: string): PluginManifest | null {
const manifestPath = path.join(pluginDir, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
logger.warn(`[PluginLoader] No manifest.json in ${path.basename(pluginDir)}`);
return null;
}
try {
const raw = fs.readFileSync(manifestPath, 'utf-8');
return JSON.parse(raw) as PluginManifest;
} catch (err: any) {
logger.error(`[PluginLoader] Invalid manifest in ${path.basename(pluginDir)}: ${err.message}`);
return null;
}
}
// ── Dependency Resolution (Topological Sort) ────────────────
function resolveDependencyOrder(manifests: PluginManifest[]): PluginManifest[] {
const byName = new Map(manifests.map(m => [m.name, m]));
const visited = new Set<string>();
const sorted: PluginManifest[] = [];
function visit(name: string, stack: Set<string>) {
if (visited.has(name)) return;
if (stack.has(name)) {
throw new Error(`[PluginLoader] Circular dependency detected involving "${name}"`);
}
stack.add(name);
const manifest = byName.get(name);
if (!manifest) return;
for (const dep of manifest.dependencies) {
visit(dep, stack);
}
stack.delete(name);
visited.add(name);
sorted.push(manifest);
}
for (const m of manifests) {
visit(m.name, new Set());
}
return sorted;
}
// ── Plugin Instance Loading ─────────────────────────────────
function loadPluginInstance(pluginName: string, manifest: PluginManifest): PluginInstance | null {
// Try compiled JS first, then source TS
const compiledDir = path.join(PLUGINS_COMPILED_DIR, pluginName);
const sourceDir = path.join(PLUGINS_SOURCE_DIR, pluginName);
const candidates = [
path.join(compiledDir, 'index.js'),
path.join(sourceDir, 'index.js'),
path.join(sourceDir, 'index.ts'),
];
let modulePath: string | null = null;
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
modulePath = candidate;
break;
}
}
if (!modulePath) {
logger.warn(`[PluginLoader] No index file in plugin "${manifest.name}"`);
return null;
}
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mod = require(modulePath);
const instance: PluginInstance = mod.default || mod;
instance.manifest = manifest;
return instance;
} catch (err: any) {
logger.error(`[PluginLoader] Failed to load "${manifest.name}": ${err.message}`);
return null;
}
}
function getPluginDirByName(name: string): { dirName: string; fullPath: string; manifest: PluginManifest } | null {
if (!fs.existsSync(PLUGINS_SOURCE_DIR)) return null;
const pluginDirs = fs.readdirSync(PLUGINS_SOURCE_DIR)
.map(dirName => ({ dirName, fullPath: path.join(PLUGINS_SOURCE_DIR, dirName) }))
.filter(p => fs.existsSync(path.join(p.fullPath, 'manifest.json')));
for (const p of pluginDirs) {
const manifest = loadManifest(p.fullPath);
if (manifest && manifest.name === name) {
return { dirName: p.dirName, fullPath: p.fullPath, manifest };
}
}
return null;
}
// ── Migration Runner ────────────────────────────────────────
async function runMigrations(pluginName: string, manifest: PluginManifest): Promise<void> {
if (!manifest.backend?.hasMigrations) return;
// Always read migrations from SOURCE directory (SQL files aren't compiled)
const migrationsDir = path.join(PLUGINS_SOURCE_DIR, pluginName, 'backend', 'migrations');
if (!fs.existsSync(migrationsDir)) return;
const migrationFiles = fs.readdirSync(migrationsDir)
.filter(f => f.endsWith('.sql'))
.sort();
for (const file of migrationFiles) {
const filePath = path.join(migrationsDir, file);
const sql = fs.readFileSync(filePath, 'utf-8');
// Check if migration was already applied
const migrationKey = `${manifest.name}:${file}`;
try {
if (db.client.config.client === 'pg') {
await db.raw(`
CREATE TABLE IF NOT EXISTS plugin_migrations (
id SERIAL PRIMARY KEY,
plugin_name VARCHAR(255) NOT NULL,
migration_file VARCHAR(255) NOT NULL,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_migration UNIQUE (plugin_name, migration_file)
)
`);
} else {
await db.raw(`
CREATE TABLE IF NOT EXISTS plugin_migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
plugin_name VARCHAR(255) NOT NULL,
migration_file VARCHAR(255) NOT NULL,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_migration (plugin_name, migration_file)
)
`);
}
const existing = await db('plugin_migrations')
.select('id')
.where({ plugin_name: manifest.name, migration_file: file })
.first();
if (existing) {
logger.info(`[PluginLoader] Migration ${migrationKey} already applied, skipping.`);
continue;
}
// Run migration — each statement is fault-tolerant for idempotent patterns
const statements = sql.split(';').filter(s => s.trim().length > 0);
for (const stmt of statements) {
try {
let finalStmt = stmt;
if (db.client.config.client === 'pg') {
// If it's a MySQL session variable script or procedural blocks, skip it
if (stmt.toLowerCase().includes('set @') ||
stmt.toLowerCase().includes('prepare ') ||
stmt.toLowerCase().includes('execute ') ||
stmt.toLowerCase().includes('deallocate ')) {
continue;
}
// Replace common MySQL types/keywords
finalStmt = finalStmt
.replace(/int\s+auto_increment/gi, 'SERIAL')
.replace(/auto_increment/gi, '')
.replace(/int\(\d+\)/gi, 'integer')
.replace(/varchar\(\d+\)/gi, 'varchar')
.replace(/text/gi, 'text')
.replace(/datetime/gi, 'timestamp')
.replace(/timestamp\s+default\s+current_timestamp\s+on\s+update\s+current_timestamp/gi, 'timestamp default current_timestamp')
.replace(/on\s+update\s+current_timestamp/gi, '')
.replace(/unique\s+key\s+\w+\s+\(([^)]+)\)/gi, 'CONSTRAINT unique_$1 UNIQUE ($1)')
.replace(/index\s+\w+\s+\(([^)]+)\)/gi, '')
.replace(/foreign\s+key\s+\(([^)]+)\)\s+references\s+(\w+)\(([^)]+)\)/gi, 'FOREIGN KEY ($1) REFERENCES $2($3)')
.replace(/engine\s*=\s*\w+/gi, '')
.replace(/default\s+charset\s*=\s*\w+/gi, '')
.replace(/collate\s*=\s*\w+/gi, '')
.replace(/modify\s+column/gi, 'ALTER COLUMN')
.replace(/add\s+column\s+if\s+not\s+exists/gi, 'ADD COLUMN')
.replace(/add\s+column/gi, 'ADD COLUMN')
.replace(/if\s+not\s+exists/gi, '')
.replace(/enum\([^)]+\)/gi, 'varchar(255)');
}
await db.raw(finalStmt);
} catch (stmtErr: any) {
if (db.client.config.client === 'pg') {
const pgErrorMsgs = [
'already exists',
'duplicate key',
'does not exist',
'already is a member'
];
if (pgErrorMsgs.some(msg => stmtErr.message.toLowerCase().includes(msg))) {
logger.info(`[PluginLoader] ⏭️ PostgreSQL skipped: ${stmtErr.message.substring(0, 80)}`);
} else {
logger.warn(`[PluginLoader] ⚠️ PostgreSQL migration statement failed (continuing): ${stmtErr.message}`);
}
} else {
const toleratedCodes = [1060, 1061, 1050, 1091];
if (toleratedCodes.includes(stmtErr.errno)) {
logger.info(`[PluginLoader] ⏭️ Skipped (already exists): ${stmtErr.message.substring(0, 80)}`);
} else {
throw stmtErr;
}
}
}
}
// Record migration
await db('plugin_migrations').insert({
plugin_name: manifest.name,
migration_file: file
});
logger.info(`[PluginLoader] ✅ Applied migration ${migrationKey}`);
} catch (err: any) {
logger.error(`[PluginLoader] ❌ Migration ${migrationKey} failed: ${err.message}`);
throw err;
}
}
}
function buildPluginContext(app: Express, io: any, manifest: PluginManifest): PluginContext {
return {
app,
io,
db,
hooks,
config: {},
logger: {
info: (msg, meta) => logger.info(`[Plugin:${manifest.name}] ${msg}`, meta),
warn: (msg, meta) => logger.warn(`[Plugin:${manifest.name}] ${msg}`, meta),
error: (msg, meta) => logger.error(`[Plugin:${manifest.name}] ${msg}`, meta),
},
};
}
function clearRequireCache(targetDir: string): void {
const normalized = path.resolve(targetDir);
Object.keys(require.cache).forEach(key => {
if (key.startsWith(normalized)) {
delete require.cache[key];
}
});
}
async function unloadPlugin(app: Express, io: any, name: string): Promise<void> {
const entry = pluginRegistry.get(name);
if (!entry) return;
const ctx = buildPluginContext(app, io, entry.manifest);
try {
await entry.instance.deactivate(ctx);
} catch (err: any) {
logger.warn(`[PluginLoader] Deactivate "${name}" failed: ${err.message}`);
}
const layers = entry.layers || [];
const routerStack = (app as any)?._router?.stack;
if (Array.isArray(routerStack) && layers.length > 0) {
(app as any)._router.stack = routerStack.filter((l: any) => !layers.includes(l));
}
pluginRegistry.remove(name);
}
async function loadPluginFromDir(app: Express, io: any, dirName: string, manifest: PluginManifest): Promise<void> {
const instance = loadPluginInstance(dirName, manifest);
if (!instance) {
pluginRegistry.register(manifest, { manifest, activate: async () => { }, deactivate: async () => { } });
pluginRegistry.markError(manifest.name, 'Failed to load plugin module');
return;
}
pluginRegistry.register(manifest, instance);
try {
await runMigrations(dirName, manifest);
} catch (err: any) {
pluginRegistry.markError(manifest.name, `Migration failed: ${err.message}`);
return;
}
const ctx = buildPluginContext(app, io, manifest);
const beforeStack = ((app as any)?._router?.stack || []).slice();
try {
await instance.activate(ctx);
pluginRegistry.markActive(manifest.name);
const afterStack = (app as any)?._router?.stack || [];
const newLayers = afterStack.filter((l: any) => !beforeStack.includes(l));
pluginRegistry.setLayers(manifest.name, newLayers);
logger.info(`[PluginLoader] ✅ Activated: ${manifest.displayName} v${manifest.version}`);
} catch (err: any) {
pluginRegistry.markError(manifest.name, err.message);
logger.error(`[PluginLoader] ❌ Failed to activate "${manifest.name}": ${err.message}`);
}
}
// ── Main Loader ─────────────────────────────────────────────
export async function loadPlugins(app: Express, io: any): Promise<void> {
logger.info(`[PluginLoader] Scanning plugins source: ${PLUGINS_SOURCE_DIR}`);
logger.info(`[PluginLoader] Compiled plugins at: ${PLUGINS_COMPILED_DIR}`);
if (!fs.existsSync(PLUGINS_SOURCE_DIR)) {
logger.info('[PluginLoader] No plugins directory found, creating...');
fs.mkdirSync(PLUGINS_SOURCE_DIR, { recursive: true });
return;
}
// 1. Scan and load manifests from source directory
const pluginDirs = fs.readdirSync(PLUGINS_SOURCE_DIR)
.map(name => ({ name, fullPath: path.join(PLUGINS_SOURCE_DIR, name) }))
.filter(p => fs.statSync(p.fullPath).isDirectory() && p.name !== 'node_modules');
const manifests: PluginManifest[] = [];
const pluginNames: Map<string, string> = new Map();
for (const { name: dirName, fullPath } of pluginDirs) {
const manifest = loadManifest(fullPath);
if (manifest && manifest.enabled) {
manifests.push(manifest);
pluginNames.set(manifest.name, dirName);
}
}
logger.info(`[PluginLoader] Found ${manifests.length} enabled plugins`);
// 2. Resolve dependency order
let sorted: PluginManifest[];
try {
sorted = resolveDependencyOrder(manifests);
} catch (err: any) {
logger.error(err.message);
return;
}
// 3. Activate plugins in order
for (const manifest of sorted) {
const dirName = pluginNames.get(manifest.name)!;
// Check dependencies
const depCheck = pluginRegistry.checkDependencies(manifest.name);
// Register first so dependency check works for later plugins
const instance = loadPluginInstance(dirName, manifest);
if (!instance) {
pluginRegistry.register(manifest, { manifest, activate: async () => { }, deactivate: async () => { } });
pluginRegistry.markError(manifest.name, 'Failed to load plugin module');
continue;
}
pluginRegistry.register(manifest, instance);
// Run migrations before activation
try {
await runMigrations(dirName, manifest);
} catch (err: any) {
pluginRegistry.markError(manifest.name, `Migration failed: ${err.message}`);
continue;
}
// Build plugin context
const ctx: PluginContext = buildPluginContext(app, io, manifest);
// Activate
try {
const beforeStack = ((app as any)?._router?.stack || []).slice();
await instance.activate(ctx);
pluginRegistry.markActive(manifest.name);
const afterStack = (app as any)?._router?.stack || [];
const newLayers = afterStack.filter((l: any) => !beforeStack.includes(l));
pluginRegistry.setLayers(manifest.name, newLayers);
logger.info(`[PluginLoader] ✅ Activated: ${manifest.displayName} v${manifest.version}`);
} catch (err: any) {
pluginRegistry.markError(manifest.name, err.message);
logger.error(`[PluginLoader] ❌ Failed to activate "${manifest.name}": ${err.message}`);
}
}
// 4. Summary
const summary = pluginRegistry.getSummary();
logger.info(`[PluginLoader] ═══════════════════════════════════`);
logger.info(`[PluginLoader] Plugins: ${summary.active} active, ${summary.error} errors, ${summary.inactive} inactive`);
logger.info(`[PluginLoader] ═══════════════════════════════════`);
// 5. Register admin API for plugin management
registerPluginAdminRoutes(app, io);
}
// ── Admin API Routes ────────────────────────────────────────
function registerPluginAdminRoutes(app: Express, io: any): void {
const adminOnly = [authenticate, authorize(['super_admin'])];
const uploadZip = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 50 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (file.originalname.toLowerCase().endsWith('.zip')) return cb(null, true);
cb(new Error('Apenas arquivos .zip são permitidos'));
},
});
const PLUGIN_LIST_CACHE_KEY = 'admin:plugins:list';
const PLUGIN_MENU_CACHE_PREFIX = 'admin:plugins:menu:';
const PLUGIN_CACHE_TTL_SECONDS = 30;
const clearPluginCaches = async () => {
try {
await redis.del(PLUGIN_LIST_CACHE_KEY);
const menuKeys = await redis.keys(`${PLUGIN_MENU_CACHE_PREFIX}*`);
if (menuKeys.length) await redis.del(...menuKeys);
} catch {
// ignore cache errors
}
};
function copyDir(srcDir: string, destDir: string) {
if (!fs.existsSync(srcDir)) return;
fs.mkdirSync(destDir, { recursive: true });
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
for (const entry of entries) {
const src = path.join(srcDir, entry.name);
const dest = path.join(destDir, entry.name);
if (entry.isDirectory()) copyDir(src, dest);
else fs.copyFileSync(src, dest);
}
}
function buildExportZip(pluginDir: string, lite: boolean): Buffer {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plugin-export-'));
const stageDir = path.join(tmpDir, 'plugin');
fs.mkdirSync(stageDir, { recursive: true });
const manifestPath = path.join(pluginDir, 'manifest.json');
if (fs.existsSync(manifestPath)) fs.copyFileSync(manifestPath, path.join(stageDir, 'manifest.json'));
const backendDir = path.join(pluginDir, 'backend');
const routesTs = path.join(backendDir, 'routes.ts');
const routesJs = path.join(backendDir, 'routes.js');
const serviceTs = path.join(backendDir, 'service.ts');
const serviceJs = path.join(backendDir, 'service.js');
const stageBackend = path.join(stageDir, 'backend');
fs.mkdirSync(stageBackend, { recursive: true });
if (fs.existsSync(routesTs)) fs.copyFileSync(routesTs, path.join(stageBackend, 'routes.ts'));
if (fs.existsSync(routesJs)) fs.copyFileSync(routesJs, path.join(stageBackend, 'routes.js'));
if (fs.existsSync(serviceTs)) fs.copyFileSync(serviceTs, path.join(stageBackend, 'service.ts'));
if (fs.existsSync(serviceJs)) fs.copyFileSync(serviceJs, path.join(stageBackend, 'service.js'));
copyDir(path.join(backendDir, 'models'), path.join(stageBackend, 'models'));
if (!lite) {
copyDir(path.join(backendDir, 'migrations'), path.join(stageBackend, 'migrations'));
const readmePath = path.join(pluginDir, 'README.md');
if (fs.existsSync(readmePath)) fs.copyFileSync(readmePath, path.join(stageDir, 'README.md'));
}
const zipPath = path.join(tmpDir, 'plugin.zip');
const zipProc = spawnSync('zip', ['-r', zipPath, '.'], { cwd: stageDir });
if (zipProc.status !== 0) {
throw new Error('Falha ao gerar ZIP');
}
const buffer = fs.readFileSync(zipPath);
fs.rmSync(tmpDir, { recursive: true, force: true });
return buffer;
}
async function importPluginFromZip(app: Express, io: any, buffer: Buffer, reloadAfter: boolean): Promise<{ name: string }> {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plugin-import-'));
const zipPath = path.join(tmpDir, 'plugin.zip');
fs.writeFileSync(zipPath, buffer);
const listProc = spawnSync('unzip', ['-Z1', zipPath]);
if (listProc.status !== 0) throw new Error('Arquivo ZIP inválido');
const entries = listProc.stdout.toString('utf-8').split('\n').filter(Boolean);
for (const name of entries) {
const clean = name.replace(/\\/g, '/');
if (clean.startsWith('/') || clean.includes('..') || clean.includes(':')) {
throw new Error('Arquivo ZIP inválido');
}
}
const extractDir = path.join(tmpDir, 'extract');
fs.mkdirSync(extractDir, { recursive: true });
const unzipProc = spawnSync('unzip', ['-o', zipPath, '-d', extractDir]);
if (unzipProc.status !== 0) throw new Error('Falha ao extrair ZIP');
const findManifest = (dir: string): string | null => {
const items = fs.readdirSync(dir, { withFileTypes: true });
for (const item of items) {
const full = path.join(dir, item.name);
if (item.isDirectory()) {
const found = findManifest(full);
if (found) return found;
} else if (item.name === 'manifest.json') {
return full;
}
}
return null;
};
const manifestPath = findManifest(extractDir);
if (!manifestPath) throw new Error('manifest.json não encontrado no ZIP');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as PluginManifest;
if (!manifest?.name) throw new Error('manifest.json inválido');
const rootDir = path.dirname(manifestPath);
const targetDir = path.join(PLUGINS_SOURCE_DIR, manifest.name);
if (fs.existsSync(targetDir)) {
await unloadPlugin(app, io, manifest.name);
fs.rmSync(targetDir, { recursive: true, force: true });
}
fs.mkdirSync(PLUGINS_SOURCE_DIR, { recursive: true });
fs.cpSync(rootDir, targetDir, { recursive: true });
clearRequireCache(path.join(PLUGINS_SOURCE_DIR, manifest.name));
clearRequireCache(path.join(PLUGINS_COMPILED_DIR, manifest.name));
const loaded = loadManifest(targetDir);
if (!loaded) throw new Error('manifest.json inválido no destino');
await loadPluginFromDir(app, io, manifest.name, loaded);
if (reloadAfter) {
await unloadPlugin(app, io, manifest.name);
clearRequireCache(path.join(PLUGINS_SOURCE_DIR, manifest.name));
clearRequireCache(path.join(PLUGINS_COMPILED_DIR, manifest.name));
await loadPluginFromDir(app, io, manifest.name, loaded);
}
fs.rmSync(tmpDir, { recursive: true, force: true });
await clearPluginCaches();
return { name: manifest.name };
}
// GET /api/admin/plugins — list all plugins
app.get('/api/admin/plugins', ...adminOnly, (req, res) => {
(async () => {
try {
const cached = await redis.get(PLUGIN_LIST_CACHE_KEY);
if (cached) {
return res.json(JSON.parse(cached));
}
} catch {
// ignore cache errors
}
const plugins = pluginRegistry.getAll().map(entry => ({
name: entry.manifest.name,
displayName: entry.manifest.displayName,
version: entry.manifest.version,
description: entry.manifest.description,
category: entry.manifest.category,
status: entry.status,
error: entry.error,
canDisable: entry.manifest.canDisable,
dependencies: entry.manifest.dependencies,
activatedAt: entry.activatedAt,
menuItems: entry.manifest.frontend?.menuItems || [],
}));
const payload = {
summary: pluginRegistry.getSummary(),
plugins,
};
try {
await redis.set(PLUGIN_LIST_CACHE_KEY, JSON.stringify(payload), 'EX', PLUGIN_CACHE_TTL_SECONDS);
} catch {
// ignore cache errors
}
res.json(payload);
})();
});
// GET /api/admin/plugins/menu — get sidebar menu items for current user
app.get('/api/admin/plugins/menu', ...adminOnly, (req, res) => {
(async () => {
const role = (req as any).user?.role || 'user';
const cacheKey = `${PLUGIN_MENU_CACHE_PREFIX}${role}`;
try {
const cached = await redis.get(cacheKey);
if (cached) {
return res.json(JSON.parse(cached));
}
} catch {
// ignore cache errors
}
const activeManifests = pluginRegistry.getActiveManifests();
const menuItems = activeManifests
.flatMap(m => (m.frontend?.menuItems || []).map(item => ({
...item,
pluginName: m.name,
})))
.filter(item => item.roles.includes(role) || item.roles.includes('*'))
.sort((a, b) => a.order - b.order);
const payload = { menuItems };
try {
await redis.set(cacheKey, JSON.stringify(payload), 'EX', PLUGIN_CACHE_TTL_SECONDS);
} catch {
// ignore cache errors
}
res.json(payload);
})();
});
// GET /api/admin/plugins/hooks — list registered hooks
app.get('/api/admin/plugins/hooks', ...adminOnly, (_req, res) => {
const hooksList = hooks.listEvents().map(event => ({
event,
handlerCount: hooks.handlerCount(event),
}));
res.json({ hooks: hooksList });
});
// GET /api/admin/plugins/:name/export — download plugin package
app.get('/api/admin/plugins/:name/export', ...adminOnly, (req, res) => {
const name = req.params.name;
const info = getPluginDirByName(name);
if (!info) return res.status(404).json({ error: 'Plugin não encontrado' });
const buffer = buildExportZip(info.fullPath, false);
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${name}.zip"`);
res.send(buffer);
});
// GET /api/admin/plugins/:name/export-lite — lightweight package
app.get('/api/admin/plugins/:name/export-lite', ...adminOnly, (req, res) => {
const name = req.params.name;
const info = getPluginDirByName(name);
if (!info) return res.status(404).json({ error: 'Plugin não encontrado' });
const buffer = buildExportZip(info.fullPath, true);
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${name}-lite.zip"`);
res.send(buffer);
});
// POST /api/admin/plugins/import — upload plugin ZIP
app.post('/api/admin/plugins/import', ...adminOnly, uploadZip.single('file'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'Arquivo ZIP obrigatório' });
const result = await importPluginFromZip(app, io, req.file.buffer, false);
await clearPluginCaches();
res.json({ message: 'Plugin importado', name: result.name });
} catch (err: any) {
res.status(400).json({ error: err.message || 'Erro ao importar plugin' });
}
});
// POST /api/admin/plugins/:name/reload — reload plugin
app.post('/api/admin/plugins/:name/reload', ...adminOnly, async (req, res) => {
const name = req.params.name;
const info = getPluginDirByName(name);
if (!info) return res.status(404).json({ error: 'Plugin não encontrado' });
try {
await unloadPlugin(app, io, name);
clearRequireCache(path.join(PLUGINS_SOURCE_DIR, info.dirName));
clearRequireCache(path.join(PLUGINS_COMPILED_DIR, info.dirName));
await loadPluginFromDir(app, io, info.dirName, info.manifest);
await clearPluginCaches();
res.json({ message: 'Plugin recarregado', name });
} catch (err: any) {
res.status(500).json({ error: err.message || 'Erro ao recarregar plugin' });
}
});
// POST /api/admin/plugins/:name/update — update plugin via ZIP
app.post('/api/admin/plugins/:name/update', ...adminOnly, uploadZip.single('file'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'Arquivo ZIP obrigatório' });
const result = await importPluginFromZip(app, io, req.file.buffer, true);
await clearPluginCaches();
res.json({ message: 'Plugin atualizado', name: result.name });
} catch (err: any) {
res.status(400).json({ error: err.message || 'Erro ao atualizar plugin' });
}
});
}
-120
View File
@@ -1,120 +0,0 @@
// ============================================================
// Clube67 — Plugin Registry
// ============================================================
// Central registry of all loaded plugins.
// Used by admin dashboard and frontend to query active plugins.
import { PluginManifest, PluginInstance } from './types';
import { logger } from '../utils/logger';
interface RegistryEntry {
manifest: PluginManifest;
instance: PluginInstance;
status: 'active' | 'inactive' | 'error';
error?: string;
activatedAt?: Date;
layers?: any[];
}
class PluginRegistry {
private plugins: Map<string, RegistryEntry> = new Map();
register(manifest: PluginManifest, instance: PluginInstance): void {
this.plugins.set(manifest.name, {
manifest,
instance,
status: 'inactive',
});
}
markActive(name: string): void {
const entry = this.plugins.get(name);
if (entry) {
entry.status = 'active';
entry.activatedAt = new Date();
}
}
markError(name: string, error: string): void {
const entry = this.plugins.get(name);
if (entry) {
entry.status = 'error';
entry.error = error;
}
}
markInactive(name: string): void {
const entry = this.plugins.get(name);
if (entry) {
entry.status = 'inactive';
entry.activatedAt = undefined;
}
}
setLayers(name: string, layers: any[]): void {
const entry = this.plugins.get(name);
if (entry) entry.layers = layers;
}
remove(name: string): void {
this.plugins.delete(name);
}
get(name: string): RegistryEntry | undefined {
return this.plugins.get(name);
}
getAll(): RegistryEntry[] {
return Array.from(this.plugins.values());
}
getActive(): RegistryEntry[] {
return this.getAll().filter(e => e.status === 'active');
}
getManifests(): PluginManifest[] {
return this.getAll().map(e => e.manifest);
}
getActiveManifests(): PluginManifest[] {
return this.getActive().map(e => e.manifest);
}
/** Get all menu items from active plugins (for frontend sidebar) */
getMenuItems(userRole: string): Array<{ pluginName: string; items: PluginManifest['frontend'] }> {
return this.getActive()
.filter(e => e.manifest.frontend?.menuItems?.length)
.map(e => ({
pluginName: e.manifest.name,
items: e.manifest.frontend!,
}));
}
/** Check if a plugin is active */
isActive(name: string): boolean {
return this.plugins.get(name)?.status === 'active';
}
/** Get plugin count summary */
getSummary(): { total: number; active: number; inactive: number; error: number } {
const all = this.getAll();
return {
total: all.length,
active: all.filter(e => e.status === 'active').length,
inactive: all.filter(e => e.status === 'inactive').length,
error: all.filter(e => e.status === 'error').length,
};
}
/** Check if all dependencies for a plugin are satisfied */
checkDependencies(name: string): { satisfied: boolean; missing: string[] } {
const entry = this.plugins.get(name);
if (!entry) return { satisfied: false, missing: [name] };
const missing = entry.manifest.dependencies.filter(dep => !this.isActive(dep));
return { satisfied: missing.length === 0, missing };
}
}
// Singleton
export const pluginRegistry = new PluginRegistry();
-6
View File
@@ -1,6 +0,0 @@
"use strict";
// ============================================================
// Clube67 — Plugin Manifest Schema
// ============================================================
// Each plugin MUST have a manifest.json following this interface.
Object.defineProperty(exports, "__esModule", { value: true });
-120
View File
@@ -1,120 +0,0 @@
// ============================================================
// Clube67 — Plugin Manifest Schema
// ============================================================
// Each plugin MUST have a manifest.json following this interface.
export interface PluginManifest {
/** Unique plugin identifier (matches folder name) */
name: string;
/** Human-readable display name */
displayName: string;
/** Semantic version */
version: string;
/** Brief description */
description: string;
/** Author or team */
author: string;
/** Plugin category for admin grouping */
category: 'core' | 'business' | 'integration' | 'ui' | 'analytics';
/** Is this plugin enabled by default? */
enabled: boolean;
/** Can this plugin be disabled by admin? (core plugins = false) */
canDisable: boolean;
/** Dependencies on other plugins (by name) */
dependencies: string[];
/** Backend configuration */
backend?: {
/** Route prefix (e.g. /api/auth, /api/users) */
routePrefix: string;
/** Has database migrations? */
hasMigrations: boolean;
/** Middleware to inject globally */
globalMiddleware?: string[];
};
/** Frontend configuration */
frontend?: {
/** Menu items to inject into sidebar */
menuItems: PluginMenuItem[];
/** Dashboard widgets */
widgets?: PluginWidget[];
/** Pages to register */
pages?: PluginPage[];
};
/** Hook subscriptions */
hooks?: {
/** Events this plugin listens to */
subscribes: string[];
/** Events this plugin emits */
emits: string[];
};
}
export interface PluginMenuItem {
id: string;
label: string;
icon: string;
href: string;
/** Which roles can see this menu item */
roles: string[];
/** Order in sidebar (lower = higher) */
order: number;
}
export interface PluginWidget {
id: string;
component: string;
/** Grid columns span (1-4) */
span: number;
roles: string[];
order: number;
}
export interface PluginPage {
path: string;
component: string;
roles: string[];
}
// ============================================================
// Plugin Lifecycle Interface
// ============================================================
import { Express } from 'express';
export interface PluginContext {
app: Express;
db: any;
hooks: HookSystem;
config: Record<string, any>;
logger: PluginLogger;
io: any; // Socket.IO Server
}
export interface PluginLogger {
info(message: string, meta?: Record<string, any>): void;
warn(message: string, meta?: Record<string, any>): void;
error(message: string, meta?: Record<string, any>): void;
}
export interface PluginInstance {
manifest: PluginManifest;
activate(ctx: PluginContext): Promise<void>;
deactivate(ctx: PluginContext): Promise<void>;
}
export interface HookSystem {
register(event: string, handler: (...args: any[]) => Promise<any>): void;
emit(event: string, ...args: any[]): Promise<any[]>;
remove(event: string, handler: (...args: any[]) => Promise<any>): void;
}
@@ -1,120 +0,0 @@
import { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// 1. users
await knex.schema.createTable('users', (table) => {
table.increments('id').primary();
table.string('name').notNullable();
table.string('email').unique().notNullable();
table.string('password_hash').notNullable();
table.string('role').notNullable().defaultTo('user');
table.string('status').notNullable().defaultTo('active');
table.string('whatsapp').nullable();
table.string('state').nullable();
table.string('city').nullable();
table.string('neighborhood').nullable();
table.integer('partner_id').nullable();
table.timestamps(true, true);
});
// 2. partners
await knex.schema.createTable('partners', (table) => {
table.increments('id').primary();
table.string('company_name').notNullable();
table.string('slug').unique().nullable();
table.string('cnpj').unique().nullable();
table.string('email').nullable();
table.string('phone').nullable();
table.text('description').nullable();
table.string('logo_url').nullable();
table.string('address_street').nullable();
table.string('address_number').nullable();
table.string('address_neighborhood').nullable();
table.string('address_city').nullable();
table.string('address_state').nullable();
table.string('type').nullable();
table.integer('owner_user_id').nullable();
table.string('status').notNullable().defaultTo('active');
table.integer('data_consent').notNullable().defaultTo(0);
table.timestamps(true, true);
});
// 3. categories
await knex.schema.createTable('categories', (table) => {
table.increments('id').primary();
table.string('name').notNullable();
table.string('slug').unique().notNullable();
table.string('icon').nullable();
table.timestamps(true, true);
});
// 4. benefits
await knex.schema.createTable('benefits', (table) => {
table.increments('id').primary();
table.integer('partner_id').notNullable();
table.integer('category_id').notNullable();
table.string('title').notNullable();
table.text('description').nullable();
table.string('discount_value').nullable();
table.text('rules').nullable();
table.boolean('active').notNullable().defaultTo(true);
table.integer('usage_count').notNullable().defaultTo(0);
table.boolean('is_global').notNullable().defaultTo(false);
table.timestamps(true, true);
});
// 5. transactions
await knex.schema.createTable('transactions', (table) => {
table.increments('id').primary();
table.integer('user_id').notNullable();
table.integer('benefit_id').notNullable();
table.integer('partner_id').notNullable();
table.string('type').notNullable().defaultTo('redemption');
table.string('status').notNullable().defaultTo('completed');
table.timestamps(true, true);
});
// 6. leads
await knex.schema.createTable('leads', (table) => {
table.increments('id').primary();
table.integer('user_id').nullable();
table.integer('partner_id').notNullable();
table.string('name').notNullable();
table.string('email').notNullable();
table.string('phone').nullable();
table.string('lead_type').notNullable().defaultTo('member');
table.string('status').notNullable().defaultTo('new');
table.timestamps(true, true);
});
// 7. favorites
await knex.schema.createTable('favorites', (table) => {
table.increments('id').primary();
table.integer('user_id').notNullable();
table.integer('benefit_id').notNullable();
table.timestamps(true, true);
});
// 8. logs
await knex.schema.createTable('logs', (table) => {
table.increments('id').primary();
table.integer('user_id').nullable();
table.string('action').notNullable();
table.string('entity_type').nullable();
table.integer('entity_id').nullable();
table.text('details').nullable();
table.string('ip_address').nullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists('logs');
await knex.schema.dropTableIfExists('favorites');
await knex.schema.dropTableIfExists('leads');
await knex.schema.dropTableIfExists('transactions');
await knex.schema.dropTableIfExists('benefits');
await knex.schema.dropTableIfExists('categories');
await knex.schema.dropTableIfExists('partners');
await knex.schema.dropTableIfExists('users');
}
@@ -1,15 +0,0 @@
import { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.table('users', (table) => {
table.timestamp('deleted_at').nullable().defaultTo(null);
table.boolean('is_anonymized').defaultTo(false);
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.table('users', (table) => {
table.dropColumn('is_anonymized');
table.dropColumn('deleted_at');
});
}
@@ -1,17 +0,0 @@
import { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
if (knex.client.config.client === 'mysql' || knex.client.config.client === 'mysql2') {
await knex.raw("ALTER TABLE users MODIFY COLUMN status ENUM('lead', 'pending', 'active', 'inactive', 'blocked', 'banned') NOT NULL DEFAULT 'active'");
} else {
// PostgreSQL: status is a varchar column, so no type modification is required.
}
}
export async function down(knex: Knex): Promise<void> {
if (knex.client.config.client === 'mysql' || knex.client.config.client === 'mysql2') {
await knex.raw("ALTER TABLE users MODIFY COLUMN status ENUM('lead', 'pending', 'active', 'inactive') NOT NULL DEFAULT 'active'");
} else {
// PostgreSQL: status is a varchar column.
}
}
@@ -1,15 +0,0 @@
import { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable('benefits', (table) => {
table.boolean('hide_partner_name').defaultTo(false);
table.string('partner_label').nullable();
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('benefits', (table) => {
table.dropColumn('hide_partner_name');
table.dropColumn('partner_label');
});
}
@@ -1,17 +0,0 @@
import { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
return knex.schema.table('partners', (table) => {
table.string('banner_url').nullable();
table.string('instagram').nullable();
table.string('website').nullable();
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.table('partners', (table) => {
table.dropColumn('banner_url');
table.dropColumn('instagram');
table.dropColumn('website');
});
}
@@ -1,13 +0,0 @@
import { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
return knex.schema.table('partners', (table) => {
table.string('logo_bg_color').nullable().defaultTo('#FFFFFF');
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.table('partners', (table) => {
table.dropColumn('logo_bg_color');
});
}
@@ -1,17 +0,0 @@
import { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.table("benefits", (table) => {
table.string("public_title").nullable();
table.text("public_description").nullable();
table.boolean("public_hide_values").defaultTo(false);
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.table("benefits", (table) => {
table.dropColumn("public_title");
table.dropColumn("public_description");
table.dropColumn("public_hide_values");
});
}
@@ -1,25 +0,0 @@
import { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('referrals', (table) => {
table.increments('id').primary();
table.integer('referrer_user_id').notNullable();
table.integer('benefit_id').notNullable();
table.string('referral_code', 50).notNullable();
table.string('referred_email').nullable();
table.integer('referred_user_id').nullable();
table.enum('status', ['pending', 'clicked', 'converted', 'expired']).defaultTo('pending');
table.timestamp('clicked_at').nullable();
table.timestamp('converted_at').nullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
table.index(['referrer_user_id']);
table.index(['referral_code']);
table.index(['benefit_id']);
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists('referrals');
}
+91 -72
View File
@@ -1,13 +1,13 @@
import express from 'express';
import http from 'http';
import { Server } from 'socket.io';
import { Server as SocketServer } from 'socket.io';
import { config } from './config';
import { setupSecurity } from './middleware/security';
import { logger } from './utils/logger';
import logger from './utils/logger';
import db from './config/database';
import { pluginService } from './services/plugin.service';
import path from 'path';
// ── Legacy monolithic routes (kept for backward compatibility) ──
// Route imports
import authRoutes from './routes/auth.routes';
import userRoutes from './routes/user.routes';
import partnerRoutes from './routes/partner.routes';
@@ -15,86 +15,105 @@ import benefitRoutes from './routes/benefit.routes';
import adminRoutes from './routes/admin.routes';
import webhookRoutes from './routes/webhook.routes';
import pluginRoutes from './routes/plugin.routes';
import leadRoutes from './routes/lead.routes';
import uploadRoutes from './routes/upload.routes';
import referralRoutes from './routes/referral.routes';
// ── New Plugin System ──────────────────────────────────────────
import { loadPlugins } from './core/plugin-loader';
import { pluginRegistry } from './core/plugin-registry';
import { storageProvider } from './core/StorageProvider';
import { checkStorageHealth } from './middleware/storage-health';
const app = express();
const server = http.createServer(app);
async function start() {
const app = express();
const server = http.createServer(app);
const io = new Server(server, { cors: { origin: '*' } });
// WebSocket
const io = new SocketServer(server, {
cors: {
origin: [config.app.frontendUrl, 'http://localhost:3000'],
credentials: true,
},
});
// Trust Nginx proxy (required for express-rate-limit behind reverse proxy)
app.set('trust proxy', 1);
// Body parsing
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
setupSecurity(app);
// Security middleware
setupSecurity(app);
// ── Fail-Safe Storage Monitoring ────────────────────────
app.use(checkStorageHealth);
// Static files (uploads)
app.use('/uploads', express.static(config.upload.dir));
// WebSocket
io.on('connection', (socket) => {
const { partnerId, userId } = socket.handshake.query;
if (partnerId) socket.join(`partner:${partnerId}`);
if (userId) socket.join(`user:${userId}`);
});
// Health (enhanced with plugin info)
app.get('/api/health', (_req, res) => res.json({
// Health check
app.get('/api/health', (_req, res) => {
res.json({
status: 'ok',
service: 'Clube de Benefícios',
architecture: 'Core + Plugins',
timestamp: new Date(),
service: config.app.name,
timestamp: new Date().toISOString(),
uptime: process.uptime(),
plugins: pluginRegistry.getSummary(),
storage: {
available: storageProvider.isAvailable()
}
}));
});
});
// ── Legacy Routes (will be gradually replaced by plugins) ──
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/partners', partnerRoutes);
app.use('/api/benefits', benefitRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/webhooks', webhookRoutes);
app.use('/api/plugins', pluginRoutes);
app.use('/api/leads', leadRoutes);
app.use('/api/uploads', uploadRoutes);
app.use('/api/referrals', referralRoutes);
// API routes
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/partners', partnerRoutes);
app.use('/api/benefits', benefitRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/webhooks', webhookRoutes);
app.use('/api/plugins', pluginRoutes);
// Static
app.use('/uploads', express.static(config.upload.dir));
app.use(express.static(path.join(process.cwd(), '../frontend')));
// WebSocket events
io.on('connection', (socket) => {
logger.info(`Socket connected: ${socket.id}`);
// SPA Wildcard fallback
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api') || req.path.startsWith('/uploads')) {
return next();
}
res.sendFile(path.join(process.cwd(), '../frontend/index.html'));
socket.on('join:partner', (partnerId: string) => {
socket.join(`partner:${partnerId}`);
});
// ── Load & Activate Plugins ───────────────────────────────
logger.info('═══════════════════════════════════');
logger.info(' Clube67 — Plugin Architecture');
logger.info('═══════════════════════════════════');
await loadPlugins(app, io);
server.listen(config.port, () => {
logger.info(`🚀 Server running on port ${config.port}`);
logger.info(`📦 Architecture: Core + ${pluginRegistry.getSummary().active} plugins`);
socket.on('join:user', (userId: string) => {
socket.join(`user:${userId}`);
});
socket.on('disconnect', () => {
logger.debug(`Socket disconnected: ${socket.id}`);
});
});
// Make io accessible to routes
app.set('io', io);
// 404
app.use((_req, res) => {
res.status(404).json({ error: 'Rota não encontrada' });
});
// Error handler
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
logger.error('Unhandled error:', err);
res.status(err.status || 500).json({
error: config.app.env === 'production' ? 'Erro interno do servidor' : err.message,
});
});
// Start
async function start() {
try {
// Test DB connection
await db.raw('SELECT 1');
logger.info('✅ Database connected');
// Note: Schema managed via SQL (backend/schema.sql)
// Migrations are not auto-run to avoid .d.ts loading issues
// Load plugins
await pluginService.loadAll(app);
server.listen(config.app.port, () => {
logger.info(`🚀 ${config.app.name} running on port ${config.app.port}`);
console.log(`🚀 ${config.app.name} running on http://localhost:${config.app.port}`);
console.log(`📋 Health: http://localhost:${config.app.port}/api/health`);
});
} catch (err) {
logger.error('❌ Failed to start server:', err);
console.error('❌ Failed to start:', err);
process.exit(1);
}
}
start().catch(err => {
logger.error('Failed to start server:', err);
process.exit(1);
});
start();
export { app, io };
-34
View File
@@ -1,34 +0,0 @@
import { redis } from '../../config/redis';
class DragonflyClient {
async get(key: string): Promise<string | null> {
try { return await redis.get(key); } catch { return null; }
}
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
try {
if (ttlSeconds && ttlSeconds > 0) {
await redis.set(key, value, 'EX', ttlSeconds);
} else {
await redis.set(key, value);
}
} catch { }
}
async del(key: string): Promise<void> {
try { await redis.del(key); } catch { }
}
async exists(key: string): Promise<boolean> {
try {
const count = await redis.exists(key);
return count > 0;
} catch { return false; }
}
async publish(channel: string, message: string): Promise<void> {
try { await redis.publish(channel, message); } catch { }
}
}
export const dragonfly = new DragonflyClient();
+47 -29
View File
@@ -1,44 +1,28 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { config } from '../config';
import { db } from '../config/database';
import db from '../config/database';
export function generateTokens(user: any) {
const accessToken = jwt.sign(
{ id: user.id, role: user.role, partner_id: user.partner_id },
config.jwt.secret,
{ expiresIn: config.jwt.expiresIn as any }
);
const refreshToken = jwt.sign(
{ id: user.id },
config.jwt.refreshSecret,
{ expiresIn: config.jwt.refreshExpiresIn as any }
);
return { accessToken, refreshToken };
export interface JwtPayload {
id: number;
email: string;
role: string;
}
export async function authenticate(req: Request, res: Response, next: NextFunction) {
export interface AuthRequest extends Request {
user?: JwtPayload;
}
export function authenticate(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Token não fornecido' });
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Token de autenticação não fornecido' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, config.jwt.secret) as any;
const decoded = jwt.verify(token, config.jwt.secret) as JwtPayload;
req.user = decoded;
if (decoded.role === 'partner' && decoded.partner_id) {
const partner = await db('partners').where({ id: decoded.partner_id }).first();
(req as any).partnerConsent = Boolean(partner?.data_consent);
(req as any).partnerId = partner?.id;
if (!partner?.data_consent) {
const consentRoute = req.method === 'POST' && req.path === '/api/partners/consent';
if (!consentRoute) {
return res.status(403).json({ error: 'Consentimento LGPD pendente' });
}
}
}
next();
} catch (err: any) {
if (err.name === 'TokenExpiredError') {
@@ -47,3 +31,37 @@ export async function authenticate(req: Request, res: Response, next: NextFuncti
return res.status(401).json({ error: 'Token inválido' });
}
}
export function optionalAuth(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return next();
}
const token = authHeader.split(' ')[1];
try {
req.user = jwt.verify(token, config.jwt.secret) as JwtPayload;
} catch { /* ignore */ }
next();
}
export function generateTokens(user: { id: number; email: string; role: string }) {
const payload: JwtPayload = { id: user.id, email: user.email, role: user.role };
const accessToken = jwt.sign(payload, config.jwt.secret, { expiresIn: config.jwt.expiresIn as any });
const refreshToken = jwt.sign(payload, config.jwt.refreshSecret, { expiresIn: config.jwt.refreshExpiresIn as any });
return { accessToken, refreshToken };
}
export async function refreshAccessToken(refreshToken: string) {
try {
const decoded = jwt.verify(refreshToken, config.jwt.refreshSecret) as JwtPayload;
const user = await db('users').where('id', decoded.id).first();
if (!user || user.refresh_token !== refreshToken) {
throw new Error('Invalid refresh token');
}
const tokens = generateTokens(user);
await db('users').where('id', user.id).update({ refresh_token: tokens.refreshToken });
return tokens;
} catch {
throw new Error('Invalid refresh token');
}
}
+29 -18
View File
@@ -1,29 +1,40 @@
import { Request, Response, NextFunction } from 'express';
import { Response, NextFunction } from 'express';
import { AuthRequest } from './auth';
const roleHierarchy: Record<string, number> = {
user: 1,
secretary: 2,
clinical_manager: 3,
finance: 3,
partner_admin: 4,
admin: 5,
super_admin: 10,
type Role = 'user' | 'partner_admin' | 'secretary' | 'clinical_manager' | 'finance' | 'super_admin';
const ROLE_HIERARCHY: Record<Role, number> = {
user: 0,
secretary: 1,
clinical_manager: 2,
finance: 2,
partner_admin: 3,
super_admin: 4,
};
export function authorize(roles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Acesso negado' });
export function authorize(...allowedRoles: Role[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: 'Não autenticado' });
}
const userRole = req.user.role as Role;
if (userRole === 'super_admin') return next(); // super admin bypasses all
if (!allowedRoles.includes(userRole)) {
return res.status(403).json({ error: 'Permissão insuficiente' });
}
next();
};
}
export function minRole(role: string) {
return (req: Request, res: Response, next: NextFunction) => {
const userRole = req.user?.role || 'user';
if (roleHierarchy[userRole] < roleHierarchy[role]) {
return res.status(403).json({ error: 'Nível de privilégio insuficiente' });
export function requireRole(minRole: Role) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: 'Não autenticado' });
}
const userLevel = ROLE_HIERARCHY[req.user.role as Role] ?? -1;
const requiredLevel = ROLE_HIERARCHY[minRole];
if (userLevel < requiredLevel) {
return res.status(403).json({ error: 'Nível de permissão insuficiente' });
}
next();
};
+63 -23
View File
@@ -1,41 +1,81 @@
import express, { Express } from 'express';
import helmet from 'helmet';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import cors from 'cors';
import compression from 'compression';
import hpp from 'hpp';
import { Express, Request, Response, NextFunction } from 'express';
import { config } from '../config';
export function setupSecurity(app: Express) {
// CORS
app.use(cors({
origin: [config.app.frontendUrl, 'http://localhost:3000'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-CSRF-Token'],
}));
// Helmet - security headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
"default-src": ["'self'"],
"script-src": ["'self'", "'unsafe-inline'", "https://unpkg.com"],
"script-src-elem": ["'self'", "'unsafe-inline'", "https://unpkg.com"],
"style-src": ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
"img-src": ["'self'", "data:", "https://api.qrserver.com", "https://unpkg.com"],
"font-src": ["'self'", "https://fonts.gstatic.com"],
"connect-src": ["'self'", "https://unpkg.com"],
"object-src": ["'none'"]
}
}
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
imgSrc: ["'self'", 'data:', 'https:', 'blob:'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
connectSrc: ["'self'", 'https://accounts.google.com', 'wss:', 'ws:'],
},
},
crossOriginEmbedderPolicy: false,
}));
app.use(cors({ origin: '*' }));
app.use(compression());
app.use(hpp());
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
const limiter = rateLimit({
// Rate limiting
const generalLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 1000, // Increased from 100 to avoid blocking frontend navigation
max: 200,
message: { error: 'Muitas requisições. Tente novamente em 15 minutos.' },
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api/', limiter);
app.use('/api/', generalLimiter);
const authLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 20,
message: 'Muitas tentativas de login, tente novamente mais tarde',
windowMs: 15 * 60 * 1000,
max: 15,
message: { error: 'Muitas tentativas de login. Tente novamente em 15 minutos.' },
});
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/register', authLimiter);
// Compression
app.use(compression());
// HPP - HTTP Parameter Pollution
app.use(hpp());
// XSS sanitize via a simple middleware
app.use((req: Request, _res: Response, next: NextFunction) => {
if (req.body) {
sanitizeObject(req.body);
}
next();
});
// Disable directory listing / powered-by
app.disable('x-powered-by');
}
function sanitizeObject(obj: any) {
for (const key in obj) {
if (typeof obj[key] === 'string') {
obj[key] = obj[key]
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/javascript:/gi, '')
.replace(/on\w+=/gi, '');
} else if (typeof obj[key] === 'object' && obj[key] !== null) {
sanitizeObject(obj[key]);
}
}
}
-18
View File
@@ -1,18 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import { storageProvider } from '../core/StorageProvider';
export const checkStorageHealth = (req: Request, res: Response, next: NextFunction) => {
// Bypass check for static assets and frontend pages (non-API routes)
if (!req.path.startsWith('/api')) return next();
// Exclude health check and PWA config so we can still monitor and register service workers
if (req.path === '/api/health' || req.path === '/api/pwa/config') return next();
if (!storageProvider.isAvailable()) {
return res.status(503).json({
error: 'Serviço temporariamente indisponível',
message: 'O sistema de armazenamento (Wasabi) está fora do ar. Por segurança, todos os acessos foram bloqueados.'
});
}
next();
};
+22 -11
View File
@@ -2,23 +2,34 @@ import multer from 'multer';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { config } from '../config';
import { Request } from 'express';
const ALLOWED_TYPES = [
'image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/svg+xml',
'application/pdf', 'application/zip',
];
const storage = multer.diskStorage({
destination: config.upload.dir,
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${uuidv4()}${ext}`);
destination: (_req: Request, _file: Express.Multer.File, cb) => {
cb(null, config.upload.dir);
},
filename: (_req: Request, file: Express.Multer.File, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const name = `${uuidv4()}${ext}`;
cb(null, name);
},
});
function fileFilter(_req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) {
if (ALLOWED_TYPES.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`Tipo de arquivo não permitido: ${file.mimetype}`));
}
}
export const upload = multer({
storage,
fileFilter,
limits: { fileSize: config.upload.maxSize },
fileFilter: (req, file, cb) => {
const allowed = /jpeg|jpg|png|webp|gif|pdf/;
const ext = allowed.test(path.extname(file.originalname).toLowerCase());
const mime = allowed.test(file.mimetype);
if (ext && mime) return cb(null, true);
cb(new Error('Tipo de arquivo não permitido'));
},
});
@@ -0,0 +1,277 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
// ── users ──
await knex.schema.createTable('users', (t) => {
t.increments('id').primary();
t.string('name', 255).notNullable();
t.string('email', 255).unique().notNullable();
t.string('password_hash', 255).nullable();
t.string('phone', 30).nullable();
t.string('cpf', 14).unique().nullable();
t.string('city', 100).nullable();
t.string('neighborhood', 100).nullable();
t.enum('gender', ['M', 'F', 'O']).nullable();
t.date('birth_date').nullable();
t.string('avatar_url', 500).nullable();
t.enum('role', ['user', 'partner_admin', 'secretary', 'clinical_manager', 'finance', 'super_admin'])
.notNullable().defaultTo('user');
t.enum('status', ['lead', 'pending', 'active', 'inactive']).notNullable().defaultTo('active');
t.boolean('email_verified').defaultTo(false);
t.string('refresh_token', 500).nullable();
t.timestamp('last_login').nullable();
t.timestamps(true, true);
t.index('email');
t.index('role');
t.index('status');
});
// ── oauth_accounts ──
await knex.schema.createTable('oauth_accounts', (t) => {
t.increments('id').primary();
t.integer('user_id').unsigned().notNullable()
.references('id').inTable('users').onDelete('CASCADE');
t.string('provider', 50).notNullable(); // google, facebook, etc
t.string('provider_user_id', 255).notNullable();
t.string('access_token', 1000).nullable();
t.string('refresh_token', 1000).nullable();
t.timestamp('token_expires_at').nullable();
t.json('profile_data').nullable();
t.timestamps(true, true);
t.unique(['provider', 'provider_user_id']);
t.index('user_id');
});
// ── categories ──
await knex.schema.createTable('categories', (t) => {
t.increments('id').primary();
t.string('name', 100).notNullable().unique();
t.string('slug', 100).notNullable().unique();
t.string('icon', 50).nullable();
t.string('color', 50).nullable();
t.text('description').nullable();
t.boolean('active').defaultTo(true);
t.integer('sort_order').defaultTo(0);
t.timestamps(true, true);
});
// ── partners ──
await knex.schema.createTable('partners', (t) => {
t.increments('id').primary();
t.string('company_name', 255).notNullable();
t.string('slug', 255).unique().notNullable();
t.string('cnpj', 20).unique().nullable();
t.string('email', 255).nullable();
t.string('phone', 30).nullable();
t.string('address_street', 255).nullable();
t.string('address_number', 20).nullable();
t.string('address_neighborhood', 100).nullable();
t.string('address_city', 100).nullable();
t.string('address_state', 2).nullable();
t.string('address_zip', 10).nullable();
t.string('type', 100).notNullable();
t.integer('monthly_goal').defaultTo(0);
t.enum('status', ['active', 'inactive']).notNullable().defaultTo('active');
t.string('gradient_from', 50).nullable();
t.string('gradient_to', 50).nullable();
t.string('icon', 10).nullable();
t.text('description').nullable();
t.string('logo_url', 500).nullable();
t.json('images').nullable();
t.string('domain', 255).nullable();
t.string('subdomain', 255).nullable();
t.enum('subscription_plan', ['basic', 'pro', 'enterprise']).defaultTo('basic');
t.integer('owner_user_id').unsigned().nullable()
.references('id').inTable('users').onDelete('SET NULL');
t.timestamps(true, true);
t.index('slug');
t.index('status');
});
// ── benefits ──
await knex.schema.createTable('benefits', (t) => {
t.increments('id').primary();
t.integer('partner_id').unsigned().notNullable()
.references('id').inTable('partners').onDelete('CASCADE');
t.integer('category_id').unsigned().nullable()
.references('id').inTable('categories').onDelete('SET NULL');
t.string('title', 255).notNullable();
t.text('description').nullable();
t.text('rules').nullable();
t.string('contact', 255).nullable();
t.string('type', 50).notNullable();
t.enum('delivery_type', ['local', 'external']).notNullable().defaultTo('local');
t.boolean('active').defaultTo(true);
t.boolean('is_global').defaultTo(false);
t.boolean('approved_by_admin').defaultTo(false);
t.enum('global_request_status', ['none', 'pending', 'approved']).defaultTo('none');
t.integer('priority').defaultTo(0);
t.decimal('discount_percent', 5, 2).nullable();
t.date('valid_until').nullable();
t.string('featured_image', 500).nullable();
t.enum('redemption_type', ['coupon', 'qrcode', 'card', 'app']).defaultTo('coupon');
t.integer('usage_count').defaultTo(0);
t.decimal('savings_amount', 12, 2).defaultTo(0);
t.string('badge', 50).nullable();
t.timestamps(true, true);
t.index('partner_id');
t.index('category_id');
t.index('active');
t.index('type');
});
// ── transactions ──
await knex.schema.createTable('transactions', (t) => {
t.increments('id').primary();
t.integer('user_id').unsigned().notNullable()
.references('id').inTable('users').onDelete('CASCADE');
t.integer('benefit_id').unsigned().notNullable()
.references('id').inTable('benefits').onDelete('CASCADE');
t.integer('partner_id').unsigned().notNullable()
.references('id').inTable('partners').onDelete('CASCADE');
t.enum('type', ['redemption', 'view', 'favorite', 'share']).notNullable();
t.enum('status', ['pending', 'completed', 'cancelled', 'expired']).defaultTo('pending');
t.string('redemption_code', 100).nullable();
t.string('qr_code_data', 500).nullable();
t.decimal('original_value', 12, 2).nullable();
t.decimal('discount_value', 12, 2).nullable();
t.timestamp('redeemed_at').nullable();
t.timestamp('expires_at').nullable();
t.json('metadata').nullable();
t.timestamps(true, true);
t.index('user_id');
t.index('benefit_id');
t.index('status');
t.index('redemption_code');
});
// ── favorites ──
await knex.schema.createTable('favorites', (t) => {
t.increments('id').primary();
t.integer('user_id').unsigned().notNullable()
.references('id').inTable('users').onDelete('CASCADE');
t.integer('benefit_id').unsigned().notNullable()
.references('id').inTable('benefits').onDelete('CASCADE');
t.timestamps(true, true);
t.unique(['user_id', 'benefit_id']);
});
// ── sessions ──
await knex.schema.createTable('sessions', (t) => {
t.increments('id').primary();
t.integer('user_id').unsigned().notNullable()
.references('id').inTable('users').onDelete('CASCADE');
t.string('token_hash', 255).notNullable();
t.string('ip_address', 45).nullable();
t.string('user_agent', 500).nullable();
t.timestamp('expires_at').notNullable();
t.boolean('is_active').defaultTo(true);
t.timestamps(true, true);
t.index('user_id');
t.index('token_hash');
});
// ── logs (audit) ──
await knex.schema.createTable('logs', (t) => {
t.increments('id').primary();
t.integer('user_id').unsigned().nullable()
.references('id').inTable('users').onDelete('SET NULL');
t.string('action', 100).notNullable();
t.string('entity_type', 100).nullable();
t.integer('entity_id').nullable();
t.text('description').nullable();
t.json('old_data').nullable();
t.json('new_data').nullable();
t.string('ip_address', 45).nullable();
t.string('user_agent', 500).nullable();
t.timestamps(true, true);
t.index('user_id');
t.index('action');
t.index('entity_type');
t.index('created_at');
});
// ── plugins ──
await knex.schema.createTable('plugins', (t) => {
t.increments('id').primary();
t.string('name', 255).notNullable().unique();
t.string('slug', 255).notNullable().unique();
t.string('version', 20).notNullable();
t.text('description').nullable();
t.string('author', 255).nullable();
t.string('entry_point', 255).notNullable();
t.boolean('active').defaultTo(false);
t.json('dependencies').nullable();
t.json('config').nullable();
t.json('hooks').nullable();
t.string('directory', 500).notNullable();
t.timestamps(true, true);
t.index('slug');
t.index('active');
});
// ── webhooks ──
await knex.schema.createTable('webhooks', (t) => {
t.increments('id').primary();
t.integer('partner_id').unsigned().nullable()
.references('id').inTable('partners').onDelete('CASCADE');
t.string('url', 500).notNullable();
t.string('secret', 255).nullable();
t.json('events').notNullable(); // ['benefit.created', 'user.registered', etc]
t.boolean('active').defaultTo(true);
t.integer('retry_count').defaultTo(0);
t.integer('max_retries').defaultTo(3);
t.timestamp('last_triggered').nullable();
t.enum('last_status', ['success', 'failed', 'pending']).nullable();
t.timestamps(true, true);
t.index('partner_id');
t.index('active');
});
// ── benefit_share_requests ──
await knex.schema.createTable('benefit_share_requests', (t) => {
t.increments('id').primary();
t.integer('benefit_id').unsigned().notNullable()
.references('id').inTable('benefits').onDelete('CASCADE');
t.integer('requesting_partner_id').unsigned().notNullable()
.references('id').inTable('partners').onDelete('CASCADE');
t.integer('owner_partner_id').unsigned().notNullable()
.references('id').inTable('partners').onDelete('CASCADE');
t.enum('status', ['pending', 'approved', 'rejected']).defaultTo('pending');
t.timestamps(true, true);
});
// ── benefit_partner_links ──
await knex.schema.createTable('benefit_partner_links', (t) => {
t.increments('id').primary();
t.integer('benefit_id').unsigned().notNullable()
.references('id').inTable('benefits').onDelete('CASCADE');
t.integer('partner_id').unsigned().notNullable()
.references('id').inTable('partners').onDelete('CASCADE');
t.timestamps(true, true);
t.unique(['benefit_id', 'partner_id']);
});
}
export async function down(knex: Knex): Promise<void> {
const tables = [
'benefit_partner_links', 'benefit_share_requests',
'webhooks', 'plugins', 'logs', 'sessions',
'favorites', 'transactions', 'benefits',
'partners', 'categories', 'oauth_accounts', 'users',
];
for (const table of tables) {
await knex.schema.dropTableIfExists(table);
}
}
@@ -1,80 +0,0 @@
"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 });
exports.proto = exports.isJidStatusBroadcast = exports.isJidGroup = exports.generateWAMessageFromContent = exports.generateMessageIDV2 = exports.getContentType = exports.downloadMediaMessage = exports.makeCacheableSignalKeyStore = exports.fetchLatestBaileysVersion = exports.DisconnectReason = exports.useMultiFileAuthState = exports.makeWASocket = void 0;
exports.getEngine = getEngine;
const official = __importStar(require("./official"));
const infinite = __importStar(require("./infinite"));
// Engine padrão global: lida de BAILEYS_ENGINE (fallback para 'infinite')
const defaultEngineType = process.env.BAILEYS_ENGINE ?? 'infinite';
function resolveEngine(type) {
return type === 'infinite' ? infinite : official;
}
/**
* Retorna os exports da engine selecionada para uma instância específica.
* Permite que cada instância use uma engine diferente (infinite ou official).
* Se não informado, usa BAILEYS_ENGINE do ambiente (padrão: 'infinite').
*/
function getEngine(engineType) {
const e = resolveEngine(engineType ?? defaultEngineType);
return {
makeWASocket: e.makeWASocket,
useMultiFileAuthState: e.useMultiFileAuthState,
fetchLatestBaileysVersion: e.fetchLatestBaileysVersion,
makeCacheableSignalKeyStore: e.makeCacheableSignalKeyStore,
downloadMediaMessage: e.downloadMediaMessage,
getContentType: e.getContentType,
generateMessageIDV2: e.generateMessageIDV2,
generateWAMessageFromContent: e.generateWAMessageFromContent,
isJidGroup: e.isJidGroup,
isJidStatusBroadcast: e.isJidStatusBroadcast,
};
}
// Exports estáticos: usam a engine global (retrocompatibilidade com código existente)
const selectedEngine = resolveEngine(defaultEngineType);
exports.makeWASocket = selectedEngine.makeWASocket;
exports.useMultiFileAuthState = selectedEngine.useMultiFileAuthState;
exports.DisconnectReason = selectedEngine.DisconnectReason;
exports.fetchLatestBaileysVersion = selectedEngine.fetchLatestBaileysVersion;
exports.makeCacheableSignalKeyStore = selectedEngine.makeCacheableSignalKeyStore;
exports.downloadMediaMessage = selectedEngine.downloadMediaMessage;
exports.getContentType = selectedEngine.getContentType;
exports.generateMessageIDV2 = selectedEngine.generateMessageIDV2;
exports.generateWAMessageFromContent = selectedEngine.generateWAMessageFromContent;
exports.isJidGroup = selectedEngine.isJidGroup;
exports.isJidStatusBroadcast = selectedEngine.isJidStatusBroadcast;
var official_1 = require("./official");
Object.defineProperty(exports, "proto", { enumerable: true, get: function () { return official_1.proto; } });
//# sourceMappingURL=index.js.map
@@ -1 +0,0 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAeA,8BAyBC;AAxCD,qDAAsC;AACtC,qDAAsC;AAEtC,0EAA0E;AAC1E,MAAM,iBAAiB,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,IAAI,UAAU,CAAA;AAElE,SAAS,aAAa,CAAC,IAAY;IACjC,OAAO,IAAI,KAAK,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAA;AAClD,CAAC;AAED;;;;GAIG;AACH,SAAgB,SAAS,CAAC,UAA0B;IAYlD,MAAM,CAAC,GAAG,aAAa,CAAC,UAAU,IAAI,iBAAiB,CAAC,CAAA;IACxD,OAAO;QACL,YAAY,EAAgB,CAAC,CAAC,YAAmB;QACjD,qBAAqB,EAAO,CAAC,CAAC,qBAA4B;QAC1D,yBAAyB,EAAG,CAAC,CAAC,yBAAgC;QAC9D,2BAA2B,EAAE,CAAC,CAAC,2BAAkC;QACjE,oBAAoB,EAAQ,CAAC,CAAC,oBAA2B;QACzD,cAAc,EAAc,CAAC,CAAC,cAAqB;QACnD,mBAAmB,EAAS,CAAC,CAAC,mBAA0B;QACxD,4BAA4B,EAAE,CAAC,CAAC,4BAAmC;QACnE,UAAU,EAAkB,CAAC,CAAC,UAAiB;QAC/C,oBAAoB,EAAQ,CAAC,CAAC,oBAA2B;KAC1D,CAAA;AACH,CAAC;AAED,sFAAsF;AACtF,MAAM,cAAc,GAAG,aAAa,CAAC,iBAAiB,CAAC,CAAA;AAE1C,QAAA,YAAY,GAAG,cAAc,CAAC,YAAmB,CAAA;AACjD,QAAA,qBAAqB,GAAG,cAAc,CAAC,qBAA4B,CAAA;AACnE,QAAA,gBAAgB,GAAG,cAAc,CAAC,gBAAuB,CAAA;AACzD,QAAA,yBAAyB,GAAG,cAAc,CAAC,yBAAgC,CAAA;AAC3E,QAAA,2BAA2B,GAAG,cAAc,CAAC,2BAAkC,CAAA;AAC/E,QAAA,oBAAoB,GAAG,cAAc,CAAC,oBAA2B,CAAA;AACjE,QAAA,cAAc,GAAG,cAAc,CAAC,cAAqB,CAAA;AACrD,QAAA,mBAAmB,GAAG,cAAc,CAAC,mBAA0B,CAAA;AAC/D,QAAA,4BAA4B,GAAG,cAAc,CAAC,4BAAmC,CAAA;AACjF,QAAA,UAAU,GAAG,cAAc,CAAC,UAAiB,CAAA;AAC7C,QAAA,oBAAoB,GAAG,cAAc,CAAC,oBAA2B,CAAA;AAC9E,uCAAkC;AAAzB,iGAAA,KAAK,OAAA"}
@@ -1,60 +0,0 @@
import * as official from './official'
import * as infinite from './infinite'
// Engine padrão global: lida de BAILEYS_ENGINE (fallback para 'infinite')
const defaultEngineType = process.env.BAILEYS_ENGINE ?? 'infinite'
function resolveEngine(type: string) {
return type === 'infinite' ? infinite : official
}
/**
* Retorna os exports da engine selecionada para uma instância específica.
* Permite que cada instância use uma engine diferente (infinite ou official).
* Se não informado, usa BAILEYS_ENGINE do ambiente (padrão: 'infinite').
*/
export function getEngine(engineType?: string | null): {
makeWASocket: any
useMultiFileAuthState: any
fetchLatestBaileysVersion: any
makeCacheableSignalKeyStore: any
downloadMediaMessage: any
getContentType: any
generateMessageIDV2: any
generateWAMessageFromContent: any
isJidGroup: any
isJidStatusBroadcast: any
} {
const e = resolveEngine(engineType ?? defaultEngineType)
return {
makeWASocket: e.makeWASocket as any,
useMultiFileAuthState: e.useMultiFileAuthState as any,
fetchLatestBaileysVersion: e.fetchLatestBaileysVersion as any,
makeCacheableSignalKeyStore: e.makeCacheableSignalKeyStore as any,
downloadMediaMessage: e.downloadMediaMessage as any,
getContentType: e.getContentType as any,
generateMessageIDV2: e.generateMessageIDV2 as any,
generateWAMessageFromContent: e.generateWAMessageFromContent as any,
isJidGroup: e.isJidGroup as any,
isJidStatusBroadcast: e.isJidStatusBroadcast as any,
}
}
// Exports estáticos: usam a engine global (retrocompatibilidade com código existente)
const selectedEngine = resolveEngine(defaultEngineType)
export const makeWASocket = selectedEngine.makeWASocket as any
export const useMultiFileAuthState = selectedEngine.useMultiFileAuthState as any
export const DisconnectReason = selectedEngine.DisconnectReason as any
export const fetchLatestBaileysVersion = selectedEngine.fetchLatestBaileysVersion as any
export const makeCacheableSignalKeyStore = selectedEngine.makeCacheableSignalKeyStore as any
export const downloadMediaMessage = selectedEngine.downloadMediaMessage as any
export const getContentType = selectedEngine.getContentType as any
export const generateMessageIDV2 = selectedEngine.generateMessageIDV2 as any
export const generateWAMessageFromContent = selectedEngine.generateWAMessageFromContent as any
export const isJidGroup = selectedEngine.isJidGroup as any
export const isJidStatusBroadcast = selectedEngine.isJidStatusBroadcast as any
export { proto } from './official'
// Exportação estática de tipos para garantir segurança em tempo de compilação
export type { WASocket, ConnectionState, WAMessage, Contact, BinaryNode } from './official'
@@ -1,18 +0,0 @@
"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 __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("baileys"), exports);
//# sourceMappingURL=infinite.js.map
@@ -1 +0,0 @@
{"version":3,"file":"infinite.js","sourceRoot":"","sources":["infinite.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,0CAAuB"}
@@ -1 +0,0 @@
export * from 'baileys'
@@ -1,18 +0,0 @@
"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 __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("@whiskeysockets/baileys"), exports);
//# sourceMappingURL=official.js.map
@@ -1 +0,0 @@
{"version":3,"file":"official.js","sourceRoot":"","sources":["official.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,0DAAuC"}
@@ -1 +0,0 @@
export * from 'baileys'
@@ -1,411 +0,0 @@
/**
* 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. 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. <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 biz > list também funciona.
*/
import { generateMessageIDV2, generateWAMessageFromContent, isJidGroup, isJidStatusBroadcast } from './engine'
import { proto } from './engine'
import type { WASocket, BinaryNode } from './engine'
// ─── Tipos de botão (espelho do formato nativo InfiniteAPI) ──────────────────
type NativeBtn =
| { type: 'reply'; id: string; text: string }
| { type: 'url'; text: string; url: string }
| { type: 'copy'; text: string; copyText: string }
| { type: 'call'; text: string; phoneNumber: string }
// ─── Payload rico de entrada (o que o frontend/ext-api envia) ────────────────
export interface RichPayload {
text?: string
footer?: string
nativeButtons?: NativeBtn[]
nativeList?: {
buttonText: string
sections: Array<{
title: string
rows: Array<{ id: string; title: string; description?: string }>
}>
}
nativeCarousel?: {
cards: Array<{
title?: string
body?: string
footer?: string
image?: { url: string }
buttons: Array<{ type?: string; id?: string; text: string }>
}>
}
poll?: {
name: string
values: string[]
selectableCount?: number
}
}
// ─── Converte um botão nativo para o formato buttonParamsJson do WhatsApp ─────
function formatNativeFlowButton(btn: NativeBtn): { name: string; buttonParamsJson: string } {
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: BinaryNode = {
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: BinaryNode = {
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: BinaryNode = {
tag: 'bot',
attrs: { biz_bot: '1' },
}
// ─── Helper: jid é chat privado (não grupo, não status, não bot) ─────────────
function isPrivateChat(jid: string): boolean {
return (
!isJidGroup(jid) &&
!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: WASocket,
jid: string,
protoMsg: any,
msgId: string,
additionalNodes: BinaryNode[],
quoted?: { key: any; message: any },
): Promise<string> {
const fullMsg = generateWAMessageFromContent(jid, protoMsg, {
userJid: sock.user?.id,
messageId: msgId,
quoted,
} as any)
await sock.relayMessage(jid, fullMsg.message as any, {
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: any): any {
return {
interactiveMessage: interactiveMsg,
}
}
/**
* Envia uma mensagem rica pelo socket Baileys padrão.
*
* - Buttons / CTAs: interactiveMessage direto + biz>interactive>native_flow + bot
* - Carousel: interactiveMessage direto + biz>interactive>native_flow
* - List: interactiveMessage direto + biz>interactive>native_flow + bot
* - Poll: sock.sendMessage (requer encriptação especial do Baileys)
* - Text: sock.sendMessage normal
*
* Retorna o messageId gerado.
*/
export async function sendRichMessage(
sock: WASocket,
jid: string,
payload: RichPayload,
quoted?: { key: any; message: any },
engineType?: string,
): Promise<string> {
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,
} as any,
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,
} as any,
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,
} as any,
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 = 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: BinaryNode[] = [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: any[] = cards.map((card) => {
const rawButtons = card.buttons ?? []
const cardButtons = rawButtons.length > 0
? rawButtons.map((btn) => formatNativeFlowButton({
type: (btn.type as any) ?? 'reply',
id: btn.id ?? btn.text.toLowerCase().replace(/\s+/g, '_'),
text: btn.text,
} as any))
: [{ 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: BinaryNode[] = [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: any = {
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: BinaryNode[] = [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()}`
}
+6 -669
View File
@@ -1,678 +1,15 @@
import { Router } from 'express';
import { adminController } from '../controllers/admin.controller';
import { authenticate } from '../middleware/auth';
import { authorize } from '../middleware/rbac';
import { db } from '../config/database';
import fs from 'fs/promises';
import path from 'path';
import net from 'net';
import { spawn } from 'child_process';
const router = Router();
const PROJECT_ROOT = path.resolve(process.cwd());
const FRONTEND_DIR = path.join(PROJECT_ROOT, 'frontend');
const LIVE_DIR = path.join(FRONTEND_DIR, '.next');
const STAGING_DIR = path.join(FRONTEND_DIR, '.next-staging-build');
const HISTORY_DIR = path.join(FRONTEND_DIR, '.next-history');
const HISTORY_FILE = path.join(PROJECT_ROOT, 'storage', 'version-manager-history.json');
router.use(authenticate);
router.use(authorize('super_admin'));
type VersionAction = 'deploy' | 'rollback';
type VersionStatus = 'success' | 'failed';
type HistoryEntry = {
id: string;
action: VersionAction;
status: VersionStatus;
from_version?: string | null;
to_version?: string | null;
user_id?: number | string;
user_name?: string;
created_at: string;
meta?: Record<string, any>;
};
async function readHistory(): Promise<HistoryEntry[]> {
try {
const raw = await fs.readFile(HISTORY_FILE, 'utf-8');
const data = JSON.parse(raw);
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
async function writeHistory(entries: HistoryEntry[]): Promise<void> {
await fs.mkdir(path.dirname(HISTORY_FILE), { recursive: true });
await fs.writeFile(HISTORY_FILE, JSON.stringify(entries, null, 2));
}
async function getBuildId(dir: string): Promise<string | null> {
try {
const raw = await fs.readFile(path.join(dir, 'BUILD_ID'), 'utf-8');
return raw.trim() || null;
} catch {
return null;
}
}
async function getDirMtime(dir: string): Promise<string | null> {
try {
const stat = await fs.stat(dir);
return stat.mtime.toISOString();
} catch {
return null;
}
}
function checkPort(port: number, host = '127.0.0.1', timeoutMs = 500): Promise<boolean> {
return new Promise(resolve => {
const socket = new net.Socket();
const onDone = (ok: boolean) => {
socket.destroy();
resolve(ok);
};
socket.setTimeout(timeoutMs);
socket.once('connect', () => onDone(true));
socket.once('timeout', () => onDone(false));
socket.once('error', () => onDone(false));
socket.connect(port, host);
});
}
async function snapshotLiveIfExists(): Promise<string | null> {
const liveId = await getBuildId(LIVE_DIR);
if (!liveId) return null;
const target = path.join(HISTORY_DIR, liveId);
try {
await fs.mkdir(HISTORY_DIR, { recursive: true });
await fs.access(target);
return liveId; // already snapped
} catch {
await fs.cp(LIVE_DIR, target, { recursive: true });
return liveId;
}
}
async function replaceLiveWith(sourceDir: string): Promise<void> {
const backupDir = `${LIVE_DIR}-backup-${Date.now()}`;
const tempDeployDir = `${LIVE_DIR}-deploying-${Date.now()}`;
// 1. Copiar para pasta temporária de deploy para garantir integridade
await fs.cp(sourceDir, tempDeployDir, { recursive: true });
// 2. Se houver live atual, move para backup (Atomic Swap part 1)
if (await fs.access(LIVE_DIR).then(() => true).catch(() => false)) {
await fs.rename(LIVE_DIR, backupDir);
}
try {
// 3. Move temp para live (Atomic Swap part 2)
await fs.rename(tempDeployDir, LIVE_DIR);
// Limpar backup antigo se tudo correu bem (opcional, aqui mantemos por segurança 1 versão)
const oldBackups = await fs.readdir(path.dirname(LIVE_DIR));
for (const f of oldBackups) {
if (f.startsWith('.next-backup-') && f !== path.basename(backupDir)) {
await fs.rm(path.join(path.dirname(LIVE_DIR), f), { recursive: true, force: true }).catch(() => { });
}
}
} catch (err) {
// Rollback imediato se o swap falhar
if (await fs.access(backupDir).then(() => true).catch(() => false)) {
await fs.rename(backupDir, LIVE_DIR);
}
throw err;
}
}
async function restartLive(): Promise<void> {
const { exec } = await import('child_process');
const cmd = 'npx pm2 restart ecosystem.config.js --only clube67-frontend --update-env';
const env = {
...process.env,
PATH: `/www/server/nodejs/v24.13.0/bin:${process.env.PATH || ''}`,
};
await new Promise<void>((resolve, reject) => {
exec(cmd, { cwd: PROJECT_ROOT, env }, (err, stdout, stderr) => {
if (err) return reject(new Error(stderr || err.message));
resolve();
});
});
}
async function buildStaging(): Promise<void> {
const { exec } = await import('child_process');
const cmd = 'BUILD_STAGING_ONLY=1 BUILD_STAGING=1 bash build.sh';
const env = {
...process.env,
PATH: `/www/server/nodejs/v24.13.0/bin:${process.env.PATH || ''}`,
};
await new Promise<void>((resolve, reject) => {
exec(cmd, { cwd: PROJECT_ROOT, env }, (err, stdout, stderr) => {
if (err) return reject(new Error(stderr || err.message));
resolve();
});
});
}
async function buildLive(): Promise<void> {
const { exec } = await import('child_process');
const cmd = 'env -u NEXT_BASE_PATH -u NEXT_DIST_DIR -u NEXT_PUBLIC_BACKEND_URL npm run build';
const env = {
...process.env,
PATH: `/www/server/nodejs/v24.13.0/bin:${process.env.PATH || ''}`,
};
await new Promise<void>((resolve, reject) => {
exec(cmd, { cwd: FRONTEND_DIR, env }, (err, stdout, stderr) => {
if (err) return reject(new Error(stderr || err.message));
resolve();
});
});
}
function createStreamWriter(res: any) {
return (line: string) => {
if (!res.writableEnded) {
res.write(line.endsWith('\n') ? line : `${line}\n`);
}
};
}
async function streamCommand(cmd: string, cwd: string, env: NodeJS.ProcessEnv, write: (line: string) => void): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(cmd, { cwd, env, shell: true });
child.stdout.on('data', chunk => write(chunk.toString()));
child.stderr.on('data', chunk => write(chunk.toString()));
child.on('error', err => reject(err));
child.on('close', code => {
if (code === 0) resolve();
else reject(new Error(`Command failed (${code}): ${cmd}`));
});
});
}
router.get('/dashboard', authenticate, authorize(['super_admin']), async (req, res) => {
try {
const [activePartners] = await db('partners').where({ status: 'active' }).count('id as count');
const [totalBenefits] = await db('benefits').count('id as count');
const [globalBenefits] = await db('benefits').where({ is_global: true }).count('id as count');
// Month Leads: count leads created in current month
const now = new Date();
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
const [monthLeads] = await db('leads')
.where('created_at', '>=', firstDay)
.count('id as count');
res.json({
activePartners: Number(activePartners.count),
totalBenefits: Number(totalBenefits.count),
monthLeads: Number(monthLeads.count),
globalBenefits: Number(globalBenefits.count)
});
} catch (err: any) {
console.error(err);
res.status(500).json({ error: 'Erro ao carregar dashboard' });
}
});
router.get('/partners', authenticate, authorize(['super_admin']), async (req, res) => {
try {
const partners = await db('partners')
.join('users', 'partners.owner_user_id', 'users.id')
.select(
'partners.id',
'partners.company_name',
'partners.slug',
'partners.cnpj',
'partners.email as partner_email',
'partners.status',
'partners.type',
'partners.created_at',
'users.name as owner_name',
'users.email as owner_email'
)
.orderBy('partners.created_at', 'desc');
res.json({ partners });
} catch (err: any) {
console.error(err);
res.status(500).json({ error: 'Erro ao listar parceiros' });
}
});
router.patch('/partners/:id/status', authenticate, authorize(['super_admin']), async (req, res) => {
try {
const { id } = req.params;
const { status } = req.body;
if (!['active', 'inactive', 'pending'].includes(status)) {
return res.status(400).json({ error: 'Status inválido' });
}
await db('partners').where({ id }).update({ status });
res.json({ success: true });
} catch (err: any) {
console.error(err);
res.status(500).json({ error: 'Erro ao atualizar status do parceiro' });
}
});
router.put('/partners/:id', authenticate, authorize(['super_admin']), async (req, res) => {
try {
const { id } = req.params;
const { company_name, slug, cnpj, partner_email, status, type } = req.body;
const updates: any = {
company_name,
slug,
cnpj,
email: partner_email,
status,
type
};
// Remove undefined values
Object.keys(updates).forEach(key => updates[key] === undefined && delete updates[key]);
await db('partners').where({ id }).update(updates);
res.json({ success: true });
} catch (err: any) {
console.error(err);
res.status(500).json({ error: 'Erro ao editar parceiro' });
}
});
// Version Manager
router.get('/version-manager/status', authenticate, authorize(['super_admin']), async (req, res) => {
try {
const [history, liveId, testId, liveMtime, testMtime] = await Promise.all([
readHistory(),
getBuildId(LIVE_DIR),
getBuildId(STAGING_DIR),
getDirMtime(LIVE_DIR),
getDirMtime(STAGING_DIR),
]);
const lastDeploy = history.find(h => h.action === 'deploy' && h.status === 'success');
const [liveUp, testUp, backendUp] = await Promise.all([
checkPort(3000),
checkPort(3001),
checkPort(3002),
]);
res.json({
live: {
version: liveId,
published_at: lastDeploy?.created_at || liveMtime,
published_by: lastDeploy?.user_name || null,
},
test: {
version: testId,
last_build_at: testMtime,
},
health: {
live: liveUp,
test: testUp,
backend: backendUp,
},
});
} catch (err: any) {
console.error(err);
res.status(500).json({ error: 'Erro ao carregar status' });
}
});
router.get('/version-manager/history', authenticate, authorize(['super_admin']), async (req, res) => {
try {
const limit = Number(req.query.limit || 50);
const history = await readHistory();
res.json(history.slice(0, Math.max(1, limit)));
} catch (err: any) {
console.error(err);
res.status(500).json({ error: 'Erro ao carregar historico' });
}
});
router.post('/version-manager/prepare-test', authenticate, authorize(['super_admin']), async (req, res) => {
try {
const confirm = Boolean(req.body?.confirm);
if (!confirm) return res.status(400).json({ error: 'Confirmacao obrigatoria' });
const stream = String(req.query.stream || '') === '1';
if (stream) {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Accel-Buffering', 'no');
if (typeof (res as any).flushHeaders === 'function') (res as any).flushHeaders();
const write = createStreamWriter(res);
write('== Preparar TEST ==');
write('> Rodando build staging...');
await streamCommand(
'BUILD_STAGING_ONLY=1 BUILD_STAGING=1 bash build.sh',
PROJECT_ROOT,
{ ...process.env, PATH: `/www/server/nodejs/v24.13.0/bin:${process.env.PATH || ''}` },
write
);
write('> Reiniciando staging...');
await streamCommand(
'npx pm2 restart ecosystem.config.js --only clube67-frontend-staging --update-env',
PROJECT_ROOT,
{ ...process.env, PATH: `/www/server/nodejs/v24.13.0/bin:${process.env.PATH || ''}` },
write
);
const testId = await getBuildId(STAGING_DIR);
const testMtime = await getDirMtime(STAGING_DIR);
if (!testId) throw new Error('Build TEST nao encontrado');
write(`> Build TEST: ${testId}`);
write(`> Atualizado em: ${testMtime || '—'}`);
write('== OK ==');
res.end();
return;
}
await buildStaging();
await streamCommand(
'npx pm2 restart ecosystem.config.js --only clube67-frontend-staging --update-env',
PROJECT_ROOT,
{ ...process.env, PATH: `/www/server/nodejs/v24.13.0/bin:${process.env.PATH || ''}` },
() => { }
);
const testId = await getBuildId(STAGING_DIR);
const testMtime = await getDirMtime(STAGING_DIR);
if (!testId) return res.status(500).json({ error: 'Build TEST nao encontrado' });
res.json({ success: true, test: { version: testId, last_build_at: testMtime } });
} catch (err: any) {
console.error(err);
if (!res.headersSent) {
res.status(500).json({ error: 'Erro ao preparar build TEST' });
} else {
try {
const write = createStreamWriter(res);
write(`ERROR: ${err?.message || 'Erro ao preparar build TEST'}`);
} finally {
res.end();
}
}
}
});
router.post('/version-manager/restart-test', authenticate, authorize(['super_admin']), async (req, res) => {
try {
const confirm = Boolean(req.body?.confirm);
if (!confirm) return res.status(400).json({ error: 'Confirmacao obrigatoria' });
const stream = String(req.query.stream || '') === '1';
if (stream) {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Accel-Buffering', 'no');
if (typeof (res as any).flushHeaders === 'function') (res as any).flushHeaders();
const write = createStreamWriter(res);
write('== Reiniciar TEST ==');
await streamCommand(
'npx pm2 restart ecosystem.config.js --only clube67-frontend-staging --update-env',
PROJECT_ROOT,
{ ...process.env, PATH: `/www/server/nodejs/v24.13.0/bin:${process.env.PATH || ''}` },
write
);
const testId = await getBuildId(STAGING_DIR);
write(`> Test build: ${testId || '—'}`);
write('== OK ==');
res.end();
return;
}
await streamCommand(
'npx pm2 restart ecosystem.config.js --only clube67-frontend-staging --update-env',
PROJECT_ROOT,
{ ...process.env, PATH: `/www/server/nodejs/v24.13.0/bin:${process.env.PATH || ''}` },
() => { }
);
const testId = await getBuildId(STAGING_DIR);
res.json({ success: true, test: { version: testId || '—' } });
} catch (err: any) {
console.error(err);
if (!res.headersSent) {
res.status(500).json({ error: 'Erro ao reiniciar TEST' });
} else {
try {
const write = createStreamWriter(res);
write(`ERROR: ${err?.message || 'Erro ao reiniciar TEST'}`);
} finally {
res.end();
}
}
}
});
router.post('/version-manager/fix-live', authenticate, authorize(['super_admin']), async (req, res) => {
try {
const confirm = Boolean(req.body?.confirm);
if (!confirm) return res.status(400).json({ error: 'Confirmacao obrigatoria' });
const stream = String(req.query.stream || '') === '1';
if (stream) {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Accel-Buffering', 'no');
if (typeof (res as any).flushHeaders === 'function') (res as any).flushHeaders();
const write = createStreamWriter(res);
write('== Corrigir LIVE ==');
write('> Rodando build live...');
await streamCommand(
'env -u NEXT_BASE_PATH -u NEXT_DIST_DIR -u NEXT_PUBLIC_BACKEND_URL npm run build',
FRONTEND_DIR,
{ ...process.env, PATH: `/www/server/nodejs/v24.13.0/bin:${process.env.PATH || ''}` },
write
);
write('> Reiniciando live...');
await streamCommand(
'npx pm2 restart ecosystem.config.js --only clube67-frontend --update-env',
PROJECT_ROOT,
{ ...process.env, PATH: `/www/server/nodejs/v24.13.0/bin:${process.env.PATH || ''}` },
write
);
const liveId = await getBuildId(LIVE_DIR);
const liveMtime = await getDirMtime(LIVE_DIR);
write(`> Live: ${liveId || '—'}`);
write(`> Atualizado em: ${liveMtime || '—'}`);
write('== OK ==');
res.end();
return;
}
await buildLive();
await restartLive();
const liveId = await getBuildId(LIVE_DIR);
const liveMtime = await getDirMtime(LIVE_DIR);
res.json({ success: true, live: { version: liveId, published_at: liveMtime } });
} catch (err: any) {
console.error(err);
if (!res.headersSent) {
res.status(500).json({ error: 'Erro ao corrigir LIVE' });
} else {
try {
const write = createStreamWriter(res);
write(`ERROR: ${err?.message || 'Erro ao corrigir LIVE'}`);
} finally {
res.end();
}
}
}
});
router.post('/version-manager/deploy', authenticate, authorize(['super_admin']), async (req, res) => {
try {
const confirm = Boolean(req.body?.confirm);
if (!confirm) return res.status(400).json({ error: 'Confirmacao obrigatoria' });
const stream = String(req.query.stream || '') === '1';
if (stream) {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Accel-Buffering', 'no');
if (typeof (res as any).flushHeaders === 'function') (res as any).flushHeaders();
const write = createStreamWriter(res);
write('== Publicar TEST -> LIVE ==');
const testId = await getBuildId(STAGING_DIR);
if (!testId) throw new Error('Build TEST nao encontrado');
write(`> Test build: ${testId}`);
write('> Fazendo snapshot da live...');
const fromId = await snapshotLiveIfExists();
write(`> Snapshot: ${fromId || '—'}`);
write('> Substituindo live...');
await replaceLiveWith(STAGING_DIR);
write('> Reiniciando live...');
await streamCommand(
'npx pm2 restart ecosystem.config.js --only clube67-frontend --update-env',
PROJECT_ROOT,
{ ...process.env, PATH: `/www/server/nodejs/v24.13.0/bin:${process.env.PATH || ''}` },
write
);
const userId = (req.user as any)?.id;
const userRow = userId ? await db('users').where({ id: userId }).first() : null;
const userName = userRow?.name || 'Super Admin';
const entry: HistoryEntry = {
id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
action: 'deploy',
status: 'success',
from_version: fromId,
to_version: testId,
user_id: userId,
user_name: userName,
created_at: new Date().toISOString(),
};
const history = await readHistory();
await writeHistory([entry, ...history]);
write(`> Live agora: ${testId}`);
write(`> Publicado por: ${userName}`);
write('> Aguardando inicialização (Health Check)...');
await new Promise(r => setTimeout(r, 5000));
const isAlive = await checkPort(3000);
if (isAlive) {
write('✅ Health Check: LIVE está respondendo na porta 3000!');
} else {
write('⚠️ Health Check: LIVE não respondeu na porta 3000 após 5s. Verifique os logs do PM2.');
}
write('== OK ==');
res.end();
return;
}
const testId = await getBuildId(STAGING_DIR);
if (!testId) return res.status(400).json({ error: 'Build TEST nao encontrado' });
const fromId = await snapshotLiveIfExists();
await replaceLiveWith(STAGING_DIR);
await restartLive();
// Health check silencioso para o log de histórico
await new Promise(r => setTimeout(r, 5000));
const finalHealth = await checkPort(3000);
const userId = (req.user as any)?.id;
const userRow = userId ? await db('users').where({ id: userId }).first() : null;
const userName = userRow?.name || 'Super Admin';
const entry: HistoryEntry = {
id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
action: 'deploy',
status: 'success',
from_version: fromId,
to_version: testId,
user_id: userId,
user_name: userName,
created_at: new Date().toISOString(),
meta: { health: finalHealth ? 'ok' : 'failed' }
};
const history = await readHistory();
await writeHistory([entry, ...history]);
res.json({ success: true, live: { version: testId, published_at: entry.created_at, published_by: userName } });
} catch (err: any) {
console.error(err);
const history = await readHistory();
const entry: HistoryEntry = {
id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
action: 'deploy',
status: 'failed',
created_at: new Date().toISOString(),
meta: { error: err?.message || 'Erro desconhecido' },
};
await writeHistory([entry, ...history]);
if (!res.headersSent) {
res.status(500).json({ error: 'Erro ao publicar TEST em LIVE' });
} else {
try {
const write = createStreamWriter(res);
write(`ERROR: ${err?.message || 'Erro ao publicar TEST em LIVE'}`);
} finally {
res.end();
}
}
}
});
router.post('/version-manager/rollback', authenticate, authorize(['super_admin']), async (req, res) => {
try {
const confirm = Boolean(req.body?.confirm);
const target = String(req.body?.target_version || '').trim();
if (!confirm) return res.status(400).json({ error: 'Confirmacao obrigatoria' });
if (!target) return res.status(400).json({ error: 'Versao alvo obrigatoria' });
const targetDir = path.join(HISTORY_DIR, target);
try {
await fs.access(targetDir);
} catch {
return res.status(404).json({ error: 'Versao alvo nao encontrada' });
}
const fromId = await snapshotLiveIfExists();
await replaceLiveWith(targetDir);
await restartLive();
const userId = (req.user as any)?.id;
const userRow = userId ? await db('users').where({ id: userId }).first() : null;
const userName = userRow?.name || 'Super Admin';
const entry: HistoryEntry = {
id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
action: 'rollback',
status: 'success',
from_version: fromId,
to_version: target,
user_id: userId,
user_name: userName,
created_at: new Date().toISOString(),
};
const history = await readHistory();
await writeHistory([entry, ...history]);
res.json({ success: true, live: { version: target, published_at: entry.created_at, published_by: userName } });
} catch (err: any) {
console.error(err);
const history = await readHistory();
const entry: HistoryEntry = {
id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
action: 'rollback',
status: 'failed',
created_at: new Date().toISOString(),
meta: { error: err?.message || 'Erro desconhecido' },
};
await writeHistory([entry, ...history]);
res.status(500).json({ error: 'Erro ao realizar rollback' });
}
});
router.get('/dashboard', adminController.getDashboard);
router.get('/logs', adminController.getAuditLogs);
router.get('/metrics', adminController.getMetrics);
export default router;
+6 -3
View File
@@ -1,11 +1,14 @@
import { Router } from 'express';
import * as authController from '../controllers/auth.controller';
import { authController } from '../controllers/auth.controller';
import { authenticate } from '../middleware/auth';
const router = Router();
router.post('/register', authController.register);
router.post('/login', authController.login);
router.get('/me', authenticate, authController.profile);
router.post('/complete', authenticate, authController.completeProfile);
router.post('/refresh', authController.refresh);
router.post('/google', authController.googleCallback);
router.get('/me', authenticate, authController.me);
router.post('/logout', authenticate, authController.logout);
export default router;
+13 -8
View File
@@ -1,14 +1,19 @@
import { Router } from 'express';
import * as benefitController from '../controllers/benefit.controller';
import { authenticate } from '../middleware/auth';
import { benefitController } from '../controllers/benefit.controller';
import { authenticate, optionalAuth } from '../middleware/auth';
import { authorize } from '../middleware/rbac';
import { upload } from '../middleware/upload';
const router = Router();
router.get('/', benefitController.getAll);
router.get('/', optionalAuth, benefitController.list);
router.get('/categories', benefitController.getCategories);
router.post('/', authenticate, benefitController.create);
router.put('/:id', authenticate, benefitController.update);
router.delete('/:id', authenticate, benefitController.deleteBenefit);
router.post('/:id/toggle', authenticate, benefitController.toggle);
router.post('/:id/use', authenticate, benefitController.useBenefit);
router.get('/:id', optionalAuth, benefitController.getById);
router.post('/', authenticate, authorize('partner_admin', 'super_admin'), benefitController.create);
router.put('/:id', authenticate, authorize('partner_admin', 'super_admin'), benefitController.update);
router.delete('/:id', authenticate, authorize('partner_admin', 'super_admin'), benefitController.delete);
router.post('/:id/approve', authenticate, authorize('super_admin'), benefitController.approve);
router.post('/:id/promote-global', authenticate, authorize('super_admin'), benefitController.promoteGlobal);
router.post('/:id/redeem', authenticate, benefitController.redeem);
export default router;
-11
View File
@@ -1,11 +0,0 @@
import { Router } from 'express';
import * as leadController from '../controllers/lead.controller';
import { authenticate } from '../middleware/auth';
import { minRole } from '../middleware/rbac';
const router = Router();
router.get('/', authenticate, minRole('partner_admin'), leadController.getAll);
router.put('/:id', authenticate, minRole('partner_admin'), leadController.updateStatus);
export default router;
+13 -7
View File
@@ -1,12 +1,18 @@
import { Router } from 'express';
import * as partnerController from '../controllers/partner.controller';
import { authenticate } from '../middleware/auth';
import { partnerController } from '../controllers/partner.controller';
import { authenticate, optionalAuth } from '../middleware/auth';
import { authorize } from '../middleware/rbac';
import { upload } from '../middleware/upload';
const router = Router();
router.get('/', partnerController.getAll);
router.get('/:id', partnerController.getById);
router.get('/:slug', partnerController.getBySlug);
router.put('/:id', authenticate, partnerController.updatePartner);
router.delete('/:id', authenticate, partnerController.deletePartner);
router.get('/', optionalAuth, partnerController.list);
router.get('/slug/:slug', optionalAuth, partnerController.getBySlug);
router.get('/:id', optionalAuth, partnerController.getById);
router.get('/:id/stats', authenticate, authorize('partner_admin', 'super_admin'), partnerController.getDashboardStats);
router.post('/', authenticate, authorize('super_admin'), partnerController.create);
router.put('/:id', authenticate, authorize('partner_admin', 'super_admin'), partnerController.update);
router.post('/:id/logo', authenticate, authorize('partner_admin', 'super_admin'), upload.single('logo'), partnerController.uploadLogo);
router.delete('/:id', authenticate, authorize('super_admin'), partnerController.delete);
export default router;
+85 -18
View File
@@ -1,28 +1,95 @@
import { Router } from 'express';
import { pluginConfig } from '../core/plugin-config';
import { authenticate } from '../middleware/auth';
import { Router, Response } from 'express';
import { authenticate, AuthRequest } from '../middleware/auth';
import { authorize } from '../middleware/rbac';
import { pluginService } from '../services/plugin.service';
import { upload } from '../middleware/upload';
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
const router = Router();
// Get Plugin Config
router.get('/:name/config', authenticate, authorize(['super_admin']), (req, res) => {
const { name } = req.params;
const config = pluginConfig.get(name);
// Mask secrets if needed? For now, super admin sees all.
res.json(config);
router.use(authenticate);
router.use(authorize('super_admin'));
// List all plugins
router.get('/', async (_req: AuthRequest, res: Response) => {
try {
const plugins = await pluginService.getAll();
const loaded = pluginService.getLoadedPlugins();
res.json({ plugins, loaded });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao listar plugins' });
}
});
// Set Plugin Config
router.post('/:name/config', authenticate, authorize(['super_admin']), (req, res) => {
const { name } = req.params;
const config = req.body;
// Install plugin from ZIP
router.post('/install', upload.single('plugin'), async (req: AuthRequest, res: Response) => {
try {
pluginConfig.set(name, config);
res.json({ success: true, message: 'Configuração salva com sucesso' });
} catch (error) {
res.status(500).json({ error: 'Erro ao salvar configuração' });
if (!req.file) return res.status(400).json({ error: 'Arquivo ZIP obrigatório' });
const zipPath = req.file.path;
const pluginsDir = path.resolve(__dirname, '../../../plugins');
const tempDir = path.join(pluginsDir, '_temp_' + Date.now());
fs.mkdirSync(tempDir, { recursive: true });
execSync(`unzip -o "${zipPath}" -d "${tempDir}"`);
// Find manifest
const items = fs.readdirSync(tempDir);
let pluginRoot = tempDir;
if (items.length === 1 && fs.statSync(path.join(tempDir, items[0])).isDirectory()) {
pluginRoot = path.join(tempDir, items[0]);
}
const manifestPath = path.join(pluginRoot, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
fs.rmSync(tempDir, { recursive: true });
return res.status(400).json({ error: 'manifest.json not found in plugin' });
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
const finalDir = path.join(pluginsDir, manifest.slug);
if (fs.existsSync(finalDir)) fs.rmSync(finalDir, { recursive: true });
fs.renameSync(pluginRoot, finalDir);
if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true });
fs.unlinkSync(zipPath);
const result = await pluginService.install(manifest.slug);
res.status(201).json({ plugin: result });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao instalar plugin', details: err.message });
}
});
// Activate
router.post('/:slug/activate', async (req: AuthRequest, res: Response) => {
try {
const app = req.app as any;
await pluginService.activate(app, req.params.slug);
res.json({ message: 'Plugin ativado' });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao ativar plugin', details: err.message });
}
});
// Deactivate
router.post('/:slug/deactivate', async (req: AuthRequest, res: Response) => {
try {
await pluginService.deactivate(req.params.slug);
res.json({ message: 'Plugin desativado' });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao desativar plugin' });
}
});
// Remove
router.delete('/:slug', async (req: AuthRequest, res: Response) => {
try {
await pluginService.remove(req.params.slug);
res.json({ message: 'Plugin removido' });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao remover plugin' });
}
});
-16
View File
@@ -1,16 +0,0 @@
import { Router } from 'express';
import * as referralController from '../controllers/referral.controller';
import { authenticate } from '../middleware/auth';
const router = Router();
// Publico: registra clique no link de indicacao
router.post('/track', referralController.trackClick);
// Autenticado: estatisticas do usuario
router.get('/my-stats', authenticate, referralController.getMyStats);
// Interno: converter indicacao
router.post('/convert', authenticate, referralController.convert);
export default router;
-22
View File
@@ -1,22 +0,0 @@
import { Router } from 'express';
import { upload } from '../middleware/upload';
const router = Router();
router.post('/', upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'Nenhum arquivo enviado' });
}
const url = `/uploads/${req.file.filename}`;
res.json({
success: true,
url,
filename: req.file.filename,
mimetype: req.file.mimetype,
size: req.file.size
});
});
export default router;
+11 -16
View File
@@ -1,24 +1,19 @@
import { Router } from 'express';
import * as userController from '../controllers/user.controller';
import { userController } from '../controllers/user.controller';
import { authenticate } from '../middleware/auth';
import { authorize } from '../middleware/rbac';
const router = Router();
// List Users (Admin/Partner Admin) - Assuming partner admin can list users assigned to them or global?
// Current impl allows global view.
router.get('/', authenticate, authorize(['super_admin', 'partner_admin']), userController.getAll);
// User Stats & Benefits (Self/Authenticated)
router.get('/me/stats', authenticate, userController.getStats);
router.get('/me/benefits', authenticate, userController.getMyBenefits);
// Admin Actions (Super Admin Only)
// Delete User
router.delete('/:id', authenticate, authorize(['super_admin']), userController.deleteUser);
// Update Status (Ban/Block)
router.patch('/:id/status', authenticate, authorize(['super_admin']), userController.updateStatus);
// Edit User (Name/Email/Role)
router.put('/:id', authenticate, authorize(['super_admin']), userController.updateUser);
router.get('/', authenticate, authorize('super_admin', 'partner_admin'), userController.list);
router.get('/me/favorites', authenticate, userController.getFavorites);
router.post('/me/favorites', authenticate, userController.addFavorite);
router.delete('/me/favorites/:benefitId', authenticate, userController.removeFavorite);
router.get('/me/history', authenticate, userController.getHistory);
router.get('/me/stats', authenticate, userController.getDashboardStats);
router.get('/:id', authenticate, userController.getById);
router.put('/:id', authenticate, userController.update);
router.patch('/:id/role', authenticate, authorize('super_admin'), userController.updateRole);
router.delete('/:id', authenticate, authorize('super_admin'), userController.delete);
export default router;
+16 -1
View File
@@ -1,2 +1,17 @@
import { Router } from 'express';
export default Router();
import { webhookController } from '../controllers/webhook.controller';
import { authenticate } from '../middleware/auth';
import { authorize } from '../middleware/rbac';
const router = Router();
router.use(authenticate);
router.use(authorize('super_admin', 'partner_admin'));
router.get('/', webhookController.list);
router.post('/', webhookController.create);
router.put('/:id', webhookController.update);
router.delete('/:id', webhookController.delete);
router.post('/:id/test', webhookController.test);
export default router;
+183
View File
@@ -0,0 +1,183 @@
import { Knex } from 'knex';
import bcrypt from 'bcryptjs';
export async function seed(knex: Knex): Promise<void> {
// ── Super Admin ──
const passwordHash = await bcrypt.hash('Rc362514', 12);
const [existingAdmin] = await knex('users').where('email', 'ruibto@gmail.com');
if (!existingAdmin) {
await knex('users').insert({
name: 'Super Admin',
email: 'ruibto@gmail.com',
password_hash: passwordHash,
role: 'super_admin',
status: 'active',
email_verified: true,
});
}
// ── Categories ──
const categories = [
{ name: 'Gastronomia', slug: 'gastronomia', icon: '🍽️', color: '#FF6B35' },
{ name: 'Saúde', slug: 'saude', icon: '🏥', color: '#4CAF50' },
{ name: 'Educação', slug: 'educacao', icon: '📚', color: '#2196F3' },
{ name: 'Lazer', slug: 'lazer', icon: '🎭', color: '#9C27B0' },
{ name: 'Esportes', slug: 'esportes', icon: '⚽', color: '#FF9800' },
{ name: 'Compras', slug: 'compras', icon: '🛍️', color: '#E91E63' },
{ name: 'Serviços', slug: 'servicos', icon: '🔧', color: '#607D8B' },
{ name: 'Viagens', slug: 'viagens', icon: '✈️', color: '#00BCD4' },
{ name: 'Beleza & Estética', slug: 'beleza-estetica', icon: '💅', color: '#F06292' },
{ name: 'Odontologia', slug: 'odontologia', icon: '🦷', color: '#26A69A' },
{ name: 'Nutrição', slug: 'nutricao', icon: '🥗', color: '#8BC34A' },
{ name: 'Farmácia', slug: 'farmacia', icon: '💊', color: '#EF5350' },
];
for (const cat of categories) {
const exists = await knex('categories').where('slug', cat.slug).first();
if (!exists) await knex('categories').insert(cat);
}
// ── Partners ──
const superAdmin = await knex('users').where('email', 'ruibto@gmail.com').first();
const partners = [
{
company_name: 'Escola Coração de Maria',
slug: 'coracaodemaria',
cnpj: '11.222.333/0001-44',
phone: '(67) 3333-1111',
address_street: 'Rua das Flores', address_number: '123',
address_neighborhood: 'Centro', address_city: 'Campo Grande',
address_state: 'MS', address_zip: '79000-001',
type: 'Escola', monthly_goal: 50, status: 'active',
gradient_from: '#3B82F6', gradient_to: '#1D4ED8',
icon: '🏫', description: 'Instituição de ensino fundamental e médio.',
owner_user_id: superAdmin?.id,
},
{
company_name: 'Drogasil Filial Centro',
slug: 'drogasilcentro',
cnpj: '44.555.666/0001-77',
phone: '(67) 3333-2222',
address_street: 'Avenida Principal', address_number: '456',
address_neighborhood: 'Tiradentes', address_city: 'Campo Grande',
address_state: 'MS', address_zip: '79000-002',
type: 'Farmácia', monthly_goal: 75, status: 'active',
gradient_from: '#EF4444', gradient_to: '#B91C1C',
icon: '⚕️', description: 'Rede de farmácias com ampla variedade.',
owner_user_id: superAdmin?.id,
},
{
company_name: 'SmartFit Unidade Centro',
slug: 'smartfitcentro',
cnpj: null,
phone: '(67) 3333-3333',
address_street: 'Travessa Esportiva', address_number: '789',
address_neighborhood: 'Aero Rancho', address_city: 'Campo Grande',
address_state: 'MS', address_zip: '79000-003',
type: 'Academia', monthly_goal: 100, status: 'active',
gradient_from: '#EAB308', gradient_to: '#F97316',
icon: '🏋️', description: 'Academia com equipamentos modernos.',
owner_user_id: superAdmin?.id,
},
{
company_name: 'Consultt Clinic',
slug: 'consulttclinic',
cnpj: null,
phone: '(67) 9999-0000',
address_street: 'Rua Odonto', address_number: '100',
address_neighborhood: 'Centro', address_city: 'Campo Grande',
address_state: 'MS', address_zip: '79000-100',
type: 'Odontologia', monthly_goal: 30, status: 'active',
gradient_from: '#06B6D4', gradient_to: '#3B82F6',
icon: '🦷', description: 'Clínica odontológica de referência.',
owner_user_id: superAdmin?.id,
},
{
company_name: 'Escola de Música Som do Coração',
slug: 'somdocoracao',
cnpj: null,
phone: '(67) 9999-1112',
address_street: 'Rua da Harmonia', address_number: '200',
address_neighborhood: 'Centro', address_city: 'Campo Grande',
address_state: 'MS', address_zip: '79000-200',
type: 'Curso', monthly_goal: 20, status: 'active',
gradient_from: '#A855F7', gradient_to: '#6366F1',
icon: '🎵', description: 'Aulas de música para todas as idades.',
owner_user_id: superAdmin?.id,
},
{
company_name: 'Ótica Central',
slug: 'oticacentral',
cnpj: null,
phone: '(67) 9999-4444',
address_street: 'Avenida da Visão', address_number: '300',
address_neighborhood: 'Tiradentes', address_city: 'Campo Grande',
address_state: 'MS', address_zip: '79000-300',
type: 'Ótica', monthly_goal: 40, status: 'active',
gradient_from: '#14B8A6', gradient_to: '#22C55E',
icon: '👓', description: 'Soluções para saúde visual.',
owner_user_id: superAdmin?.id,
},
];
for (const p of partners) {
const exists = await knex('partners').where('slug', p.slug).first();
if (!exists) await knex('partners').insert(p);
}
// ── Benefits ──
const consultt = await knex('partners').where('slug', 'consulttclinic').first();
const som = await knex('partners').where('slug', 'somdocoracao').first();
const otica = await knex('partners').where('slug', 'oticacentral').first();
const catSaude = await knex('categories').where('slug', 'saude').first();
const catEducacao = await knex('categories').where('slug', 'educacao').first();
const catOdonto = await knex('categories').where('slug', 'odontologia').first();
const benefits = [
{
partner_id: consultt?.id,
category_id: catOdonto?.id,
title: 'Benefício Saúde Bucal',
description: 'Cuidado odontológico completo com condições especiais para membros.',
rules: 'Benefício válido para membros ativos. Descontos não acumulativos.',
contact: '(67) 9999-0000',
type: 'dental', delivery_type: 'external',
active: true, is_global: true, approved_by_admin: true,
global_request_status: 'approved', priority: 1,
discount_percent: 30, valid_until: '2026-12-31',
redemption_type: 'qrcode',
},
{
partner_id: som?.id,
category_id: catEducacao?.id,
title: 'Aula de Violão Gratuita',
description: 'Experimente uma aula de violão com nossos melhores professores.',
rules: 'Válido para novos alunos.',
contact: '(67) 9999-1112',
type: 'education', delivery_type: 'local',
active: true, is_global: false, approved_by_admin: true,
global_request_status: 'none', priority: 3,
discount_percent: 100, valid_until: '2026-06-30',
redemption_type: 'coupon',
},
{
partner_id: otica?.id,
category_id: catSaude?.id,
title: '25% OFF em Armações',
description: 'Escolha qualquer armação da loja com desconto exclusivo.',
rules: 'Válido para pagamentos à vista.',
contact: '(67) 9999-4444',
type: 'health', delivery_type: 'external',
active: true, is_global: false, approved_by_admin: true,
global_request_status: 'none', priority: 3,
discount_percent: 25, valid_until: '2026-12-31',
redemption_type: 'coupon',
},
];
for (const b of benefits) {
if (b.partner_id) {
const exists = await knex('benefits').where('title', b.title).first();
if (!exists) await knex('benefits').insert(b);
}
}
console.log('✅ Seed completed: super_admin + categories + partners + benefits');
}
@@ -1,78 +0,0 @@
import { db } from '../config/database';
import { v4 as uuidv4 } from 'uuid';
import { auditLog } from '../utils/audit';
export class UserLifecycleService {
/**
* Soft Deletes a user (Anonymization)
* This process is irreversible and designed to comply with LGPD/GDPR
*/
static async anonymizeUser(userId: number, adminId: number): Promise<boolean> {
return await db.transaction(async (trx) => {
// 1. Get User
const user = await trx('users').where({ id: userId }).first();
if (!user) throw new Error('Usuário não encontrado');
if (user.role === 'partner' || user.role === 'partner_admin') {
const partner = await trx('partners').where({ owner_user_id: user.id }).first();
if (partner && partner.status === 'active') {
throw new Error('Não é possível excluir um parceiro ativo. Desative o parceiro primeiro.');
}
}
// 2. Anonymize User Data
const anonEmail = `deleted_${uuidv4()}@clube67.com`;
const randomHash = await import('bcryptjs').then(b => b.hash(uuidv4(), 10));
// Backup Leads info before scrubbing user (optional, if we want to keep name in leads table static)
// But actually we want to anonymize leads too to remove PII
// 3. Update Leads (Keep stats, remove PII)
await trx('leads')
.where({ user_id: userId })
.update({
name: `Usuário Excluído ${userId}`,
email: anonEmail,
phone: null,
updated_at: new Date()
});
// 4. Delete Favorites
await trx('favorites').where({ user_id: userId }).delete();
// 5. Update User Record
await trx('users')
.where({ id: userId })
.update({
name: `Usuário Excluído ${userId}`,
email: anonEmail,
password_hash: randomHash,
whatsapp: null,
// cpf: null, // if exists
status: 'banned', // Prevent login logic
deleted_at: new Date(),
is_anonymized: true,
updated_at: new Date()
});
// 6. Log
// Audit log needs to happen AFTER transaction or be part of it,
// but our auditLog util might use a different connection or be simple.
// We'll log it separately.
return true;
});
}
/**
* Ban/Block User
*/
static async banUser(userId: number, reason: string, adminId: number): Promise<void> {
await db('users').where({ id: userId }).update({
status: 'banned',
updated_at: new Date()
});
await auditLog(adminId, 'BAN_USER', 'user', userId, `Usuário banido: ${reason}`, '0.0.0.0');
}
}
+169 -9
View File
@@ -1,13 +1,173 @@
import { Express } from 'express';
import { logger } from '../utils/logger';
import fs from 'fs';
import path from 'path';
import { Express, Router } from 'express';
import db from '../config/database';
import logger from '../utils/logger';
export interface PluginManifest {
name: string;
slug: string;
version: string;
description: string;
author: string;
entryPoint: string;
dependencies?: string[];
hooks?: string[];
}
export interface PluginInstance {
manifest: PluginManifest;
router?: Router;
hooks: Record<string, Function[]>;
activate: () => Promise<void>;
deactivate: () => Promise<void>;
}
const loadedPlugins: Map<string, PluginInstance> = new Map();
const hookRegistry: Record<string, Function[]> = {};
const PLUGINS_DIR = path.resolve(__dirname, '../../../plugins');
export const pluginService = {
async loadPlugins(app: Express) {
logger.info('Loading plugins...');
// Implementation details...
async loadAll(app: Express) {
if (!fs.existsSync(PLUGINS_DIR)) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
return;
}
const activePlugins = await db('plugins').where('active', true);
for (const pluginRecord of activePlugins) {
try {
await this.load(app, pluginRecord.slug);
} catch (err) {
logger.error(`Failed to load plugin: ${pluginRecord.slug}`, err);
}
}
logger.info(`Loaded ${loadedPlugins.size} plugins`);
},
async load(app: Express, slug: string) {
const pluginDir = path.join(PLUGINS_DIR, slug);
const manifestPath = path.join(pluginDir, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
throw new Error(`Plugin manifest not found: ${manifestPath}`);
}
const manifest: PluginManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
const entryPath = path.join(pluginDir, manifest.entryPoint || 'index.js');
if (!fs.existsSync(entryPath)) {
throw new Error(`Plugin entry point not found: ${entryPath}`);
}
const pluginModule = require(entryPath);
const instance: PluginInstance = {
manifest,
router: pluginModule.router,
hooks: pluginModule.hooks || {},
activate: pluginModule.activate || (async () => { }),
deactivate: pluginModule.deactivate || (async () => { }),
};
// Register routes
if (instance.router) {
app.use(`/api/plugins/${slug}`, instance.router);
}
// Register hooks
for (const [hookName, handlers] of Object.entries(instance.hooks)) {
if (!hookRegistry[hookName]) hookRegistry[hookName] = [];
if (Array.isArray(handlers)) {
hookRegistry[hookName].push(...handlers);
}
}
await instance.activate();
loadedPlugins.set(slug, instance);
logger.info(`Plugin loaded: ${manifest.name} v${manifest.version}`);
},
async unload(slug: string) {
const instance = loadedPlugins.get(slug);
if (instance) {
await instance.deactivate();
loadedPlugins.delete(slug);
logger.info(`Plugin unloaded: ${slug}`);
}
},
async install(slug: string) {
const pluginDir = path.join(PLUGINS_DIR, slug);
const manifestPath = path.join(pluginDir, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
throw new Error('Manifest not found');
}
const manifest: PluginManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
const existing = await db('plugins').where('slug', slug).first();
if (existing) {
await db('plugins').where('slug', slug).update({
version: manifest.version,
description: manifest.description,
author: manifest.author,
entry_point: manifest.entryPoint,
dependencies: JSON.stringify(manifest.dependencies || []),
hooks: JSON.stringify(manifest.hooks || []),
});
} else {
await db('plugins').insert({
name: manifest.name,
slug: manifest.slug,
version: manifest.version,
description: manifest.description,
author: manifest.author,
entry_point: manifest.entryPoint,
active: false,
dependencies: JSON.stringify(manifest.dependencies || []),
hooks: JSON.stringify(manifest.hooks || []),
directory: pluginDir,
});
}
return manifest;
},
async activate(app: Express, slug: string) {
await db('plugins').where('slug', slug).update({ active: true });
await this.load(app, slug);
},
async deactivate(slug: string) {
await db('plugins').where('slug', slug).update({ active: false });
await this.unload(slug);
},
async getAll() {
return db('plugins').orderBy('name');
},
async remove(slug: string) {
await this.unload(slug);
await db('plugins').where('slug', slug).delete();
// Note: does not delete plugin files for safety
},
async triggerHook(hookName: string, data: any) {
const handlers = hookRegistry[hookName] || [];
for (const handler of handlers) {
try {
await handler(data);
} catch (err) {
logger.error(`Hook error [${hookName}]:`, err);
}
}
},
getLoadedPlugins() {
return Array.from(loadedPlugins.entries()).map(([slug, instance]) => ({
slug,
name: instance.manifest.name,
version: instance.manifest.version,
}));
},
async installPlugin(zipPath: string) { },
async activatePlugin(slug: string) { },
async deactivatePlugin(slug: string) { },
async removePlugin(slug: string) { },
};
-16
View File
@@ -1,16 +0,0 @@
// Express Request augmentation for user property
// Added by auth middleware after JWT verification
declare namespace Express {
interface Request {
user?: {
id: string;
email: string;
role: string;
partner_id?: string;
name?: string;
};
partnerConsent?: boolean;
partnerId?: number;
}
}
+21 -9
View File
@@ -1,16 +1,28 @@
import { db } from '../config/database';
import db from '../config/database';
import { Request } from 'express';
export async function auditLog(userId: number, action: string, entityType: string, entityId?: number, description?: string, ip?: string) {
interface AuditEntry {
user_id?: number;
action: string;
entity_type?: string;
entity_id?: number;
description?: string;
old_data?: any;
new_data?: any;
ip_address?: string;
user_agent?: string;
}
export async function auditLog(req: Request | null, entry: Omit<AuditEntry, 'ip_address' | 'user_agent'>) {
try {
await db('logs').insert({
user_id: userId,
action,
entity_type: entityType,
entity_id: entityId,
description,
ip_address: ip
...entry,
old_data: entry.old_data ? JSON.stringify(entry.old_data) : null,
new_data: entry.new_data ? JSON.stringify(entry.new_data) : null,
ip_address: req?.ip || null,
user_agent: req?.get('user-agent')?.substring(0, 500) || null,
});
} catch (err) {
console.error('Failed to save audit log:', err);
console.error('Audit log error:', err);
}
}
+28 -21
View File
@@ -1,32 +1,39 @@
import winston from 'winston';
import 'winston-daily-rotate-file';
import path from 'path';
import { config } from '../config';
const logFormat = winston.format.combine(
winston.format.timestamp(),
winston.format.json()
);
const logDir = config.log.dir;
export const logger = winston.createLogger({
format: logFormat,
const logger = winston.createLogger({
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: 'clube67-api' },
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
}),
new (winston.transports as any).DailyRotateFile({
dirname: config.logging.dir,
filename: 'error-%DATE%.log',
new winston.transports.File({
filename: path.join(logDir, 'error.log'),
level: 'error',
maxFiles: '14d',
maxsize: 5242880,
maxFiles: 5,
}),
new (winston.transports as any).DailyRotateFile({
dirname: config.logging.dir,
filename: 'combined-%DATE%.log',
maxFiles: '14d',
new winston.transports.File({
filename: path.join(logDir, 'combined.log'),
maxsize: 5242880,
maxFiles: 10,
}),
],
});
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
}));
}
export default logger;
-1
View File
@@ -1 +0,0 @@
{}
-28
View File
@@ -1,28 +0,0 @@
const { Client } = require('pg');
const dotenv = require('dotenv');
const path = require('path');
dotenv.config({ path: path.join(__dirname, '.env') });
const client = new Client({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
});
async function main() {
try {
console.log('Connecting to:', process.env.DB_HOST, 'as user:', process.env.DB_USER, 'to database:', process.env.DB_NAME);
await client.connect();
console.log('Connected!');
const res = await client.query('SELECT NOW()');
console.log('Result:', res.rows[0]);
await client.end();
} catch (err) {
console.error('Connection failed:', err);
}
}
main();
+15 -5
View File
@@ -2,20 +2,30 @@
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": [
"ES2020"
],
"outDir": "./dist",
"rootDir": "..",
"rootDir": "./src",
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"noImplicitAny": false,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationDir": "./dist/types",
"sourceMap": true
"declarationMap": true,
"sourceMap": true,
"moduleResolution": "node",
"baseUrl": "./src",
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"src/**/*",
"../plugins/**/*"
"src/**/*"
],
"exclude": [
"node_modules",

Some files were not shown because too many files have changed in this diff Show More