feat: restaura o projeto original do clube67.com (Next.js + Express API + MySQL + Redis) livre de gambiarras e dockerizado
This commit is contained in:
+22
-15
@@ -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,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
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
Generated
+3225
-5591
File diff suppressed because it is too large
Load Diff
+54
-51
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
PORT=8787
|
||||
API_KEY=ACFH4RFOTME4RU50R4FKGNW34LDFG8DSQ
|
||||
AUTH_FOLDER=auth
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
auth/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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.
|
||||
-2922
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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) Ex: 553598828503 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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');
|
||||
@@ -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();
|
||||
@@ -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
@@ -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'),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { logger } from '../utils/logger';
|
||||
export { logger };
|
||||
export default logger;
|
||||
@@ -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;
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 });
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
Vendored
-34
@@ -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();
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/javascript:/gi, '')
|
||||
.replace(/on\w+=/gi, '');
|
||||
} else if (typeof obj[key] === 'object' && obj[key] !== null) {
|
||||
sanitizeObject(obj[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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. Nó adicional <biz><interactive type="native_flow" v="1"><native_flow v="9" name="mixed"/></interactive></biz>
|
||||
* injetado no stanza via relayMessage(additionalNodes)
|
||||
* (sem isso o WhatsApp Web exibe "Não foi possível carregar")
|
||||
*
|
||||
* 3. Nó <bot biz_bot="1"/> para chats 1-a-1
|
||||
* (necessário para interactive messages em conversas privadas)
|
||||
*
|
||||
* Lista (single_select) também usa o mesmo fluxo — InfiniteAPI converte
|
||||
* o viewOnceMessage > nativeFlowMessage de volta para listMessage no relayMessage,
|
||||
* mas testamos que a forma direta com nó biz > list também funciona.
|
||||
*/
|
||||
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 + nó biz>interactive>native_flow + nó bot
|
||||
* - Carousel: interactiveMessage direto + nó biz>interactive>native_flow
|
||||
* - List: interactiveMessage direto + nó biz>interactive>native_flow + nó bot
|
||||
* - Poll: sock.sendMessage (requer encriptação especial do Baileys)
|
||||
* - Text: sock.sendMessage normal
|
||||
*
|
||||
* Retorna o messageId gerado.
|
||||
*/
|
||||
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()}`
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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) { },
|
||||
};
|
||||
|
||||
Vendored
-16
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 +0,0 @@
|
||||
{}
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user