feat: inicializar repositorio oficial do clube67.com

This commit is contained in:
VPS 4 Builder
2026-05-18 18:32:55 +02:00
commit a7fd18eb55
96 changed files with 17059 additions and 0 deletions
+23
View File
@@ -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;
+42
View File
@@ -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'),
}
};
+16
View File
@@ -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',
},
};
+3
View File
@@ -0,0 +1,3 @@
import { logger } from '../utils/logger';
export { logger };
export default logger;
+13
View File
@@ -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));
+155
View File
@@ -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' });
}
}
+120
View File
@@ -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' });
}
}
+82
View File
@@ -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);
}
+69
View File
@@ -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();
+44
View File
@@ -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');
}
}
};
+719
View File
@@ -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' });
}
});
}
+120
View File
@@ -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();
+6
View File
@@ -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 });
+120
View File
@@ -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');
}
+100
View File
@@ -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);
});
+34
View File
@@ -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();
+49
View File
@@ -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' });
}
}
+30
View File
@@ -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();
};
}
+41
View File
@@ -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);
}
+18
View File
@@ -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();
};
+24
View File
@@ -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()}`
}
+678
View File
@@ -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;
+11
View File
@@ -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;
+14
View File
@@ -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;
+11
View File
@@ -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;
+12
View File
@@ -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;
+29
View File
@@ -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;
+16
View File
@@ -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;
+22
View File
@@ -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;
+24
View File
@@ -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;
+2
View File
@@ -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');
}
}
+13
View File
@@ -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) { },
};
+16
View File
@@ -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;
}
}
+16
View File
@@ -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);
}
}
+32
View File
@@ -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',
}),
],
});