feat: inicializar repositorio oficial do clube67.com
This commit is contained in:
+28
@@ -0,0 +1,28 @@
|
||||
# Node modules
|
||||
node_modules/
|
||||
|
||||
# Builds e Compilados
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
.next/
|
||||
|
||||
# Variáveis de ambiente sensíveis
|
||||
.env
|
||||
.env.production
|
||||
.env.local
|
||||
.env.development
|
||||
|
||||
# Logs e Dados de Estado locais
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Uploads locais de mídia
|
||||
uploads/
|
||||
|
||||
# Sistema
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Legado / Projeto NewWhats (Migrado e Isolado)
|
||||
newwhats.local/
|
||||
@@ -0,0 +1,214 @@
|
||||
"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
|
||||
@@ -0,0 +1,30 @@
|
||||
|
||||
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();
|
||||
@@ -0,0 +1,23 @@
|
||||
|
||||
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();
|
||||
@@ -0,0 +1,20 @@
|
||||
|
||||
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();
|
||||
@@ -0,0 +1,22 @@
|
||||
|
||||
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();
|
||||
@@ -0,0 +1,24 @@
|
||||
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
+5595
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
PORT=8787
|
||||
API_KEY=ACFH4RFOTME4RU50R4FKGNW34LDFG8DSQ
|
||||
AUTH_FOLDER=auth
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
auth/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,115 @@
|
||||
# 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
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
(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 = '••••••••';
|
||||
})();
|
||||
@@ -0,0 +1,169 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,499 @@
|
||||
: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;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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;
|
||||
@@ -0,0 +1,267 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
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;
|
||||
@@ -0,0 +1,291 @@
|
||||
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;
|
||||
@@ -0,0 +1,198 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 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());
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"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,18 @@
|
||||
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();
|
||||
@@ -0,0 +1,26 @@
|
||||
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,67 @@
|
||||
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();
|
||||
@@ -0,0 +1,23 @@
|
||||
import knex from 'knex';
|
||||
import { config } from './index';
|
||||
import path from 'path';
|
||||
|
||||
const knexConfig = {
|
||||
client: 'pg',
|
||||
connection: {
|
||||
host: config.db.host,
|
||||
port: config.db.port,
|
||||
user: config.db.user,
|
||||
password: config.db.password,
|
||||
database: config.db.database,
|
||||
},
|
||||
pool: { min: 2, max: 10 },
|
||||
migrations: {
|
||||
directory: path.join(__dirname, '../database/migrations'),
|
||||
tableName: 'knex_migrations'
|
||||
}
|
||||
};
|
||||
|
||||
export const db = knex(knexConfig);
|
||||
|
||||
export default knexConfig;
|
||||
@@ -0,0 +1,42 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
// 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') });
|
||||
|
||||
export const config = {
|
||||
env: process.env.NODE_ENV || 'development',
|
||||
port: parseInt(process.env.PORT || process.env.APP_PORT || '3001', 10),
|
||||
db: {
|
||||
host: process.env.DB_HOST,
|
||||
port: parseInt(process.env.DB_PORT || '3306', 10),
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
},
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'secret',
|
||||
refreshSecret: process.env.JWT_REFRESH_SECRET || 'refresh_secret',
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || '15m',
|
||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
|
||||
},
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST || '127.0.0.1',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
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,
|
||||
},
|
||||
upload: {
|
||||
dir: process.env.UPLOAD_DIR || path.join(__dirname, '../../../uploads'),
|
||||
maxSize: parseInt(process.env.MAX_FILE_SIZE || '5242880', 10),
|
||||
},
|
||||
logging: {
|
||||
dir: process.env.LOG_DIR || path.join(__dirname, '../../../logs'),
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
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,
|
||||
},
|
||||
migrations: {
|
||||
directory: '../database/migrations',
|
||||
extension: 'ts',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { logger } from '../utils/logger';
|
||||
export { logger };
|
||||
export default logger;
|
||||
@@ -0,0 +1,13 @@
|
||||
import Redis from 'ioredis';
|
||||
import { config } from './index';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export const redis = new Redis({
|
||||
host: config.redis.host,
|
||||
port: config.redis.port,
|
||||
password: config.redis.password,
|
||||
retryStrategy: (times) => Math.min(times * 50, 2000),
|
||||
});
|
||||
|
||||
redis.on('connect', () => logger.info('✅ Redis connected'));
|
||||
redis.on('error', (err) => logger.error('❌ Redis error:', err));
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { db } from '../config/database';
|
||||
import { 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' });
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
// Link user to partner
|
||||
await trx('users').where({ id: userId }).update({ partner_id: partnerId });
|
||||
}
|
||||
|
||||
await trx.commit();
|
||||
|
||||
// Auto-login response
|
||||
const user = await db('users').where({ id: userId }).first();
|
||||
const tokens = generateTokens(user);
|
||||
const redirectPath = getRedirectPath(user);
|
||||
|
||||
// Log action (outside transaction)
|
||||
await auditLog(user.id, 'REGISTER', 'user', user.id, `Novo cadastro: ${type}`, req.ip);
|
||||
|
||||
res.status(201).json({ user, redirectPath, ...tokens });
|
||||
|
||||
} catch (err: any) {
|
||||
await trx.rollback();
|
||||
console.error('Register Error:', err);
|
||||
res.status(500).json({ error: 'Erro ao criar conta: ' + (err.message || 'Erro interno') });
|
||||
}
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
if (!(await bcrypt.compare(password, user.password_hash))) {
|
||||
return res.status(401).json({ error: 'Credenciais inválidas' });
|
||||
}
|
||||
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' });
|
||||
}
|
||||
|
||||
await db('users').where({ id: userId }).update({
|
||||
whatsapp,
|
||||
state,
|
||||
city,
|
||||
neighborhood,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
const user = await db('users').where({ id: userId }).first();
|
||||
res.json(user);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: 'Erro ao completar cadastro' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { db } from '../config/database';
|
||||
|
||||
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 async function getCategories(req: Request, res: Response) {
|
||||
const categories = await db('categories');
|
||||
res.json({ categories });
|
||||
}
|
||||
|
||||
export async function useBenefit(req: Request, res: Response) {
|
||||
const { id } = req.params;
|
||||
const userId = req.user!.id; // Assumes auth middleware populates req.user
|
||||
|
||||
try {
|
||||
const benefit = await db('benefits').where({ id }).first();
|
||||
if (!benefit) {
|
||||
return res.status(404).json({ error: 'Benefício não encontrado' });
|
||||
}
|
||||
|
||||
// Check for existing usage today (optional rule, but good practice)
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
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()
|
||||
});
|
||||
}
|
||||
|
||||
// Increment usage count on benefit
|
||||
await db('benefits').where({ id }).increment('usage_count', 1);
|
||||
|
||||
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' });
|
||||
}
|
||||
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;
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 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' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { db } from '../config/database';
|
||||
|
||||
export async function getAll(req: Request, res: Response) {
|
||||
const partners = await db('partners').where({ status: 'active' });
|
||||
res.json({ partners });
|
||||
}
|
||||
|
||||
export async function getBySlug(req: Request, res: Response) {
|
||||
const { slug } = req.params;
|
||||
|
||||
try {
|
||||
const partner = await db('partners')
|
||||
.where({ slug, status: 'active' })
|
||||
.first();
|
||||
|
||||
if (!partner) {
|
||||
return res.status(404).json({ error: 'Parceiro não encontrado' });
|
||||
}
|
||||
|
||||
const benefits = await db('benefits')
|
||||
.where({ partner_id: partner.id, active: true })
|
||||
.orderBy('created_at', 'desc');
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
// 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.' });
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
// Delete Leads
|
||||
await trx('leads').where({ partner_id: id }).delete();
|
||||
|
||||
// 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 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' });
|
||||
}
|
||||
|
||||
const deleted = await trx('partners').where({ id }).delete();
|
||||
if (!deleted) throw new Error('Parceiro não encontrado');
|
||||
});
|
||||
|
||||
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' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { db } from '../config/database';
|
||||
|
||||
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 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 });
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
res.json({ history });
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar histórico:', error);
|
||||
res.status(500).json({ error: 'Erro ao buscar histórico' });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Admin Actions ---
|
||||
|
||||
export async function updateUser(req: Request, res: Response) {
|
||||
const { id } = req.params;
|
||||
const { name, email, role } = req.body;
|
||||
|
||||
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.' });
|
||||
}
|
||||
}
|
||||
|
||||
// 🛡️ 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.' });
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateStatus(req: Request, res: Response) {
|
||||
const { id } = req.params;
|
||||
const { status } = req.body; // 'active', 'blocked', 'banned'
|
||||
|
||||
if (!['active', 'blocked', 'banned'].includes(status)) {
|
||||
return res.status(400).json({ error: 'Status inválido' });
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
try {
|
||||
const { UserLifecycleService } = await import('../services/UserLifecycleService');
|
||||
|
||||
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' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// ============================================================
|
||||
// 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);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// ============================================================
|
||||
// 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();
|
||||
@@ -0,0 +1,44 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,719 @@
|
||||
// ============================================================
|
||||
// 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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// ============================================================
|
||||
// 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();
|
||||
@@ -0,0 +1,6 @@
|
||||
"use strict";
|
||||
// ============================================================
|
||||
// Clube67 — Plugin Manifest Schema
|
||||
// ============================================================
|
||||
// Each plugin MUST have a manifest.json following this interface.
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
@@ -0,0 +1,120 @@
|
||||
// ============================================================
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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');
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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');
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import express from 'express';
|
||||
import http from 'http';
|
||||
import { Server } from 'socket.io';
|
||||
import { config } from './config';
|
||||
import { setupSecurity } from './middleware/security';
|
||||
import { logger } from './utils/logger';
|
||||
|
||||
import path from 'path';
|
||||
|
||||
// ── Legacy monolithic routes (kept for backward compatibility) ──
|
||||
import authRoutes from './routes/auth.routes';
|
||||
import userRoutes from './routes/user.routes';
|
||||
import partnerRoutes from './routes/partner.routes';
|
||||
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';
|
||||
|
||||
async function start() {
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const io = new Server(server, { cors: { origin: '*' } });
|
||||
|
||||
// Trust Nginx proxy (required for express-rate-limit behind reverse proxy)
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
setupSecurity(app);
|
||||
|
||||
// ── Fail-Safe Storage Monitoring ────────────────────────
|
||||
app.use(checkStorageHealth);
|
||||
|
||||
// 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({
|
||||
status: 'ok',
|
||||
service: 'Clube de Benefícios',
|
||||
architecture: 'Core + Plugins',
|
||||
timestamp: new Date(),
|
||||
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);
|
||||
|
||||
// Static
|
||||
app.use('/uploads', express.static(config.upload.dir));
|
||||
app.use(express.static(path.join(process.cwd(), '../frontend')));
|
||||
|
||||
// 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'));
|
||||
});
|
||||
|
||||
// ── 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`);
|
||||
});
|
||||
}
|
||||
|
||||
start().catch(err => {
|
||||
logger.error('Failed to start server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Vendored
+34
@@ -0,0 +1,34 @@
|
||||
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();
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { config } from '../config';
|
||||
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 async function authenticate(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Token não fornecido' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwt.secret) as any;
|
||||
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') {
|
||||
return res.status(401).json({ error: 'Token expirado', code: 'TOKEN_EXPIRED' });
|
||||
}
|
||||
return res.status(401).json({ error: 'Token inválido' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
const roleHierarchy: Record<string, number> = {
|
||||
user: 1,
|
||||
secretary: 2,
|
||||
clinical_manager: 3,
|
||||
finance: 3,
|
||||
partner_admin: 4,
|
||||
admin: 5,
|
||||
super_admin: 10,
|
||||
};
|
||||
|
||||
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' });
|
||||
}
|
||||
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' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import express, { Express } from 'express';
|
||||
import helmet from 'helmet';
|
||||
import cors from 'cors';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import compression from 'compression';
|
||||
import hpp from 'hpp';
|
||||
|
||||
export function setupSecurity(app: Express) {
|
||||
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'"]
|
||||
}
|
||||
}
|
||||
}));
|
||||
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({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 1000, // Increased from 100 to avoid blocking frontend navigation
|
||||
});
|
||||
app.use('/api/', limiter);
|
||||
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 20,
|
||||
message: 'Muitas tentativas de login, tente novamente mais tarde',
|
||||
});
|
||||
app.use('/api/auth/login', authLimiter);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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();
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { config } from '../config';
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: config.upload.dir,
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${uuidv4()}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const upload = multer({
|
||||
storage,
|
||||
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,80 @@
|
||||
"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
|
||||
@@ -0,0 +1 @@
|
||||
{"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"}
|
||||
@@ -0,0 +1,60 @@
|
||||
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'
|
||||
@@ -0,0 +1,18 @@
|
||||
"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
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"infinite.js","sourceRoot":"","sources":["infinite.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,0CAAuB"}
|
||||
@@ -0,0 +1 @@
|
||||
export * from 'baileys'
|
||||
@@ -0,0 +1,18 @@
|
||||
"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
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"official.js","sourceRoot":"","sources":["official.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,0DAAuC"}
|
||||
@@ -0,0 +1 @@
|
||||
export * from 'baileys'
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* 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()}`
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
import { Router } from 'express';
|
||||
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');
|
||||
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import * as 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);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import * as benefitController from '../controllers/benefit.controller';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
router.get('/', benefitController.getAll);
|
||||
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);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,11 @@
|
||||
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;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import * as partnerController from '../controllers/partner.controller';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
|
||||
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);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Router } from 'express';
|
||||
import { pluginConfig } from '../core/plugin-config';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { authorize } from '../middleware/rbac';
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// Set Plugin Config
|
||||
router.post('/:name/config', authenticate, authorize(['super_admin']), (req, res) => {
|
||||
const { name } = req.params;
|
||||
const config = req.body;
|
||||
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,16 @@
|
||||
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;
|
||||
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Router } from 'express';
|
||||
import * as 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);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,2 @@
|
||||
import { Router } from 'express';
|
||||
export default Router();
|
||||
@@ -0,0 +1,78 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Express } from 'express';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export const pluginService = {
|
||||
async loadPlugins(app: Express) {
|
||||
logger.info('Loading plugins...');
|
||||
// Implementation details...
|
||||
},
|
||||
async installPlugin(zipPath: string) { },
|
||||
async activatePlugin(slug: string) { },
|
||||
async deactivatePlugin(slug: string) { },
|
||||
async removePlugin(slug: string) { },
|
||||
};
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { db } from '../config/database';
|
||||
|
||||
export async function auditLog(userId: number, action: string, entityType: string, entityId?: number, description?: string, ip?: string) {
|
||||
try {
|
||||
await db('logs').insert({
|
||||
user_id: userId,
|
||||
action,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
description,
|
||||
ip_address: ip
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to save audit log:', err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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()
|
||||
);
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
format: logFormat,
|
||||
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',
|
||||
level: 'error',
|
||||
maxFiles: '14d',
|
||||
}),
|
||||
new (winston.transports as any).DailyRotateFile({
|
||||
dirname: config.logging.dir,
|
||||
filename: 'combined-%DATE%.log',
|
||||
maxFiles: '14d',
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,28 @@
|
||||
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();
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "..",
|
||||
"strict": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationDir": "./dist/types",
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"../plugins/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
# ─── deploy-prod-builder.sh ──────────────────────────────────────────────────
|
||||
# Script sênior de automação de build e despacho (GitOps) de VPS 4 -> VPS 1.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
set -e
|
||||
|
||||
LOCK_FILE="/tmp/deploy-prod-clube67.lock"
|
||||
if [ -f "$LOCK_FILE" ]; then
|
||||
echo "🚨 Deploy já está em andamento! Aguarde..."
|
||||
exit 1
|
||||
fi
|
||||
touch "$LOCK_FILE"
|
||||
|
||||
# Garante que o lock file será apagado no final do script mesmo se houver erro
|
||||
trap 'rm -f "$LOCK_FILE"' EXIT
|
||||
|
||||
LOG_FILE="/home/deploy/stack/webhook-listener/deploy.log"
|
||||
|
||||
log() {
|
||||
echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
log "🚀 Iniciando ciclo de build e deploy automático para Clube67..."
|
||||
|
||||
# 1. Atualizar repositório Git local na VPS 4
|
||||
log "📦 Sincronizando repositório Git local na VPS 4..."
|
||||
cd /home/deploy/stack/clube67
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
|
||||
# 2. Compilar o Backend
|
||||
log "⚙️ Instalando dependências e compilando o Backend..."
|
||||
cd /home/deploy/stack/clube67/backend
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
# 3. Sincronizar arquivos compilados e arquivos de configuração com a VPS 1 (Produção) via Rsync VPN
|
||||
log "🚚 Despachando Backend compilado e dependências para a VPS 1 (Produção)..."
|
||||
rsync -avz --delete --exclude '.env' /home/deploy/stack/clube67/backend/dist/ deploy@10.99.0.1:/home/deploy/stack/newwhats.clube67.com/newwhats.local/backend/dist/
|
||||
rsync -avz --delete /home/deploy/stack/clube67/backend/node_modules/ deploy@10.99.0.1:/home/deploy/stack/newwhats.clube67.com/newwhats.local/backend/node_modules/
|
||||
rsync -avz /home/deploy/stack/clube67/backend/package.json /home/deploy/stack/clube67/backend/package-lock.json deploy@10.99.0.1:/home/deploy/stack/newwhats.clube67.com/newwhats.local/backend/
|
||||
|
||||
log "🚚 Despachando Frontend estático para a VPS 1 (Produção)..."
|
||||
rsync -avz --delete /home/deploy/stack/clube67/frontend/ deploy@10.99.0.1:/home/deploy/stack/newwhats.clube67.com/newwhats.local/frontend/
|
||||
|
||||
log "🚚 Despachando Nginx e Docker Compose de produção para a VPS 1..."
|
||||
rsync -avz /home/deploy/stack/clube67/nginx/default.conf deploy@10.99.0.1:/home/deploy/stack/newwhats.clube67.com/nginx/default.conf
|
||||
rsync -avz /home/deploy/stack/clube67/docker-compose.yml deploy@10.99.0.1:/home/deploy/stack/newwhats.clube67.com/docker-compose.yml
|
||||
|
||||
# 4. Parar containers antigos obsoletos e iniciar os novos na VPS 1
|
||||
log "🔄 Reiniciando containers e aplicando novas definições na VPS 1..."
|
||||
ssh -o BatchMode=yes deploy@10.99.0.1 "cd /home/deploy/stack/newwhats.clube67.com && DOCKER_HOST=unix:///run/user/1000/docker.sock docker compose down && DOCKER_HOST=unix:///run/user/1000/docker.sock docker compose up -d"
|
||||
|
||||
log "✅ Deploy do Clube67 concluído com absoluto sucesso!"
|
||||
@@ -0,0 +1,61 @@
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
restart: always
|
||||
volumes:
|
||||
- "./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# Roteador de Produção (clube67.com e newwhats.clube67.com)
|
||||
- "traefik.http.routers.clube67-prod.rule=Host(`clube67.com`) || Host(`www.clube67.com`) || Host(`newwhats.clube67.com`)"
|
||||
- "traefik.http.routers.clube67-prod.entrypoints=websecure"
|
||||
- "traefik.http.routers.clube67-prod.tls.certresolver=le"
|
||||
- "traefik.http.services.clube67-prod.loadbalancer.server.port=80"
|
||||
- "traefik.docker.network=web"
|
||||
networks:
|
||||
- soc
|
||||
- web
|
||||
depends_on:
|
||||
- newwhats-backend
|
||||
|
||||
newwhats-backend:
|
||||
image: node:20
|
||||
container_name: newwhats-backend-prod
|
||||
restart: always
|
||||
# rootless docker: container UID 0 já é mapeado para o usuário deploy do host
|
||||
working_dir: /app
|
||||
env_file:
|
||||
- "./newwhats.local/backend/.env"
|
||||
environment:
|
||||
- PORT=8008
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=postgresql://clube67:clube67_db_pass_9903@10.99.0.3:5432/clube67?schema=public
|
||||
- DB_HOST=10.99.0.3
|
||||
- DB_PORT=5432
|
||||
- DB_USER=clube67
|
||||
- DB_PASSWORD=clube67_db_pass_9903
|
||||
- DB_NAME=clube67
|
||||
- REDIS_HOST=10.99.0.3
|
||||
- REDIS_PORT=6379
|
||||
- REDIS_PASSWORD=clube67_dragonfly_pass_9903
|
||||
volumes:
|
||||
- "/home/deploy/newwhats-sessions:/app/sessions"
|
||||
- "/home/deploy/newwhats-media:/app/media"
|
||||
- "./newwhats.local/plugins:/app/plugins"
|
||||
# Montar diretórios e arquivos pré-compilados pelo builder (VPS 4)
|
||||
- "./newwhats.local/backend/dist:/app/dist"
|
||||
- "./newwhats.local/backend/package.json:/app/package.json"
|
||||
- "./newwhats.local/backend/node_modules:/app/node_modules"
|
||||
- "./newwhats.local/frontend:/frontend"
|
||||
command: node dist/backend/src/index.js
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
networks:
|
||||
- soc
|
||||
- web
|
||||
|
||||
networks:
|
||||
soc:
|
||||
external: true
|
||||
web:
|
||||
external: true
|
||||
@@ -0,0 +1,330 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Clube67 — Clube de Benefícios Premium</title>
|
||||
<!-- Google Fonts -->
|
||||
<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=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<!-- Lucide Icons -->
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<!-- Stylesheet -->
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100 font-sans min-h-screen selection:bg-indigo-500/30 selection:text-indigo-200 overflow-x-hidden">
|
||||
<!-- Dynamic Background Orbs -->
|
||||
<div class="fixed -top-40 -left-40 w-96 h-96 rounded-full bg-indigo-600/10 blur-[120px] pointer-events-none animate-pulse duration-[8000ms]"></div>
|
||||
<div class="fixed top-1/2 -right-40 w-[500px] h-[500px] rounded-full bg-violet-600/10 blur-[150px] pointer-events-none animate-pulse duration-[10000ms]"></div>
|
||||
|
||||
<!-- Top Header -->
|
||||
<header class="sticky top-0 z-40 backdrop-blur-md bg-slate-950/75 border-b border-slate-900/80 px-6 py-4">
|
||||
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-tr from-indigo-600 to-violet-500 flex items-center justify-center font-display font-black text-xl text-white shadow-lg shadow-logo">
|
||||
67
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="font-display font-black text-lg tracking-wide text-white uppercase leading-none">Clube67</h1>
|
||||
<span class="brand-subtitle font-bold text-slate-500 uppercase">Premium Benefícios</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex bg-slate-900/60 p-1 rounded-xl border border-slate-800/80">
|
||||
<button id="tab-catalog" class="nav-tab active flex items-center gap-2 px-5 py-2.5 rounded-lg text-xs font-bold tracking-wide uppercase transition-all duration-300">
|
||||
<i data-lucide="tag" class="w-3.5 h-3.5"></i> Catálogo
|
||||
</button>
|
||||
<button id="tab-redeem" class="nav-tab flex items-center gap-2 px-5 py-2.5 rounded-lg text-xs font-bold tracking-wide uppercase transition-all duration-300">
|
||||
<i data-lucide="qr-code" class="w-3.5 h-3.5"></i> Resgate
|
||||
</button>
|
||||
<button id="tab-analytics" class="nav-tab flex items-center gap-2 px-5 py-2.5 rounded-lg text-xs font-bold tracking-wide uppercase transition-all duration-300">
|
||||
<i data-lucide="bar-chart-3" class="w-3.5 h-3.5"></i> Analytics
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-7xl mx-auto px-6 py-12">
|
||||
<!-- SECTION 1: CATALOG -->
|
||||
<section id="section-catalog" class="content-section">
|
||||
<div class="mb-10 text-center md:text-left">
|
||||
<h2 class="font-display font-black text-3xl md:text-4xl text-white tracking-tight mb-3">
|
||||
Seus Benefícios <span class="bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">Exclusivos</span>
|
||||
</h2>
|
||||
<p class="text-slate-400 text-sm max-w-2xl leading-relaxed">
|
||||
Explore descontos e vantagens premium selecionados sob medida para você nos melhores estabelecimentos parceiros do Mato Grosso do Sul.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Search & Filters Container -->
|
||||
<div class="bg-slate-900/40 border border-slate-800/50 rounded-2xl p-6 backdrop-blur-md mb-8 flex flex-col md:flex-row gap-6 items-center justify-between">
|
||||
<!-- Search bar -->
|
||||
<div class="relative w-full md:max-w-md">
|
||||
<span class="absolute inset-y-0 left-0 pl-4 flex items-center text-slate-500 pointer-events-none">
|
||||
<i data-lucide="search" class="w-4 h-4"></i>
|
||||
</span>
|
||||
<input type="text" id="search-input" name="search-input" placeholder="Buscar parceiro ou benefício..." class="w-full bg-slate-950/60 border border-slate-800 rounded-xl pl-11 pr-4 py-3 text-sm focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/20 text-slate-100 placeholder:text-slate-500 transition-all duration-300">
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-2 justify-center" id="category-filters">
|
||||
<button class="filter-pill active" data-category="all">Todos</button>
|
||||
<button class="filter-pill" data-category="saude">🩺 Saúde</button>
|
||||
<button class="filter-pill" data-category="odontologia">🦷 Odonto</button>
|
||||
<button class="filter-pill" data-category="gastronomia">🍔 Gastronomia</button>
|
||||
<button class="filter-pill" data-category="lazer">🌴 Lazer</button>
|
||||
<button class="filter-pill" data-category="farmacia">💊 Farmácia</button>
|
||||
<button class="filter-pill" data-category="educacao">📚 Educação</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Benefits Grid -->
|
||||
<div id="benefits-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- Loading State -->
|
||||
<div class="col-span-full py-20 text-center" id="catalog-loading">
|
||||
<div class="w-10 h-10 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p class="text-slate-400 text-sm">Carregando benefícios exclusivos...</p>
|
||||
</div>
|
||||
<!-- Dynamic cards will be injected here -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SECTION 2: REDEEM -->
|
||||
<section id="section-redeem" class="content-section hidden">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="text-center mb-10">
|
||||
<h2 class="font-display font-black text-3xl text-white tracking-tight mb-3">Validação & Resgate de Código</h2>
|
||||
<p class="text-slate-400 text-sm leading-relaxed">
|
||||
Selecione um benefício no catálogo para resgatar. Abaixo você pode visualizar e simular o fluxo de validação segura por Token QR Code.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Active Voucher Container -->
|
||||
<div class="bg-slate-900/35 border border-slate-800/80 rounded-3xl p-8 backdrop-blur-xl relative overflow-hidden shadow-2xl" id="redeem-card-empty">
|
||||
<div class="text-center py-12 text-slate-500">
|
||||
<i data-lucide="tag" class="w-12 h-12 mx-auto mb-4 text-slate-600 animate-bounce"></i>
|
||||
<h3 class="font-display font-bold text-white text-base mb-1">Nenhum cupom ativo no momento</h3>
|
||||
<p class="text-xs max-w-sm mx-auto leading-relaxed">Navegue pelo Catálogo de Benefícios e selecione a oferta desejada clicando em "Resgatar Voucher".</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-900/35 border border-slate-800/80 rounded-3xl p-8 backdrop-blur-xl relative overflow-hidden shadow-2xl hidden" id="redeem-card-active">
|
||||
<div class="absolute top-0 right-0 w-40 h-40 bg-indigo-500/5 rounded-full blur-3xl pointer-events-none"></div>
|
||||
|
||||
<div class="flex items-center gap-4 mb-8 pb-6 border-b border-slate-800/80">
|
||||
<div class="w-12 h-12 rounded-xl bg-indigo-600/10 border border-indigo-500/20 flex items-center justify-center text-xl" id="voucher-icon">🩺</div>
|
||||
<div>
|
||||
<h3 class="font-display font-black text-lg text-white leading-snug" id="voucher-title">Clube Odonto Plus</h3>
|
||||
<p class="text-xs text-slate-400" id="voucher-partner">ScoreOdonto Ltda</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code & Laser Scanner -->
|
||||
<div class="flex flex-col items-center justify-center mb-8">
|
||||
<div class="relative w-48 h-48 bg-white p-4 rounded-2xl shadow-xl shadow-indigo-600/5 border border-slate-700/20 overflow-hidden">
|
||||
<!-- Simulated QR Code Image -->
|
||||
<img src="https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=C67-A9X2B&color=0f172a" id="qr-code-img" class="w-full h-full object-contain" alt="QR Code">
|
||||
<!-- Laser scan bar -->
|
||||
<div class="absolute left-0 right-0 h-1 bg-indigo-500 shadow-lg shadow-indigo-500/80 animate-scan"></div>
|
||||
</div>
|
||||
|
||||
<!-- Token Display -->
|
||||
<div class="mt-5 px-6 py-2 bg-slate-950/80 border border-slate-800 rounded-xl">
|
||||
<span class="text-xs font-bold tracking-[0.25em] text-indigo-400 uppercase font-mono" id="voucher-token">C67-A9X2B</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timer Countdown -->
|
||||
<div class="space-y-3 mb-8">
|
||||
<div class="flex items-center justify-between text-xs font-bold text-slate-400">
|
||||
<span>Token expira em:</span>
|
||||
<span class="text-indigo-400 font-mono" id="timer-text">05:00</span>
|
||||
</div>
|
||||
<div class="h-1.5 w-full bg-slate-950 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-gradient-to-r from-indigo-500 to-violet-500 transition-all duration-1000" id="timer-bar" style="width: 100%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Simulator Trigger -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<button id="btn-validate" class="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-display font-bold py-4 rounded-xl text-xs uppercase tracking-widest shadow-lg shadow-button transition-all duration-300">
|
||||
Simular Validação (Estabelecimento)
|
||||
</button>
|
||||
<button id="btn-cancel" class="w-full bg-slate-950 hover:bg-slate-900 border border-slate-800 hover:border-slate-700 text-slate-400 hover:text-white font-display font-bold py-4 rounded-xl text-xs uppercase tracking-widest transition-all duration-300">
|
||||
Cancelar Resgate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SECTION 3: ANALYTICS -->
|
||||
<section id="section-analytics" class="content-section hidden">
|
||||
<div class="mb-10 text-center md:text-left">
|
||||
<h2 class="font-display font-black text-3xl text-white tracking-tight mb-3">Painel de Performance do Parceiro</h2>
|
||||
<p class="text-slate-400 text-sm max-w-2xl leading-relaxed">
|
||||
Acompanhe em tempo real a tração da sua marca, a economia estimada gerada para os membros do Clube67 e os picos de resgate semanais.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Stat 1 -->
|
||||
<div class="bg-slate-900/40 border border-slate-800/80 rounded-2xl p-6 backdrop-blur-md relative overflow-hidden hover:border-indigo-500/30 transition-all duration-300">
|
||||
<div class="absolute -top-6 -right-6 w-24 h-24 bg-indigo-500/5 rounded-full blur-2xl pointer-events-none"></div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="text-xs font-bold text-slate-400 uppercase tracking-wider">Membros Ativos</span>
|
||||
<div class="w-8 h-8 rounded-lg bg-indigo-600/10 flex items-center justify-center text-indigo-400">
|
||||
<i data-lucide="users" class="w-4 h-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="font-display font-black text-3xl text-white mb-2" id="stat-members">1,248</h3>
|
||||
<span class="text-10px font-bold text-emerald-400 flex items-center gap-1">
|
||||
<i data-lucide="trending-up" class="w-3 h-3"></i> +12.4% este mês
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Stat 2 -->
|
||||
<div class="bg-slate-900/40 border border-slate-800/80 rounded-2xl p-6 backdrop-blur-md relative overflow-hidden hover:border-indigo-500/30 transition-all duration-300">
|
||||
<div class="absolute -top-6 -right-6 w-24 h-24 bg-indigo-500/5 rounded-full blur-2xl pointer-events-none"></div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="text-xs font-bold text-slate-400 uppercase tracking-wider">Resgates (Mês)</span>
|
||||
<div class="w-8 h-8 rounded-lg bg-indigo-600/10 flex items-center justify-center text-indigo-400">
|
||||
<i data-lucide="ticket" class="w-4 h-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="font-display font-black text-3xl text-white mb-2" id="stat-redemptions">384</h3>
|
||||
<span class="text-10px font-bold text-emerald-400 flex items-center gap-1">
|
||||
<i data-lucide="trending-up" class="w-3 h-3"></i> +8.2% esta semana
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Stat 3 -->
|
||||
<div class="bg-slate-900/40 border border-slate-800/80 rounded-2xl p-6 backdrop-blur-md relative overflow-hidden hover:border-indigo-500/30 transition-all duration-300">
|
||||
<div class="absolute -top-6 -right-6 w-24 h-24 bg-indigo-500/5 rounded-full blur-2xl pointer-events-none"></div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="text-xs font-bold text-slate-400 uppercase tracking-wider">Economia Gerada</span>
|
||||
<div class="w-8 h-8 rounded-lg bg-indigo-600/10 flex items-center justify-center text-indigo-400">
|
||||
<i data-lucide="piggy-bank" class="w-4 h-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="font-display font-black text-3xl text-white mb-2" id="stat-savings">R$ 14,820</h3>
|
||||
<span class="text-10px font-bold text-emerald-400 flex items-center gap-1">
|
||||
<i data-lucide="trending-up" class="w-3 h-3"></i> +15.1% de economia
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
<!-- Chart 1: SVG Radial Goal (Left) -->
|
||||
<div class="bg-slate-900/40 border border-slate-800/80 rounded-2xl p-6 backdrop-blur-md lg:col-span-4 flex flex-col items-center justify-between min-h-[350px]">
|
||||
<div class="w-full">
|
||||
<h4 class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-1">Atingimento de Meta</h4>
|
||||
<p class="text-10px text-slate-500">Resgates cumulativos em relação ao objetivo mensal</p>
|
||||
</div>
|
||||
|
||||
<!-- SVG Circular gauge -->
|
||||
<div class="relative w-40 h-40 flex items-center justify-center my-6">
|
||||
<svg class="w-full h-full transform -rotate-90">
|
||||
<circle cx="80" cy="80" r="64" class="stroke-slate-950 fill-transparent" stroke-width="10" />
|
||||
<circle cx="80" cy="80" r="64" id="radial-progress-bar" class="stroke-indigo-500 fill-transparent transition-all duration-1000" stroke-width="10" stroke-dasharray="402" stroke-dashoffset="402" stroke-linecap="round" />
|
||||
</svg>
|
||||
<div class="absolute flex flex-col items-center justify-center">
|
||||
<span class="font-display font-black text-3xl text-white" id="radial-percentage">0%</span>
|
||||
<span class="text-9px font-bold uppercase tracking-wider text-slate-500">Completado</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center w-full">
|
||||
<span class="text-xs font-semibold text-slate-300">Faltam apenas <span class="text-indigo-400">16 resgates</span> para bater o recorde!</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart 2: Weekday vertical bar chart (Right) -->
|
||||
<div class="bg-slate-900/40 border border-slate-800/80 rounded-2xl p-6 backdrop-blur-md lg:col-span-8 flex flex-col justify-between min-h-[350px]">
|
||||
<div>
|
||||
<h4 class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-1">Fluxo de Resgates por Dia</h4>
|
||||
<p class="text-10px text-slate-500">Análise de picos semanais de resgates de cupons no estabelecimento</p>
|
||||
</div>
|
||||
|
||||
<!-- Vertical bars container -->
|
||||
<div class="flex items-end justify-between h-40 px-4 my-6 gap-3">
|
||||
<!-- Monday -->
|
||||
<div class="flex-1 flex flex-col items-center gap-2 group">
|
||||
<span class="text-9px font-bold font-mono text-slate-500 group-hover:text-indigo-400 transition-colors opacity-0 group-hover:opacity-100 transition-opacity">12</span>
|
||||
<div class="w-full bg-slate-950/80 rounded-t-lg h-40 flex items-end overflow-hidden border border-slate-900/20">
|
||||
<div class="bg-gradient-to-t from-indigo-600 to-violet-500 w-full rounded-t-lg transition-all duration-1000" style="height: 0%" id="bar-seg"></div>
|
||||
</div>
|
||||
<span class="text-10px font-semibold text-slate-400">Seg</span>
|
||||
</div>
|
||||
<!-- Tuesday -->
|
||||
<div class="flex-1 flex flex-col items-center gap-2 group">
|
||||
<span class="text-9px font-bold font-mono text-slate-500 group-hover:text-indigo-400 transition-colors opacity-0 group-hover:opacity-100 transition-opacity">25</span>
|
||||
<div class="w-full bg-slate-950/80 rounded-t-lg h-40 flex items-end overflow-hidden border border-slate-900/20">
|
||||
<div class="bg-gradient-to-t from-indigo-600 to-violet-500 w-full rounded-t-lg transition-all duration-1000" style="height: 0%" id="bar-ter"></div>
|
||||
</div>
|
||||
<span class="text-10px font-semibold text-slate-400">Ter</span>
|
||||
</div>
|
||||
<!-- Wednesday -->
|
||||
<div class="flex-1 flex flex-col items-center gap-2 group">
|
||||
<span class="text-9px font-bold font-mono text-slate-500 group-hover:text-indigo-400 transition-colors opacity-0 group-hover:opacity-100 transition-opacity">42</span>
|
||||
<div class="w-full bg-slate-950/80 rounded-t-lg h-40 flex items-end overflow-hidden border border-slate-900/20">
|
||||
<div class="bg-gradient-to-t from-indigo-600 to-violet-500 w-full rounded-t-lg transition-all duration-1000" style="height: 0%" id="bar-qua"></div>
|
||||
</div>
|
||||
<span class="text-10px font-semibold text-slate-400">Qua</span>
|
||||
</div>
|
||||
<!-- Thursday -->
|
||||
<div class="flex-1 flex flex-col items-center gap-2 group">
|
||||
<span class="text-9px font-bold font-mono text-slate-500 group-hover:text-indigo-400 transition-colors opacity-0 group-hover:opacity-100 transition-opacity">30</span>
|
||||
<div class="w-full bg-slate-950/80 rounded-t-lg h-40 flex items-end overflow-hidden border border-slate-900/20">
|
||||
<div class="bg-gradient-to-t from-indigo-600 to-violet-500 w-full rounded-t-lg transition-all duration-1000" style="height: 0%" id="bar-qui"></div>
|
||||
</div>
|
||||
<span class="text-10px font-semibold text-slate-400">Qui</span>
|
||||
</div>
|
||||
<!-- Friday -->
|
||||
<div class="flex-1 flex flex-col items-center gap-2 group">
|
||||
<span class="text-9px font-bold font-mono text-slate-500 group-hover:text-indigo-400 transition-colors opacity-0 group-hover:opacity-100 transition-opacity">58</span>
|
||||
<div class="w-full bg-slate-950/80 rounded-t-lg h-40 flex items-end overflow-hidden border border-slate-900/20">
|
||||
<div class="bg-gradient-to-t from-indigo-600 to-violet-500 w-full rounded-t-lg transition-all duration-1000" style="height: 0%" id="bar-sex"></div>
|
||||
</div>
|
||||
<span class="text-10px font-semibold text-slate-400">Sex</span>
|
||||
</div>
|
||||
<!-- Saturday -->
|
||||
<div class="flex-1 flex flex-col items-center gap-2 group">
|
||||
<span class="text-9px font-bold font-mono text-slate-500 group-hover:text-indigo-400 transition-colors opacity-0 group-hover:opacity-100 transition-opacity">70</span>
|
||||
<div class="w-full bg-slate-950/80 rounded-t-lg h-40 flex items-end overflow-hidden border border-slate-900/20">
|
||||
<div class="bg-gradient-to-t from-indigo-600 to-violet-500 w-full rounded-t-lg transition-all duration-1000" style="height: 0%" id="bar-sab"></div>
|
||||
</div>
|
||||
<span class="text-10px font-semibold text-slate-400">Sáb</span>
|
||||
</div>
|
||||
<!-- Sunday -->
|
||||
<div class="flex-1 flex flex-col items-center gap-2 group">
|
||||
<span class="text-9px font-bold font-mono text-slate-500 group-hover:text-indigo-400 transition-colors opacity-0 group-hover:opacity-100 transition-opacity">18</span>
|
||||
<div class="w-full bg-slate-950/80 rounded-t-lg h-40 flex items-end overflow-hidden border border-slate-900/20">
|
||||
<div class="bg-gradient-to-t from-indigo-600 to-violet-500 w-full rounded-t-lg transition-all duration-1000" style="height: 0%" id="bar-dom"></div>
|
||||
</div>
|
||||
<span class="text-10px font-semibold text-slate-400">Dom</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center text-xs text-slate-400">
|
||||
<span>Pico de atividades: <span class="text-white font-bold">Sábado</span></span>
|
||||
<span>Atualizado há 5m</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="border-t border-slate-900/80 py-8 px-6 text-center text-slate-600 text-[10px] font-bold uppercase tracking-widest">
|
||||
© 2026 Clube67. Todos os direitos reservados.
|
||||
</footer>
|
||||
|
||||
<!-- Script -->
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,393 @@
|
||||
// ── CLUBE67 PREMIUM INTERACTIVE SCRIPT ──
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initialize Lucide Icons
|
||||
lucide.createIcons();
|
||||
|
||||
// ── STATE MANAGEMENT ──
|
||||
let activeTab = 'catalog';
|
||||
let benefits = [];
|
||||
let activeVoucher = null;
|
||||
let countdownInterval = null;
|
||||
let timerTotalSeconds = 300; // 5 minutes
|
||||
let timerRemainingSeconds = 300;
|
||||
|
||||
// Premium Mock Data in case backend returns empty/offline to guarantee flawless display
|
||||
const mockBenefits = [
|
||||
{ id: 1, title: "Consulta + Limpeza Geral", description: "Ganhe 50% de desconto na primeira consulta preventiva e profilaxia.", category: "odontologia", partner: "ScoreOdonto MS", icon: "🦷", rating: "4.9", discount: "50% OFF" },
|
||||
{ id: 2, title: "Checkout Cardiológico Completo", description: "Desconto especial de 30% em exames de eletrocardiograma e ecocardiograma.", category: "saude", partner: "CardioClinic CG", icon: "🩺", rating: "4.8", discount: "30% OFF" },
|
||||
{ id: 3, title: "Menu Degustação Premium (Casal)", description: "Compre um menu degustação de 5 etapas e ganhe uma garrafa de vinho importado.", category: "gastronomia", partner: "Le Jardin Bistrô", icon: "🍔", rating: "5.0", discount: "Vinho Cortesia" },
|
||||
{ id: 4, title: "Day Use Completo no Eco Resort", description: "Acesso total às piscinas naturais, trilhas e almoço pantaneiro incluso com 25% OFF.", category: "lazer", partner: "Bonito Eco Resort", icon: "🌴", rating: "4.7", discount: "25% OFF" },
|
||||
{ id: 5, title: "Fórmulas Manipuladas e Vitaminas", description: "Desconto exclusivo de 20% em qualquer fórmula médica manipulada.", category: "farmacia", partner: "Fórmula Ativa", icon: "💊", rating: "4.9", discount: "20% OFF" },
|
||||
{ id: 6, title: "MBA Executivo & Especializações", description: "Isenção na taxa de matrícula e 35% de desconto nas mensalidades do curso.", category: "educacao", partner: "Unigran Capital", icon: "📚", rating: "4.6", discount: "35% OFF" }
|
||||
];
|
||||
|
||||
// Analytics Dashboard Data
|
||||
const analyticsData = {
|
||||
members: 1248,
|
||||
redemptions: 384,
|
||||
savings: 14820,
|
||||
goalPercentage: 96,
|
||||
weeklyDistribution: {
|
||||
seg: 25,
|
||||
ter: 45,
|
||||
qua: 70,
|
||||
qui: 55,
|
||||
sex: 85,
|
||||
sab: 100,
|
||||
dom: 35
|
||||
}
|
||||
};
|
||||
|
||||
// ── DOM ELEMENTS ──
|
||||
const navTabs = {
|
||||
catalog: document.getElementById('tab-catalog'),
|
||||
redeem: document.getElementById('tab-redeem'),
|
||||
analytics: document.getElementById('tab-analytics')
|
||||
};
|
||||
|
||||
const sections = {
|
||||
catalog: document.getElementById('section-catalog'),
|
||||
redeem: document.getElementById('section-redeem'),
|
||||
analytics: document.getElementById('section-analytics')
|
||||
};
|
||||
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const categoryFilters = document.getElementById('category-filters');
|
||||
const benefitsGrid = document.getElementById('benefits-grid');
|
||||
|
||||
// Redeem tab elements
|
||||
const redeemCardEmpty = document.getElementById('redeem-card-empty');
|
||||
const redeemCardActive = document.getElementById('redeem-card-active');
|
||||
const voucherIcon = document.getElementById('voucher-icon');
|
||||
const voucherTitle = document.getElementById('voucher-title');
|
||||
const voucherPartner = document.getElementById('voucher-partner');
|
||||
const voucherToken = document.getElementById('voucher-token');
|
||||
const qrCodeImg = document.getElementById('qr-code-img');
|
||||
const timerText = document.getElementById('timer-text');
|
||||
const timerBar = document.getElementById('timer-bar');
|
||||
const btnValidate = document.getElementById('btn-validate');
|
||||
const btnCancel = document.getElementById('btn-cancel');
|
||||
|
||||
// Analytics elements
|
||||
const statMembers = document.getElementById('stat-members');
|
||||
const statRedemptions = document.getElementById('stat-redemptions');
|
||||
const statSavings = document.getElementById('stat-savings');
|
||||
const radialProgressBar = document.getElementById('radial-progress-bar');
|
||||
const radialPercentage = document.getElementById('radial-percentage');
|
||||
|
||||
const barSeg = document.getElementById('bar-seg');
|
||||
const barTer = document.getElementById('bar-ter');
|
||||
const barQua = document.getElementById('bar-qua');
|
||||
const barQui = document.getElementById('bar-qui');
|
||||
const barSex = document.getElementById('bar-sex');
|
||||
const barSab = document.getElementById('bar-sab');
|
||||
const barDom = document.getElementById('bar-dom');
|
||||
|
||||
// ── NAVIGATION LOGIC ──
|
||||
function switchTab(tabId) {
|
||||
if (activeTab === tabId) return;
|
||||
|
||||
// Update tabs active state
|
||||
Object.keys(navTabs).forEach(key => {
|
||||
if (key === tabId) {
|
||||
navTabs[key].classList.add('active');
|
||||
} else {
|
||||
navTabs[key].classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Hide old section, show new section
|
||||
Object.keys(sections).forEach(key => {
|
||||
if (key === tabId) {
|
||||
sections[key].classList.remove('hidden');
|
||||
} else {
|
||||
sections[key].classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
activeTab = tabId;
|
||||
|
||||
// Perform specific triggers per tab
|
||||
if (tabId === 'analytics') {
|
||||
loadAnalyticsCharts();
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(navTabs).forEach(key => {
|
||||
navTabs[key].addEventListener('click', () => switchTab(key));
|
||||
});
|
||||
|
||||
// ── BENEFIT FETCHING & RENDERING ──
|
||||
async function loadBenefits() {
|
||||
try {
|
||||
// Sincronized backend API consume
|
||||
const response = await fetch('/api/benefits');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
benefits = data && data.length > 0 ? data : mockBenefits;
|
||||
} else {
|
||||
benefits = mockBenefits;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Clube67] API offline, running with premium fallback simulation.');
|
||||
benefits = mockBenefits;
|
||||
}
|
||||
|
||||
renderBenefits(benefits);
|
||||
}
|
||||
|
||||
function renderBenefits(list) {
|
||||
const loadingEl = document.getElementById('catalog-loading');
|
||||
if (loadingEl) loadingEl.remove();
|
||||
|
||||
benefitsGrid.innerHTML = '';
|
||||
if (list.length === 0) {
|
||||
benefitsGrid.innerHTML = `
|
||||
<div class="col-span-full py-16 text-center text-slate-500">
|
||||
<i data-lucide="frown" class="w-10 h-10 mx-auto mb-3"></i>
|
||||
<p class="text-sm">Nenhum benefício encontrado para esta categoria ou busca.</p>
|
||||
</div>
|
||||
`;
|
||||
lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
list.forEach(benefit => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'benefit-card';
|
||||
|
||||
// Mouse moves 3D lighting effect
|
||||
card.addEventListener('mousemove', e => {
|
||||
const rect = card.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
card.style.setProperty('--mouse-x', `${x}px`);
|
||||
card.style.setProperty('--mouse-y', `${y}px`);
|
||||
});
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="absolute top-4 right-4 bg-indigo-500/10 border border-indigo-500/20 text-indigo-300 text-[10px] font-bold uppercase tracking-widest px-3 py-1 rounded-full">
|
||||
${benefit.discount || 'Desconto'}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 mb-5">
|
||||
<div class="w-12 h-12 rounded-2xl bg-indigo-600/10 border border-indigo-500/10 flex items-center justify-center text-2xl shadow-inner">
|
||||
${benefit.icon || '🎁'}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[9px] font-bold tracking-widest text-slate-500 uppercase">${benefit.partner || 'Estabelecimento'}</span>
|
||||
<h4 class="font-display font-bold text-base text-white leading-snug line-clamp-1 mt-0.5">${benefit.title}</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-slate-400 text-xs leading-relaxed mb-6 min-h-[48px] line-clamp-3">${benefit.description}</p>
|
||||
|
||||
<div class="flex items-center justify-between border-t border-slate-800/80 pt-4">
|
||||
<div class="flex items-center gap-1.5 text-xs font-semibold text-amber-400">
|
||||
<i data-lucide="star" class="w-3.5 h-3.5 fill-amber-400"></i>
|
||||
<span>${benefit.rating || '4.8'}</span>
|
||||
</div>
|
||||
<button class="btn-redeem-trigger btn-primary px-4 py-2 rounded-xl text-[10px] font-bold uppercase tracking-wider transition-all duration-300" data-id="${benefit.id}">
|
||||
Resgatar Voucher
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
benefitsGrid.appendChild(card);
|
||||
});
|
||||
|
||||
// Add event listeners to voucher buttons
|
||||
document.querySelectorAll('.btn-redeem-trigger').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = parseInt(btn.getAttribute('data-id'), 10);
|
||||
const selected = benefits.find(b => b.id === id);
|
||||
if (selected) {
|
||||
triggerVoucherRedemption(selected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
// ── SEARCH & FILTERING ──
|
||||
let activeCategory = 'all';
|
||||
|
||||
function filterBenefits() {
|
||||
const query = searchInput.value.toLowerCase().trim();
|
||||
const filtered = benefits.filter(b => {
|
||||
const matchesCategory = activeCategory === 'all' || b.category === activeCategory;
|
||||
const matchesSearch = b.title.toLowerCase().includes(query) ||
|
||||
b.description.toLowerCase().includes(query) ||
|
||||
(b.partner && b.partner.toLowerCase().includes(query));
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
renderBenefits(filtered);
|
||||
}
|
||||
|
||||
searchInput.addEventListener('input', filterBenefits);
|
||||
|
||||
categoryFilters.querySelectorAll('.filter-pill').forEach(pill => {
|
||||
pill.addEventListener('click', () => {
|
||||
categoryFilters.querySelector('.filter-pill.active').classList.remove('active');
|
||||
pill.classList.add('active');
|
||||
activeCategory = pill.getAttribute('data-category');
|
||||
filterBenefits();
|
||||
});
|
||||
});
|
||||
|
||||
// ── VOUCHER REDEMPTION & SCANNER TIMER ──
|
||||
function triggerVoucherRedemption(benefit) {
|
||||
activeVoucher = benefit;
|
||||
|
||||
// Populate DOM elements
|
||||
voucherIcon.textContent = benefit.icon || '🎁';
|
||||
voucherTitle.textContent = benefit.title;
|
||||
voucherPartner.textContent = benefit.partner || 'Estabelecimento';
|
||||
|
||||
// Generate dynamic unique token
|
||||
const randToken = 'C67-' + Math.random().toString(36).substring(2, 7).toUpperCase();
|
||||
voucherToken.textContent = randToken;
|
||||
|
||||
// Update QR Code Source with parameters
|
||||
qrCodeImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${randToken}&color=0f172a`;
|
||||
|
||||
// Switch to redeem container view
|
||||
redeemCardEmpty.classList.add('hidden');
|
||||
redeemCardActive.classList.remove('hidden');
|
||||
|
||||
// Switch view tab
|
||||
switchTab('redeem');
|
||||
|
||||
// Start 5 minutes expiration timer
|
||||
startVoucherTimer();
|
||||
}
|
||||
|
||||
function startVoucherTimer() {
|
||||
if (countdownInterval) clearInterval(countdownInterval);
|
||||
|
||||
timerRemainingSeconds = timerTotalSeconds;
|
||||
updateTimerDisplay();
|
||||
|
||||
countdownInterval = setInterval(() => {
|
||||
timerRemainingSeconds--;
|
||||
updateTimerDisplay();
|
||||
|
||||
if (timerRemainingSeconds <= 0) {
|
||||
handleVoucherExpiration();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function updateTimerDisplay() {
|
||||
const min = Math.floor(timerRemainingSeconds / 60);
|
||||
const sec = timerRemainingSeconds % 60;
|
||||
|
||||
timerText.textContent = `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
|
||||
|
||||
// Progress bar percentage shrink
|
||||
const pct = (timerRemainingSeconds / timerTotalSeconds) * 100;
|
||||
timerBar.style.width = `${pct}%`;
|
||||
|
||||
// Danger colors for low time
|
||||
if (timerRemainingSeconds < 60) {
|
||||
timerBar.className = 'h-full bg-gradient-to-r from-red-500 to-rose-500 transition-all duration-1000';
|
||||
timerText.className = 'text-red-400 font-mono';
|
||||
} else {
|
||||
timerBar.className = 'h-full bg-gradient-to-r from-indigo-500 to-violet-500 transition-all duration-1000';
|
||||
timerText.className = 'text-indigo-400 font-mono';
|
||||
}
|
||||
}
|
||||
|
||||
function handleVoucherExpiration() {
|
||||
clearInterval(countdownInterval);
|
||||
alert('Este token de resgate expirou por limite de tempo de segurança. Por favor, gere um novo voucher.');
|
||||
cancelVoucher();
|
||||
}
|
||||
|
||||
function cancelVoucher() {
|
||||
if (countdownInterval) clearInterval(countdownInterval);
|
||||
activeVoucher = null;
|
||||
redeemCardActive.classList.add('hidden');
|
||||
redeemCardEmpty.classList.remove('hidden');
|
||||
}
|
||||
|
||||
btnCancel.addEventListener('click', cancelVoucher);
|
||||
|
||||
// ── VOUCHER VALIDATION SIMULATOR (API CONSUME) ──
|
||||
btnValidate.addEventListener('click', async () => {
|
||||
if (!activeVoucher) return;
|
||||
|
||||
btnValidate.disabled = true;
|
||||
btnValidate.textContent = 'VALIDANDO...';
|
||||
|
||||
try {
|
||||
// Dynamic validation call to backend API
|
||||
const response = await fetch('/api/benefits/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tokenId: voucherToken.textContent,
|
||||
benefitId: activeVoucher.id
|
||||
})
|
||||
});
|
||||
|
||||
// Simulated UI validation popup for high wow-factor and offline resilience
|
||||
setTimeout(() => {
|
||||
alert(`✅ SUCESSO!
|
||||
O Voucher "${activeVoucher.title}" foi validado com sucesso e consumido pelo parceiro!
|
||||
Token: ${voucherToken.textContent}
|
||||
Estabelecimento: ${activeVoucher.partner || 'Clube67'}`);
|
||||
|
||||
// Update analytics counters to reflect live usage!
|
||||
analyticsData.redemptions++;
|
||||
analyticsData.savings += 45; // average discount saving value
|
||||
|
||||
cancelVoucher();
|
||||
btnValidate.disabled = false;
|
||||
btnValidate.textContent = 'Simular Validação (Estabelecimento)';
|
||||
|
||||
// Automatically redirect to Analytics to see the metric update live!
|
||||
switchTab('analytics');
|
||||
}, 1200);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Validation API fail:', err);
|
||||
btnValidate.disabled = false;
|
||||
btnValidate.textContent = 'Simular Validação (Estabelecimento)';
|
||||
}
|
||||
});
|
||||
|
||||
// ── ANALYTICS CHARTS & COUNTERS ──
|
||||
function loadAnalyticsCharts() {
|
||||
// Load live dynamic counters
|
||||
statMembers.textContent = analyticsData.members.toLocaleString();
|
||||
statRedemptions.textContent = analyticsData.redemptions.toLocaleString();
|
||||
statSavings.textContent = `R$ ${analyticsData.savings.toLocaleString('pt-BR')}`;
|
||||
|
||||
// Load SVG gauge radial chart progress
|
||||
const radius = 64;
|
||||
const circumference = 2 * Math.PI * radius; // 402.12
|
||||
const pct = analyticsData.goalPercentage;
|
||||
const offset = circumference - (pct / 100) * circumference;
|
||||
|
||||
radialProgressBar.style.strokeDasharray = `${circumference}`;
|
||||
radialProgressBar.style.strokeDashoffset = `${offset}`;
|
||||
radialPercentage.textContent = `${pct}%`;
|
||||
|
||||
// Load vertical bar charts heights
|
||||
setTimeout(() => {
|
||||
barSeg.style.height = `${analyticsData.weeklyDistribution.seg}%`;
|
||||
barTer.style.height = `${analyticsData.weeklyDistribution.ter}%`;
|
||||
barQua.style.height = `${analyticsData.weeklyDistribution.qua}%`;
|
||||
barQui.style.height = `${analyticsData.weeklyDistribution.qui}%`;
|
||||
barSex.style.height = `${analyticsData.weeklyDistribution.sex}%`;
|
||||
barSab.style.height = `${analyticsData.weeklyDistribution.sab}%`;
|
||||
barDom.style.height = `${analyticsData.weeklyDistribution.dom}%`;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Initialize Catalog Fetching
|
||||
loadBenefits();
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
/* ── DESIGN SYSTEM & STYLING — CLUBE67 PREMIUM ── */
|
||||
|
||||
/* Typography & Root variables */
|
||||
:root {
|
||||
--font-display: 'Outfit', 'Inter', sans-serif;
|
||||
--font-sans: 'Inter', sans-serif;
|
||||
|
||||
/* Sleek harmonized dark palette */
|
||||
--color-bg: 15 23 42; /* slate-900 equivalent */
|
||||
--color-primary: 99 102 241; /* indigo-500 */
|
||||
--color-primary-rgb: 99, 102, 241;
|
||||
--color-accent: 139 92 246; /* violet-500 */
|
||||
--color-accent-rgb: 139, 92, 246;
|
||||
|
||||
--glass-bg: rgba(15, 23, 42, 0.45);
|
||||
--glass-border: rgba(255, 255, 255, 0.05);
|
||||
--glass-border-hover: rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
|
||||
/* Base resets & styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: rgb(10, 15, 30);
|
||||
color: #e2e8f0;
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.font-display {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
/* Custom Webkit scrollbar for sleek premium look */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgb(10, 15, 30);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
/* Typography elements styling */
|
||||
h1, h2, h3, h4 {
|
||||
font-family: var(--font-display);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Header & Navigation styling */
|
||||
.nav-tab {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #64748b; /* slate-500 */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-tab:hover {
|
||||
color: #cbd5e1; /* slate-300 */
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
background-color: rgba(99, 102, 241, 0.15);
|
||||
color: #818cf8; /* indigo-400 */
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
|
||||
/* Pills & Filter chips */
|
||||
.filter-pill {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px border rgba(255, 255, 255, 0.04);
|
||||
color: #94a3b8; /* slate-400 */
|
||||
padding: 8px 18px;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.filter-pill:hover {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
border-color: rgba(99, 102, 241, 0.2);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.filter-pill.active {
|
||||
background: gradient-to-r from-indigo-600 to-violet-600;
|
||||
background: linear-gradient(135deg, rgb(79, 70, 229), rgb(124, 58, 237));
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
|
||||
/* Glassmorphism Benefit Cards styling */
|
||||
.benefit-card {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 24px;
|
||||
padding: 24px;
|
||||
backdrop-filter: blur(12px);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.benefit-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(800px circle at var(--mouse-x, 0) var(--mouse-y, 0), rgba(255, 255, 255, 0.03), transparent 40%);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.benefit-card:hover {
|
||||
transform: translateY(-5px);
|
||||
border-color: var(--glass-border-hover);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3), 0 0 20px rgba(99, 102, 241, 0.05);
|
||||
}
|
||||
|
||||
/* Laser scan animation for voucher validation page */
|
||||
@keyframes scan {
|
||||
0%, 100% {
|
||||
top: 0%;
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
top: 98%;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-scan {
|
||||
animation: scan 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Spacing & Layout Utilities */
|
||||
.p-1 { padding: 0.25rem !important; }
|
||||
.p-4 { padding: 1rem !important; }
|
||||
.p-6 { padding: 1.5rem !important; }
|
||||
.p-8 { padding: 2rem !important; }
|
||||
.px-4 { padding-left: 1rem !important; padding-right: 1rem !important; }
|
||||
.px-5 { padding-left: 1.25rem !important; padding-right: 1.25rem !important; }
|
||||
.px-6 { padding-left: 1.5rem !important; padding-right: 1.5rem !important; }
|
||||
.py-2 { padding-top: 0.5rem !important; padding-bottom: 0.5rem !important; }
|
||||
.py-2\.5 { padding-top: 0.625rem !important; padding-bottom: 0.625rem !important; }
|
||||
.py-3 { padding-top: 0.75rem !important; padding-bottom: 0.75rem !important; }
|
||||
.py-4 { padding-top: 1rem !important; padding-bottom: 1rem !important; }
|
||||
.py-8 { padding-top: 2rem !important; padding-bottom: 2rem !important; }
|
||||
.py-12 { padding-top: 3rem !important; padding-bottom: 3rem !important; }
|
||||
.py-20 { padding-top: 5rem !important; padding-bottom: 5rem !important; }
|
||||
.pl-4 { padding-left: 1rem !important; }
|
||||
.pl-11 { padding-left: 2.75rem !important; }
|
||||
.pr-4 { padding-right: 1rem !important; }
|
||||
|
||||
/* Display & Flexbox */
|
||||
.flex { display: flex !important; }
|
||||
.flex-col { flex-direction: column !important; }
|
||||
.items-center { align-items: center !important; }
|
||||
.justify-between { justify-content: space-between !important; }
|
||||
.justify-center { justify-content: center !important; }
|
||||
.flex-wrap { flex-wrap: wrap !important; }
|
||||
.flex-1 { flex: 1 1 0% !important; }
|
||||
|
||||
/* Gap Utilities */
|
||||
.gap-1 { gap: 0.25rem !important; }
|
||||
.gap-2 { gap: 0.5rem !important; }
|
||||
.gap-3 { gap: 0.75rem !important; }
|
||||
.gap-4 { gap: 1rem !important; }
|
||||
.gap-6 { gap: 1.5rem !important; }
|
||||
|
||||
/* Grid Utilities */
|
||||
.grid { display: grid !important; }
|
||||
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)) !important; }
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
|
||||
.md\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)) !important; }
|
||||
.md\:flex-row { flex-direction: row !important; }
|
||||
.md\:text-left { text-align: left !important; }
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.lg\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)) !important; }
|
||||
.lg\:grid-cols-12 { grid-template-columns: repeat(12, minmax(0, 1fr)) !important; }
|
||||
.lg\:col-span-4 { grid-column: span 4 / span 4 !important; }
|
||||
.lg\:col-span-8 { grid-column: span 8 / span 8 !important; }
|
||||
}
|
||||
|
||||
/* Sizing Utilities */
|
||||
.w-full { width: 100% !important; }
|
||||
.max-w-7xl { max-width: 80rem !important; }
|
||||
.max-w-2xl { max-width: 42rem !important; }
|
||||
.max-w-md { max-width: 28rem !important; }
|
||||
.mx-auto { margin-left: auto !important; margin-right: auto !important; }
|
||||
|
||||
.w-3\.5 { width: 0.875rem !important; }
|
||||
.h-3\.5 { height: 0.875rem !important; }
|
||||
.w-4 { width: 1rem !important; }
|
||||
.h-4 { height: 1rem !important; }
|
||||
.w-8 { width: 2rem !important; }
|
||||
.h-8 { height: 2rem !important; }
|
||||
.w-10 { width: 2.5rem !important; }
|
||||
.h-10 { height: 2.5rem !important; }
|
||||
.w-12 { width: 3rem !important; }
|
||||
.h-12 { height: 3rem !important; }
|
||||
.w-40 { width: 10rem !important; }
|
||||
.h-40 { height: 10rem !important; }
|
||||
.w-48 { width: 12rem !important; }
|
||||
.h-48 { height: 12rem !important; }
|
||||
.w-96 { width: 24rem !important; }
|
||||
.h-96 { height: 24rem !important; }
|
||||
.w-\[500px\] { width: 500px !important; }
|
||||
.h-\[500px\] { height: 500px !important; }
|
||||
|
||||
/* Borders & Styles */
|
||||
.border { border: 1px solid rgba(255, 255, 255, 0.08) !important; }
|
||||
.border-b { border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important; }
|
||||
.border-slate-800 { border-color: rgba(30, 41, 59, 0.6) !important; }
|
||||
.border-slate-800\/50 { border-color: rgba(30, 41, 59, 0.4) !important; }
|
||||
.border-slate-800\/80 { border-color: rgba(30, 41, 59, 0.8) !important; }
|
||||
.border-slate-900\/80 { border-color: rgba(15, 23, 42, 0.8) !important; }
|
||||
.border-slate-900\/20 { border-color: rgba(15, 23, 42, 0.2) !important; }
|
||||
|
||||
/* Border Radius */
|
||||
.rounded-lg { border-radius: 0.5rem !important; }
|
||||
.rounded-xl { border-radius: 0.75rem !important; }
|
||||
.rounded-2xl { border-radius: 1rem !important; }
|
||||
.rounded-3xl { border-radius: 1.5rem !important; }
|
||||
|
||||
/* Typography & Colors */
|
||||
.text-center { text-align: center !important; }
|
||||
.text-slate-400 { color: #94a3b8 !important; }
|
||||
.text-slate-500 { color: #64748b !important; }
|
||||
.text-slate-600 { color: #475569 !important; }
|
||||
.text-slate-100 { color: #f1f5f9 !important; }
|
||||
.text-white { color: #ffffff !important; }
|
||||
.text-indigo-400 { color: #818cf8 !important; }
|
||||
.text-emerald-400 { color: #34d399 !important; }
|
||||
.font-bold { font-weight: 700 !important; }
|
||||
.font-semibold { font-weight: 600 !important; }
|
||||
.font-black { font-weight: 900 !important; }
|
||||
|
||||
.text-xs { font-size: 0.75rem !important; }
|
||||
.text-sm { font-size: 0.875rem !important; }
|
||||
.text-base { font-size: 1rem !important; }
|
||||
.text-lg { font-size: 1.125rem !important; }
|
||||
.text-xl { font-size: 1.25rem !important; }
|
||||
.text-3xl { font-size: 1.875rem !important; }
|
||||
.text-4xl { font-size: 2.25rem !important; }
|
||||
|
||||
.uppercase { text-transform: uppercase !important; }
|
||||
.tracking-wide { letter-spacing: 0.025em !important; }
|
||||
.tracking-wider { letter-spacing: 0.05em !important; }
|
||||
.tracking-widest { letter-spacing: 0.1em !important; }
|
||||
.leading-none { line-height: 1 !important; }
|
||||
.leading-relaxed { line-height: 1.625 !important; }
|
||||
.leading-snug { line-height: 1.375 !important; }
|
||||
|
||||
/* Transitions & Shadows */
|
||||
.transition-all { transition-property: all !important; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important; transition-duration: 150ms !important; }
|
||||
.duration-300 { transition-duration: 300ms !important; }
|
||||
.shadow-lg { box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.05) !important; }
|
||||
.shadow-2xl { box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5) !important; }
|
||||
|
||||
/* Semantic Classes */
|
||||
.brand-subtitle {
|
||||
font-size: 9px !important;
|
||||
letter-spacing: 0.25em !important;
|
||||
}
|
||||
.trend-pct {
|
||||
font-size: 10px !important;
|
||||
}
|
||||
.shadow-logo {
|
||||
box-shadow: 0 10px 15px -3px rgba(99, 102, 241, 0.2), 0 4px 6px -2px rgba(99, 102, 241, 0.1) !important;
|
||||
}
|
||||
.shadow-button {
|
||||
box-shadow: 0 10px 15px -3px rgba(99, 102, 241, 0.25), 0 4px 6px -2px rgba(99, 102, 241, 0.15) !important;
|
||||
}
|
||||
|
||||
.text-9px { font-size: 9px !important; }
|
||||
.text-10px { font-size: 10px !important; }
|
||||
.letter-spacing-25 { letter-spacing: 0.25em !important; }
|
||||
|
||||
.selection\:bg-indigo-500\/30::selection { background-color: rgba(99, 102, 241, 0.3); }
|
||||
.selection\:text-indigo-200::selection { color: #c7d2fe; }
|
||||
|
||||
.fixed { position: fixed !important; }
|
||||
.top-0 { top: 0 !important; }
|
||||
.sticky { position: sticky !important; }
|
||||
.z-40 { z-index: 40 !important; }
|
||||
.backdrop-blur-md { backdrop-filter: blur(12px) !important; -webkit-backdrop-filter: blur(12px) !important; }
|
||||
.backdrop-blur-xl { backdrop-filter: blur(24px) !important; -webkit-backdrop-filter: blur(24px) !important; }
|
||||
.pointer-events-none { pointer-events: none !important; }
|
||||
.overflow-x-hidden { overflow-x: hidden !important; }
|
||||
.relative { position: relative !important; }
|
||||
.absolute { position: absolute !important; }
|
||||
.inset-y-0 { top: 0; bottom: 0; }
|
||||
.left-0 { left: 0 !important; }
|
||||
.right-0 { right: 0 !important; }
|
||||
.pl-4 { padding-left: 1rem !important; }
|
||||
.object-contain { object-fit: contain !important; }
|
||||
.bg-slate-950 { background-color: rgb(2, 6, 23) !important; }
|
||||
.bg-slate-950\/60 { background-color: rgba(2, 6, 23, 0.6) !important; }
|
||||
.bg-slate-950\/80 { background-color: rgba(2, 6, 23, 0.8) !important; }
|
||||
.bg-slate-900\/40 { background-color: rgba(15, 23, 42, 0.4) !important; }
|
||||
.bg-slate-900\/60 { background-color: rgba(15, 23, 42, 0.6) !important; }
|
||||
.bg-slate-900\/35 { background-color: rgba(15, 23, 42, 0.35) !important; }
|
||||
@@ -0,0 +1,37 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name clube67.com www.clube67.com newwhats.clube67.com;
|
||||
|
||||
# Limite de upload para mídias do WhatsApp
|
||||
client_max_body_size 100M;
|
||||
|
||||
# Resolver DNS interno do Docker para resolução dinâmica
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
|
||||
# Proxy para o Socket.io (Realtime Events)
|
||||
location /socket.io/ {
|
||||
set $backend_upstream http://newwhats-backend:8008;
|
||||
proxy_pass $backend_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Proxy para tudo (Frontend Estático & API Express)
|
||||
location / {
|
||||
set $backend_upstream http://newwhats-backend:8008;
|
||||
proxy_pass $backend_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user