feat: inicializar repositorio oficial do clube67.com
This commit is contained in:
@@ -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',
|
||||
}),
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user