feat: sincronização de desenvolvimento local da VPS 4 para o Gitea

This commit is contained in:
VPS 4 Deploy Agent
2026-05-10 07:38:54 +02:00
parent 621fbfba64
commit 3396fa0598
402 changed files with 234697 additions and 0 deletions
@@ -0,0 +1,63 @@
import sharp from 'sharp';
export interface OptimizationResult {
buffer: Buffer;
mimetype: string;
}
export class ImageOptimizer {
/**
* Optimizes an image: converts to WebP, strips metadata,
* and applies a 1:1 Smart Crop if needed.
*/
static async optimize(
buffer: Buffer,
category: string = 'media',
options: { aspect?: string; size?: number } = {}
): Promise<OptimizationResult> {
try {
let pipeline = sharp(buffer);
const metadata = await pipeline.metadata();
if (!metadata.format) return { buffer, mimetype: 'application/octet-stream' };
// 1. Identify Target Size
// Default sizes based on category
let targetSize = options.size || 1024;
if (category === 'partners' || category === 'users') targetSize = 400;
if (category === 'benefits') targetSize = 800;
// 2. Smart Crop 1:1 if aspect=1:1 is requested or implied by category
const shouldCrop = options.aspect === '1:1' || ['partners', 'users'].includes(category);
if (shouldCrop) {
pipeline = pipeline.resize({
width: targetSize,
height: targetSize,
fit: sharp.fit.cover,
position: sharp.strategy.entropy // Smart focal point detection
});
} else {
// Resize without cropping if it's too large
pipeline = pipeline.resize({
width: targetSize,
withoutEnlargement: true,
fit: sharp.fit.inside
});
}
// 3. Convert to WebP & Strip Metadata
const optimizedBuffer = await pipeline
.webp({ quality: 85, effort: 4 })
.toBuffer();
return {
buffer: optimizedBuffer,
mimetype: 'image/webp'
};
} catch (err) {
console.error('[SmartVision] Optimization failed:', err);
return { buffer, mimetype: 'image/jpeg' }; // Fallback
}
}
}
@@ -0,0 +1,35 @@
import { PluginInstance, PluginContext } from '../../backend/src/core/types';
import { ImageOptimizer } from './backend/Optimizer';
import manifest from './manifest.json';
const plugin: PluginInstance = {
manifest: manifest as any,
async activate(ctx: PluginContext): Promise<void> {
// Register for the upload:transform hook
ctx.hooks.register('upload:transform', async (payload: any) => {
const { buffer, mimetype, category } = payload;
// Only process common image formats
const imageTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/avif'];
if (!imageTypes.includes(mimetype)) {
return null; // Skip non-images
}
ctx.logger.info(`[SmartVision] Optimizing image for category: ${category}`);
const result = await ImageOptimizer.optimize(buffer, category);
return {
buffer: result.buffer,
mimetype: result.mimetype
};
});
ctx.logger.info('SmartVision Optimizer activated');
},
async deactivate(ctx: PluginContext): Promise<void> {
ctx.logger.info('SmartVision Optimizer deactivated');
}
};
export default plugin;
@@ -0,0 +1,19 @@
{
"name": "SmartVision",
"displayName": "SmartVision Optimizer",
"version": "1.0.0",
"description": "Otimização inteligente de imagens com WebP e Smart Crop 1:1.",
"author": "Clube67 Team",
"category": "integration",
"enabled": true,
"canDisable": true,
"dependencies": [
"uploads"
],
"hooks": {
"subscribes": [
"upload:transform"
],
"emits": []
}
}
@@ -0,0 +1,296 @@
import { S3Client, PutObjectCommand, GetObjectCommand, HeadBucketCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import { PluginInstance, PluginContext } from '../../backend/src/core/types';
import { storageProvider, StorageUploadPayload, StorageProviderInterface } from '../../backend/src/core/StorageProvider';
import { pluginConfig } from '../../backend/src/core/plugin-config';
import sharp from 'sharp';
import fs from 'fs';
import path from 'path';
const VALID_CATEGORIES = ['cdi', 'xrays', 'documents', 'exports', 'posts', 'cards', 'partners', 'whatsapp', 'media'];
class UnifiedStorageProviderPlugin implements PluginInstance, StorageProviderInterface {
manifest: any;
private s3: S3Client | null = null;
private bucket: string = '';
private prefix: string = '';
private localFallbackDir: string = path.resolve(process.cwd(), 'storage', 'local_fallback');
private ctx!: PluginContext;
/** Activation logic */
async activate(ctx: PluginContext): Promise<void> {
this.ctx = ctx;
const storedConfig = pluginConfig.get('UnifiedStorageProvider') || {};
// Ensure local fallback directory exists
if (!fs.existsSync(this.localFallbackDir)) {
fs.mkdirSync(this.localFallbackDir, { recursive: true });
}
const accessKey = storedConfig.wasabiAccessKey || process.env.WASABI_ACCESS_KEY;
const secretKey = storedConfig.wasabiSecretKey || process.env.WASABI_SECRET_KEY;
const region = storedConfig.wasabiRegion || process.env.WASABI_REGION || 'us-east-1';
this.bucket = storedConfig.wasabiBucket || process.env.WASABI_BUCKET || '';
this.prefix = storedConfig.wasabiBucketPrefix || process.env.WASABI_BUCKET_PREFIX || '';
const rawEndpoint = storedConfig.wasabiEndpoint || process.env.WASABI_ENDPOINT || 's3.wasabisys.com';
const endpoint = rawEndpoint.startsWith('http') ? rawEndpoint : `https://${rawEndpoint}`;
if (!storedConfig['category_bucket_whatsapp']) {
storedConfig['category_bucket_whatsapp'] = this.bucket || 'clube67-storage';
pluginConfig.set('UnifiedStorageProvider', storedConfig);
}
if (!accessKey || !secretKey || !this.bucket) {
ctx.logger.error('Wasabi credentials or bucket missing in .env or plugin config');
} else {
this.s3 = new S3Client({
region,
endpoint,
credentials: {
accessKeyId: accessKey,
secretAccessKey: secretKey,
},
forcePathStyle: true,
});
storageProvider.register(this);
ctx.logger.info(`UnifiedStorageProvider activated (Endpoint: ${endpoint})`);
// start the background worker
this.startSyncWorker();
}
// --- Proxy Route ---
ctx.app.get('/api/storage/view/*', async (req, res) => {
const filePath = req.params[0];
if (!filePath) return res.status(400).send('Path missing');
try {
// 1. Check local fallback
const localPath = path.join(this.localFallbackDir, filePath);
if (fs.existsSync(localPath)) {
const data = fs.readFileSync(localPath);
const ext = filePath.split('.').pop()?.toLowerCase();
const mimeTypes: any = { 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'webp': 'image/webp', 'pdf': 'application/pdf' };
res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream');
res.setHeader('X-Storage-Source', 'local_fallback');
res.setHeader('Cache-Control', 'public, max-age=31536000');
return res.send(data);
}
// 2. Wasabi Logic
if (!this.s3 || !this.bucket) return res.status(500).send('Storage not initialized');
const category = filePath.split('/')[0];
const storedConfig = pluginConfig.get('UnifiedStorageProvider') || {};
const mappedBucket = storedConfig[`category_bucket_${category}`] || this.bucket;
const legacyBucket = (category === 'whatsapp') ? 'clube67-whatsapp-v1' : null;
const tryFetch = async (bucket: string) => {
return this.s3!.send(new GetObjectCommand({ Bucket: bucket, Key: filePath }));
};
let data;
try {
data = await tryFetch(mappedBucket);
} catch (e: any) {
if (legacyBucket && legacyBucket !== mappedBucket) {
try { data = await tryFetch(legacyBucket); } catch (e2) { throw e; }
} else { throw e; }
}
const ext = filePath.split('.').pop()?.toLowerCase();
const mimeTypes: any = { 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp', 'svg': 'image/svg+xml', 'pdf': 'application/pdf' };
res.setHeader('Content-Type', data.ContentType || mimeTypes[ext] || 'application/octet-stream');
res.setHeader('X-Storage-Source', 'wasabi');
res.setHeader('Cache-Control', 'public, max-age=31536000');
// In v3, Body has utility methods to convert the stream
if (data.Body) {
const bytes = await (data.Body as any).transformToByteArray();
res.send(Buffer.from(bytes));
} else {
res.status(404).send('Body empty');
}
} catch (err: any) {
res.status(404).send('File not found');
}
});
// --- Admin/Fetch Routes ---
ctx.app.post('/api/storage/list-files', async (req, res) => {
const { accessKey, secretKey, bucket, prefix, region, endpoint } = req.body;
if (!accessKey || !secretKey || !bucket) return res.status(400).json({ error: 'Params missing' });
try {
const s3 = new S3Client({
region: region || 'us-east-1',
endpoint: endpoint || 'https://s3.wasabisys.com',
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
forcePathStyle: true
});
const data = await s3.send(new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix || '', MaxKeys: 20 }));
const files = data.Contents?.map(item => ({ key: item.Key, size: item.Size, lastModified: item.LastModified })) || [];
res.json({ files });
} catch (err: any) { res.status(500).json({ error: err.message }); }
});
setInterval(() => { storageProvider.updateHealth(); }, 30000);
}
async deactivate(ctx: PluginContext): Promise<void> { ctx.logger.info('UnifiedStorageProvider deactivated'); }
async checkHealth(): Promise<boolean> {
if (!this.s3 || !this.bucket) return false;
try { await this.s3.send(new HeadBucketCommand({ Bucket: this.bucket })); return true; }
catch (err) { return false; }
}
async upload(payload: StorageUploadPayload): Promise<{ provider: string; path: string }> {
const storedConfig = pluginConfig.get('UnifiedStorageProvider') || {};
const { category, file } = payload;
if (!VALID_CATEGORIES.includes(category)) throw new Error(`Invalid category: ${category}`);
let buffer = file.buffer;
let originalName = file.originalname;
let mimetype = file.mimetype;
if (mimetype.startsWith('image/') && buffer.length > 150 * 1024) {
try {
this.ctx.logger.info(`[Optimizer] Processing ${originalName}`);
const optimized = await sharp(buffer)
.resize(1200, 1200, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 75 })
.toBuffer();
buffer = optimized;
mimetype = 'image/webp';
originalName = originalName.replace(/\.[^.]+$/, '.webp');
} catch (err: any) { this.ctx.logger.warn(`[Optimizer] Failed: ${err.message}`); }
}
const fileName = `${Date.now()}-${originalName.replace(/[^a-zA-Z0-9.-]/g, '_')}`;
const relativePath = `${category}/${fileName}`;
try {
if (!this.s3 || !this.bucket) throw new Error('S3 not initialized');
const targetBucket = storedConfig[`category_bucket_${category}`] || this.bucket;
const parallelUploads3 = new Upload({
client: this.s3,
params: {
Bucket: targetBucket,
Key: relativePath,
Body: buffer,
ContentType: mimetype,
ACL: 'public-read'
},
});
await parallelUploads3.done();
return { provider: 'wasabi', path: relativePath };
} catch (err: any) {
this.ctx.logger.error(`[Wasabi] Fallback to LOCAL: ${err.message}`);
// Log fallback attempt
try {
await this.ctx.db('janitor_logs').insert({
filename: originalName,
category: category,
status: 'fallback',
message: `Erro Wasabi: ${err.message.substring(0, 100)}`
});
} catch (dbErr) { }
const localDest = path.join(this.localFallbackDir, category);
if (!fs.existsSync(localDest)) fs.mkdirSync(localDest, { recursive: true });
fs.writeFileSync(path.join(this.localFallbackDir, relativePath), buffer);
return { provider: 'local_fallback', path: relativePath };
}
}
private startSyncWorker() {
setInterval(async () => {
if (!this.s3 || !this.bucket) return;
const scanFolders = [
{ path: this.localFallbackDir, isCategorized: true },
{ path: path.resolve(process.cwd(), 'uploads'), isCategorized: false, category: 'media' }
];
for (const folder of scanFolders) {
try {
if (!fs.existsSync(folder.path)) continue;
if (folder.isCategorized) {
const categories = fs.readdirSync(folder.path);
for (const cat of categories) {
const catDir = path.join(folder.path, cat);
if (!fs.statSync(catDir).isDirectory()) continue;
await this.syncFolder(catDir, cat);
}
} else if (folder.category) {
await this.syncFolder(folder.path, folder.category);
}
} catch (err: any) { }
}
}, 120000);
}
private async syncFolder(dir: string, category: string) {
if (!this.s3 || !this.bucket) return;
try {
const files = fs.readdirSync(dir);
if (files.length === 0) return;
for (const file of files) {
const localPath = path.join(dir, file);
if (!fs.existsSync(localPath) || fs.statSync(localPath).isDirectory()) continue;
try {
// Log step 1: Wasabi
const logId = await this.ctx.db('janitor_logs').insert({
filename: file,
category: category,
status: 'wasabi',
message: 'Sincronizando com Wasabi...'
});
const buffer = fs.readFileSync(localPath);
const targetBucket = pluginConfig.get('UnifiedStorageProvider')?.[`category_bucket_${category}`] || this.bucket;
const parallelUploads3 = new Upload({
client: this.s3,
params: {
Bucket: targetBucket,
Key: `${category}/${file}`,
Body: buffer
},
});
await parallelUploads3.done();
// Log step 2: Deleting VPS
await this.ctx.db('janitor_logs').where('id', logId[0]).update({
status: 'vps_delete',
message: 'Wasabi OK, excluindo do servidor...'
});
fs.unlinkSync(localPath);
// Log step 3: Fim
await this.ctx.db('janitor_logs').where('id', logId[0]).update({
status: 'fim',
message: 'Limpeza concluída com sucesso.'
});
} catch (err: any) {
this.ctx.logger.error(`[Janitor] Sync failed for ${file}: ${err.message}`);
await this.ctx.db('janitor_logs').insert({
filename: file,
category: category,
status: 'erro',
message: `Falha: ${err.message.substring(0, 100)}`
});
}
}
} catch (err: any) { }
}
}
const instance = new UnifiedStorageProviderPlugin();
export default instance;
@@ -0,0 +1,15 @@
{
"name": "UnifiedStorageProvider",
"displayName": "Unified Storage Provider",
"version": "1.0.0",
"description": "Central storage provider using Wasabi (S3 compatible)",
"author": "Clube67",
"category": "core",
"enabled": true,
"canDisable": false,
"dependencies": [],
"backend": {
"routePrefix": "/api/storage",
"hasMigrations": false
}
}
@@ -0,0 +1,148 @@
"use strict";
// ============================================================
// Plugin: core-auth — Routes
// ============================================================
// Migrated from: backend/src/routes/auth.routes.ts
// backend/src/controllers/auth.controller.ts
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createAuthRoutes = createAuthRoutes;
const express_1 = require("express");
const bcryptjs_1 = __importDefault(require("bcryptjs"));
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
function createAuthRoutes(ctx) {
const router = (0, express_1.Router)();
const { db, hooks } = ctx;
// ── POST /login ───────────────────────────────────────
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email e senha são obrigatórios' });
}
const user = await db('users').where({ email }).first();
if (!user) {
return res.status(401).json({ error: 'Credenciais inválidas' });
}
const isValid = await bcryptjs_1.default.compare(password, user.password_hash);
if (!isValid) {
return res.status(401).json({ error: 'Credenciais inválidas' });
}
const accessToken = jsonwebtoken_1.default.sign({ id: user.id, email: user.email, role: user.role }, process.env.JWT_SECRET, { expiresIn: (process.env.JWT_EXPIRES_IN || '15m') });
const refreshToken = jsonwebtoken_1.default.sign({ id: user.id, type: 'refresh' }, process.env.JWT_REFRESH_SECRET, { expiresIn: (process.env.JWT_REFRESH_EXPIRES_IN || '7d') });
// Emit hook event
await hooks.emit('user:login', { userId: user.id, ip: req.ip });
// Audit log
await db('audit_log').insert({
user_id: user.id,
action: 'LOGIN',
entity_type: 'user',
entity_id: user.id,
details: 'Usuário logou no sistema',
ip_address: req.ip,
}).catch(() => { }); // non-critical
const { password_hash, ...safeUser } = user;
res.json({ user: safeUser, accessToken, refreshToken });
}
catch (err) {
ctx.logger.error(`Login error: ${err.message}`);
res.status(500).json({ error: 'Erro interno no servidor' });
}
});
// ── POST /register ────────────────────────────────────
router.post('/register', async (req, res) => {
try {
const { name, email, password, cpf, whatsapp, city, neighborhood, state } = req.body;
if (!name || !email || !password) {
return res.status(400).json({ error: 'Nome, email e senha são obrigatórios' });
}
const existing = await db('users').where({ email }).first();
if (existing) {
return res.status(409).json({ error: 'Email já cadastrado' });
}
const password_hash = await bcryptjs_1.default.hash(password, 12);
const [id] = await db('users').insert({
name,
email,
password_hash,
cpf: cpf || null,
whatsapp: whatsapp || null,
city: city || null,
neighborhood: neighborhood || null,
state: state || null,
role: 'user',
status: 'active',
});
await hooks.emit('user:register', { userId: id, email });
res.status(201).json({ id, name, email, role: 'user' });
}
catch (err) {
ctx.logger.error(`Register error: ${err.message}`);
res.status(500).json({ error: 'Erro interno no servidor' });
}
});
// ── GET /me ───────────────────────────────────────────
router.get('/me', async (req, res) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Token não fornecido' });
}
const token = authHeader.split(' ')[1];
const payload = jsonwebtoken_1.default.verify(token, process.env.JWT_SECRET);
const user = await db('users')
.where({ id: payload.id })
.select('id', 'name', 'email', 'role', 'status', 'whatsapp', 'cpf', 'city', 'neighborhood', 'state', 'partner_id', 'created_at')
.first();
if (!user) {
return res.status(404).json({ error: 'Usuário não encontrado' });
}
let partnerConsent = false;
let partnerConsentAt = null;
let partnerConsentIp = null;
if (user?.partner_id) {
const partner = await db('partners')
.where({ id: user.partner_id })
.select('data_consent', 'consent_at', 'consent_ip')
.first();
partnerConsent = Boolean(partner?.data_consent);
partnerConsentAt = partner?.consent_at || null;
partnerConsentIp = partner?.consent_ip || null;
}
res.json({ ...user, partnerConsent, partnerConsentAt, partnerConsentIp });
}
catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expirado' });
}
res.status(401).json({ error: 'Token inválido' });
}
});
// ── POST /refresh ─────────────────────────────────────
router.post('/refresh', async (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({ error: 'Refresh token obrigatório' });
}
const payload = jsonwebtoken_1.default.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
if (payload.type !== 'refresh') {
return res.status(401).json({ error: 'Token inválido' });
}
const user = await db('users').where({ id: payload.id }).first();
if (!user) {
return res.status(404).json({ error: 'Usuário não encontrado' });
}
const newAccessToken = jsonwebtoken_1.default.sign({ id: user.id, email: user.email, role: user.role }, process.env.JWT_SECRET, { expiresIn: (process.env.JWT_EXPIRES_IN || '15m') });
await hooks.emit('token:refresh', { userId: user.id });
res.json({ accessToken: newAccessToken });
}
catch (err) {
res.status(401).json({ error: 'Refresh token inválido ou expirado' });
}
});
return router;
}
//# sourceMappingURL=routes.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,180 @@
// ============================================================
// Plugin: core-auth — Routes
// ============================================================
// Migrated from: backend/src/routes/auth.routes.ts
// backend/src/controllers/auth.controller.ts
import { Router, Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { PluginContext } from '../../../backend/src/core/types';
export function createAuthRoutes(ctx: PluginContext): Router {
const router = Router();
const { db, hooks } = ctx;
// ── POST /login ───────────────────────────────────────
router.post('/login', async (req: Request, res: Response) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email e senha são obrigatórios' });
}
const user = await db('users').where({ email }).first();
if (!user) {
return res.status(401).json({ error: 'Credenciais inválidas' });
}
const isValid = await bcrypt.compare(password, user.password_hash);
if (!isValid) {
return res.status(401).json({ error: 'Credenciais inválidas' });
}
const accessToken = jwt.sign(
{ id: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET!,
{ expiresIn: (process.env.JWT_EXPIRES_IN || '15m') as any }
);
const refreshToken = jwt.sign(
{ id: user.id, type: 'refresh' },
process.env.JWT_REFRESH_SECRET!,
{ expiresIn: (process.env.JWT_REFRESH_EXPIRES_IN || '7d') as any }
);
// Emit hook event
await hooks.emit('user:login', { userId: user.id, ip: req.ip });
// Audit log
await db('audit_log').insert({
user_id: user.id,
action: 'LOGIN',
entity_type: 'user',
entity_id: user.id,
details: 'Usuário logou no sistema',
ip_address: req.ip,
}).catch(() => { }); // non-critical
const { password_hash, ...safeUser } = user;
res.json({ user: safeUser, accessToken, refreshToken });
} catch (err: any) {
ctx.logger.error(`Login error: ${err.message}`);
res.status(500).json({ error: 'Erro interno no servidor' });
}
});
// ── POST /register ────────────────────────────────────
router.post('/register', async (req: Request, res: Response) => {
try {
const { name, email, password, cpf, whatsapp, city, neighborhood, state } = req.body;
if (!name || !email || !password) {
return res.status(400).json({ error: 'Nome, email e senha são obrigatórios' });
}
const existing = await db('users').where({ email }).first();
if (existing) {
return res.status(409).json({ error: 'Email já cadastrado' });
}
const password_hash = await bcrypt.hash(password, 12);
const [id] = await db('users').insert({
name,
email,
password_hash,
cpf: cpf || null,
whatsapp: whatsapp || null,
city: city || null,
neighborhood: neighborhood || null,
state: state || null,
role: 'user',
status: 'active',
});
await hooks.emit('user:register', { userId: id, email });
res.status(201).json({ id, name, email, role: 'user' });
} catch (err: any) {
ctx.logger.error(`Register error: ${err.message}`);
res.status(500).json({ error: 'Erro interno no servidor' });
}
});
// ── GET /me ───────────────────────────────────────────
router.get('/me', async (req: Request, res: Response) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Token não fornecido' });
}
const token = authHeader.split(' ')[1];
const payload = jwt.verify(token, process.env.JWT_SECRET!) as any;
const user = await db('users')
.where({ id: payload.id })
.select('id', 'name', 'email', 'role', 'status', 'whatsapp', 'cpf', 'city', 'neighborhood', 'state', 'partner_id', 'created_at')
.first();
if (!user) {
return res.status(404).json({ error: 'Usuário não encontrado' });
}
let partnerConsent = false;
let partnerConsentAt = null;
let partnerConsentIp = null;
if (user?.partner_id) {
const partner = await db('partners')
.where({ id: user.partner_id })
.select('data_consent', 'consent_at', 'consent_ip')
.first();
partnerConsent = Boolean(partner?.data_consent);
partnerConsentAt = partner?.consent_at || null;
partnerConsentIp = partner?.consent_ip || null;
}
res.json({ ...user, partnerConsent, partnerConsentAt, partnerConsentIp });
} catch (err: any) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expirado' });
}
res.status(401).json({ error: 'Token inválido' });
}
});
// ── POST /refresh ─────────────────────────────────────
router.post('/refresh', async (req: Request, res: Response) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({ error: 'Refresh token obrigatório' });
}
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET!) as any;
if (payload.type !== 'refresh') {
return res.status(401).json({ error: 'Token inválido' });
}
const user = await db('users').where({ id: payload.id }).first();
if (!user) {
return res.status(404).json({ error: 'Usuário não encontrado' });
}
const newAccessToken = jwt.sign(
{ id: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET!,
{ expiresIn: (process.env.JWT_EXPIRES_IN || '15m') as any }
);
await hooks.emit('token:refresh', { userId: user.id });
res.json({ accessToken: newAccessToken });
} catch (err: any) {
res.status(401).json({ error: 'Refresh token inválido ou expirado' });
}
});
return router;
}
@@ -0,0 +1,23 @@
"use strict";
// ============================================================
// Plugin: core-auth — Entry Point
// ============================================================
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const routes_1 = require("./backend/routes");
const manifest_json_1 = __importDefault(require("./manifest.json"));
const plugin = {
manifest: manifest_json_1.default,
async activate(ctx) {
const router = (0, routes_1.createAuthRoutes)(ctx);
ctx.app.use('/api/auth', router);
ctx.logger.info('Auth routes registered at /api/auth');
},
async deactivate(ctx) {
ctx.logger.info('Auth plugin deactivated');
},
};
exports.default = plugin;
//# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../plugins/core-auth/index.ts"],"names":[],"mappings":";AAAA,+DAA+D;AAC/D,kCAAkC;AAClC,+DAA+D;;;;;AAG/D,6CAAoD;AACpD,oEAAuC;AAEvC,MAAM,MAAM,GAAmB;IAC3B,QAAQ,EAAE,uBAAe;IAEzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC7B,MAAM,MAAM,GAAG,IAAA,yBAAgB,EAAC,GAAG,CAAC,CAAC;QACrC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;QACjC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,qCAAqC,CAAC,CAAC;IAC3D,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,GAAkB;QAC/B,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,yBAAyB,CAAC,CAAC;IAC/C,CAAC;CACJ,CAAC;AAEF,kBAAe,MAAM,CAAC"}
@@ -0,0 +1,23 @@
// ============================================================
// Plugin: core-auth — Entry Point
// ============================================================
import { PluginInstance, PluginContext } from '../../backend/src/core/types';
import { createAuthRoutes } from './backend/routes';
import manifest from './manifest.json';
const plugin: PluginInstance = {
manifest: manifest as any,
async activate(ctx: PluginContext): Promise<void> {
const router = createAuthRoutes(ctx);
ctx.app.use('/api/auth', router);
ctx.logger.info('Auth routes registered at /api/auth');
},
async deactivate(ctx: PluginContext): Promise<void> {
ctx.logger.info('Auth plugin deactivated');
},
};
export default plugin;
@@ -0,0 +1,46 @@
{
"name": "core-auth",
"displayName": "Autenticação",
"version": "1.0.0",
"description": "Login, JWT, refresh tokens, sessões, Google OAuth e RBAC.",
"author": "Clube67",
"category": "core",
"enabled": true,
"canDisable": false,
"dependencies": [],
"backend": {
"routePrefix": "/api/auth",
"hasMigrations": true,
"globalMiddleware": [
"authenticate"
]
},
"frontend": {
"menuItems": [],
"pages": [
{
"path": "/login",
"component": "LoginPage",
"roles": [
"*"
]
},
{
"path": "/register",
"component": "RegisterPage",
"roles": [
"*"
]
}
]
},
"hooks": {
"subscribes": [],
"emits": [
"user:login",
"user:logout",
"user:register",
"token:refresh"
]
}
}
@@ -0,0 +1,117 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createFinanceRoutes = createFinanceRoutes;
const express_1 = require("express");
function createFinanceRoutes(ctx) {
const router = (0, express_1.Router)();
const { db, hooks } = ctx;
// GET /plans — List subscription plans
router.get('/plans', async (_req, res) => {
try {
const plans = await db('subscription_plans').orderBy('monthly_price', 'asc');
res.json({ plans });
}
catch (err) {
res.status(500).json({ error: 'Erro ao listar planos' });
}
});
// GET /invoices — List invoices (optional partner filter)
router.get('/invoices', async (req, res) => {
try {
const partnerId = req.query.partner_id;
const status = req.query.status;
let query = db('invoices')
.join('partners', 'invoices.partner_id', 'partners.id')
.select('invoices.*', 'partners.company_name');
if (partnerId)
query = query.where({ 'invoices.partner_id': partnerId });
if (status)
query = query.where({ 'invoices.status': status });
const invoices = await query.orderBy('invoices.due_date', 'desc');
res.json({ invoices });
}
catch (err) {
res.status(500).json({ error: 'Erro ao listar faturas' });
}
});
// POST /invoices — Create invoice
router.post('/invoices', async (req, res) => {
try {
const { partner_id, plan_id, amount, due_date } = req.body;
const [id] = await db('invoices').insert({
partner_id, plan_id, amount,
issue_date: db.fn.now(),
due_date,
status: 'Pendente',
});
await hooks.emit('invoice:created', { invoiceId: id, partnerId: partner_id });
const invoice = await db('invoices').where({ id }).first();
res.status(201).json(invoice);
}
catch (err) {
res.status(500).json({ error: 'Erro ao criar fatura' });
}
});
// PUT /invoices/:id/pay — Mark invoice as paid
router.put('/invoices/:id/pay', async (req, res) => {
try {
await db('invoices').where({ id: req.params.id }).update({
status: 'Paga',
paid_at: db.fn.now(),
});
await hooks.emit('invoice:paid', { invoiceId: req.params.id });
res.json({ message: 'Fatura paga com sucesso' });
}
catch (err) {
res.status(500).json({ error: 'Erro ao pagar fatura' });
}
});
// GET /dashboard — Financial overview
router.get('/dashboard', async (_req, res) => {
try {
const [totalRevenue] = await db('invoices')
.where({ status: 'Paga' })
.sum('amount as total');
const [pendingRevenue] = await db('invoices')
.where({ status: 'Pendente' })
.sum('amount as total');
const [overdueRevenue] = await db('invoices')
.where({ status: 'Atrasada' })
.sum('amount as total');
const [activeSubscriptions] = await db('partners')
.where({ status: 'active' })
.count('id as count');
const planDistribution = await db('partners')
.select('subscription_plan_id as plan')
.count('id as count')
.where({ status: 'active' })
.groupBy('subscription_plan_id');
res.json({
totalRevenue: Number(totalRevenue.total || 0),
pendingRevenue: Number(pendingRevenue.total || 0),
overdueRevenue: Number(overdueRevenue.total || 0),
activeSubscriptions: Number(activeSubscriptions.count),
planDistribution,
});
}
catch (err) {
res.status(500).json({ error: 'Erro ao buscar dashboard financeiro' });
}
});
// GET /commissions — List commissions
router.get('/commissions', async (req, res) => {
try {
const partnerId = req.query.partner_id;
let query = db('commissions');
if (partnerId)
query = query.where({ partner_id: partnerId });
const commissions = await query.orderBy('transaction_date', 'desc');
res.json({ commissions });
}
catch (err) {
res.status(500).json({ error: 'Erro ao listar comissões' });
}
});
return router;
}
//# sourceMappingURL=routes.js.map
@@ -0,0 +1 @@
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../../../../../plugins/financeiro/backend/routes.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,123 @@
import { Router, Request, Response } from 'express';
import { PluginContext } from '../../../backend/src/core/types';
export function createFinanceRoutes(ctx: PluginContext): Router {
const router = Router();
const { db, hooks } = ctx;
// GET /plans — List subscription plans
router.get('/plans', async (_req: Request, res: Response) => {
try {
const plans = await db('subscription_plans').orderBy('monthly_price', 'asc');
res.json({ plans });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao listar planos' });
}
});
// GET /invoices — List invoices (optional partner filter)
router.get('/invoices', async (req: Request, res: Response) => {
try {
const partnerId = req.query.partner_id as string;
const status = req.query.status as string;
let query = db('invoices')
.join('partners', 'invoices.partner_id', 'partners.id')
.select('invoices.*', 'partners.company_name');
if (partnerId) query = query.where({ 'invoices.partner_id': partnerId });
if (status) query = query.where({ 'invoices.status': status });
const invoices = await query.orderBy('invoices.due_date', 'desc');
res.json({ invoices });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao listar faturas' });
}
});
// POST /invoices — Create invoice
router.post('/invoices', async (req: Request, res: Response) => {
try {
const { partner_id, plan_id, amount, due_date } = req.body;
const [id] = await db('invoices').insert({
partner_id, plan_id, amount,
issue_date: db.fn.now(),
due_date,
status: 'Pendente',
});
await hooks.emit('invoice:created', { invoiceId: id, partnerId: partner_id });
const invoice = await db('invoices').where({ id }).first();
res.status(201).json(invoice);
} catch (err: any) {
res.status(500).json({ error: 'Erro ao criar fatura' });
}
});
// PUT /invoices/:id/pay — Mark invoice as paid
router.put('/invoices/:id/pay', async (req: Request, res: Response) => {
try {
await db('invoices').where({ id: req.params.id }).update({
status: 'Paga',
paid_at: db.fn.now(),
});
await hooks.emit('invoice:paid', { invoiceId: req.params.id });
res.json({ message: 'Fatura paga com sucesso' });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao pagar fatura' });
}
});
// GET /dashboard — Financial overview
router.get('/dashboard', async (_req: Request, res: Response) => {
try {
const [totalRevenue] = await db('invoices')
.where({ status: 'Paga' })
.sum('amount as total');
const [pendingRevenue] = await db('invoices')
.where({ status: 'Pendente' })
.sum('amount as total');
const [overdueRevenue] = await db('invoices')
.where({ status: 'Atrasada' })
.sum('amount as total');
const [activeSubscriptions] = await db('partners')
.where({ status: 'active' })
.count('id as count');
const planDistribution = await db('partners')
.select('subscription_plan_id as plan')
.count('id as count')
.where({ status: 'active' })
.groupBy('subscription_plan_id');
res.json({
totalRevenue: Number(totalRevenue.total || 0),
pendingRevenue: Number(pendingRevenue.total || 0),
overdueRevenue: Number(overdueRevenue.total || 0),
activeSubscriptions: Number(activeSubscriptions.count),
planDistribution,
});
} catch (err: any) {
res.status(500).json({ error: 'Erro ao buscar dashboard financeiro' });
}
});
// GET /commissions — List commissions
router.get('/commissions', async (req: Request, res: Response) => {
try {
const partnerId = req.query.partner_id as string;
let query = db('commissions');
if (partnerId) query = query.where({ partner_id: partnerId });
const commissions = await query.orderBy('transaction_date', 'desc');
res.json({ commissions });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao listar comissões' });
}
});
return router;
}
@@ -0,0 +1,24 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const routes_1 = require("./backend/routes");
const manifest_json_1 = __importDefault(require("./manifest.json"));
const plugin = {
manifest: manifest_json_1.default,
async activate(ctx) {
const router = (0, routes_1.createFinanceRoutes)(ctx);
ctx.app.use('/api/finance', router);
// Listen for new partners to create initial invoice
ctx.hooks.register('partner:created', async (data) => {
ctx.logger.info(`Creating initial invoice for partner ${data.partnerId}`);
});
ctx.logger.info('Finance routes registered at /api/finance');
},
async deactivate(ctx) {
ctx.logger.info('Financeiro plugin deactivated');
},
};
exports.default = plugin;
//# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../plugins/financeiro/index.ts"],"names":[],"mappings":";;;;;AACA,6CAAuD;AACvD,oEAAuC;AAEvC,MAAM,MAAM,GAAmB;IAC3B,QAAQ,EAAE,uBAAe;IAEzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC7B,MAAM,MAAM,GAAG,IAAA,4BAAmB,EAAC,GAAG,CAAC,CAAC;QACxC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,cAAc,EAAE,MAAM,CAAC,CAAC;QAEpC,oDAAoD;QACpD,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,iBAAiB,EAAE,KAAK,EAAE,IAA2B,EAAE,EAAE;YACxE,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,wCAAwC,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC;QAC9E,CAAC,CAAC,CAAC;QAEH,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,2CAA2C,CAAC,CAAC;IACjE,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,GAAkB;QAC/B,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,+BAA+B,CAAC,CAAC;IACrD,CAAC;CACJ,CAAC;AAEF,kBAAe,MAAM,CAAC"}
@@ -0,0 +1,25 @@
import { PluginInstance, PluginContext } from '../../backend/src/core/types';
import { createFinanceRoutes } from './backend/routes';
import manifest from './manifest.json';
const plugin: PluginInstance = {
manifest: manifest as any,
async activate(ctx: PluginContext): Promise<void> {
const router = createFinanceRoutes(ctx);
ctx.app.use('/api/finance', router);
// Listen for new partners to create initial invoice
ctx.hooks.register('partner:created', async (data: { partnerId: string }) => {
ctx.logger.info(`Creating initial invoice for partner ${data.partnerId}`);
});
ctx.logger.info('Finance routes registered at /api/finance');
},
async deactivate(ctx: PluginContext): Promise<void> {
ctx.logger.info('Financeiro plugin deactivated');
},
};
export default plugin;
@@ -0,0 +1,66 @@
{
"name": "financeiro",
"displayName": "Financeiro",
"version": "1.0.0",
"description": "Planos de assinatura, faturas, comissões e controle financeiro de parceiros.",
"author": "Clube67",
"category": "business",
"enabled": true,
"canDisable": true,
"dependencies": [
"core-auth",
"partners"
],
"backend": {
"routePrefix": "/api/finance",
"hasMigrations": true
},
"frontend": {
"menuItems": [
{
"id": "finance-dashboard",
"label": "Financeiro",
"icon": "DollarSign",
"href": "/admin/finance",
"roles": [
"super_admin",
"finance"
],
"order": 50
},
{
"id": "finance-invoices",
"label": "Faturas",
"icon": "FileText",
"href": "/partner/invoices",
"roles": [
"partner_admin"
],
"order": 55
}
],
"widgets": [
{
"id": "finance-mrr",
"component": "MRRWidget",
"span": 2,
"roles": [
"super_admin",
"finance"
],
"order": 30
}
]
},
"hooks": {
"subscribes": [
"partner:created",
"partner:deactivated"
],
"emits": [
"invoice:created",
"invoice:paid",
"invoice:overdue"
]
}
}
@@ -0,0 +1,87 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createGoogleOAuthRoutes = createGoogleOAuthRoutes;
const express_1 = require("express");
const plugin_config_1 = require("../../../backend/src/core/plugin-config");
const auth_1 = require("../../../backend/src/middleware/auth");
function createGoogleOAuthRoutes(ctx) {
const router = (0, express_1.Router)();
const { db } = ctx;
const storedConfig = plugin_config_1.pluginConfig.get('google-oauth') || {};
const GOOGLE_CLIENT_ID = storedConfig.clientId || process.env.GOOGLE_CLIENT_ID;
const GOOGLE_CLIENT_SECRET = storedConfig.clientSecret || process.env.GOOGLE_CLIENT_SECRET;
const GOOGLE_CALLBACK = storedConfig.callbackUrl || process.env.GOOGLE_CALLBACK_URL || '/api/auth/google/callback';
const rawBasePath = storedConfig.basePath || process.env.NEXT_PUBLIC_BASE_PATH || process.env.NEXT_BASE_PATH || '';
const basePath = rawBasePath ? `/${String(rawBasePath).replace(/^\/+|\/+$/g, '')}` : '';
// GET / — Redirect to Google
router.get('/', (_req, res) => {
if (!GOOGLE_CLIENT_ID)
return res.status(503).json({ error: 'Google OAuth não configurado' });
const url = `https://accounts.google.com/o/oauth2/v2/auth?` +
`client_id=${GOOGLE_CLIENT_ID}&redirect_uri=${encodeURIComponent(GOOGLE_CALLBACK)}` +
`&response_type=code&scope=openid%20email%20profile&access_type=offline`;
res.redirect(url);
});
// GET /callback — Handle Google callback
router.get('/callback', async (req, res) => {
try {
const { code } = req.query;
if (!code || !GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) {
return res.status(400).json({ error: 'Código ou config ausente' });
}
// Exchange code for token
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code, client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET,
redirect_uri: GOOGLE_CALLBACK, grant_type: 'authorization_code',
}),
});
const tokenData = await tokenRes.json();
if (!tokenRes.ok || !(tokenData === null || tokenData === void 0 ? void 0 : tokenData.access_token)) {
ctx.logger.error(`Google OAuth token error: ${JSON.stringify(tokenData)}`);
return res.status(400).json({ error: 'Falha ao obter token do Google' });
}
// Get user info
const userRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { Authorization: `Bearer ${tokenData.access_token}` },
});
const googleUser = await userRes.json();
if (!userRes.ok || !(googleUser === null || googleUser === void 0 ? void 0 : googleUser.email)) {
ctx.logger.error(`Google OAuth userinfo error: ${JSON.stringify(googleUser)}`);
return res.status(400).json({ error: 'Falha ao obter e-mail do Google' });
}
// Find or create user
let user = await db('users').where({ email: googleUser.email }).first();
if (!user) {
const [id] = await db('users').insert({
name: googleUser.name, email: googleUser.email,
role: 'user', status: 'active', google_id: googleUser.id,
created_at: new Date(),
updated_at: new Date(),
});
user = { id, name: googleUser.name, email: googleUser.email, role: 'user' };
}
else if (!user.google_id) {
await db('users').where({ id: user.id }).update({ google_id: googleUser.id, updated_at: new Date() });
}
if (user.deleted_at || user.status === 'banned') {
return res.status(401).json({ error: 'Conta desativada' });
}
const tokens = (0, auth_1.generateTokens)(user);
await ctx.hooks.emit('user:google_login', { userId: user.id, email: user.email });
// Redirect to frontend with token
res.redirect(`${basePath}/login?token=${encodeURIComponent(tokens.accessToken)}&google=1`);
}
catch (err) {
ctx.logger.error(`Google OAuth error: ${err.message}`);
res.status(500).json({ error: 'Erro no OAuth' });
}
});
return router;
}
//# sourceMappingURL=routes.js.map
@@ -0,0 +1 @@
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../../../../../plugins/google-oauth/backend/routes.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,120 @@
import { Router, Request, Response } from 'express';
import { PluginContext } from '../../../backend/src/core/types';
import { pluginConfig } from '../../../backend/src/core/plugin-config';
import { generateTokens } from '../../../backend/src/middleware/auth';
export function createGoogleOAuthRoutes(ctx: PluginContext): Router {
const router = Router();
const { db } = ctx;
const storedConfig = pluginConfig.get('google-oauth') || {};
const GOOGLE_CLIENT_ID = storedConfig.clientId || process.env.GOOGLE_CLIENT_ID;
const GOOGLE_CLIENT_SECRET = storedConfig.clientSecret || process.env.GOOGLE_CLIENT_SECRET;
const GOOGLE_CALLBACK = storedConfig.callbackUrl || process.env.GOOGLE_CALLBACK_URL || '/api/auth/google/callback';
const rawBasePath = storedConfig.basePath || process.env.NEXT_PUBLIC_BASE_PATH || process.env.NEXT_BASE_PATH || '';
const basePath = rawBasePath ? `/${String(rawBasePath).replace(/^\/+|\/+$/g, '')}` : '';
// GET / — Redirect to Google
router.get('/', (req: Request, res: Response) => {
if (!GOOGLE_CLIENT_ID) return res.status(503).json({ error: 'Google OAuth não configurado' });
const reqBasePath = req.query.basePath as string || '';
const state = reqBasePath ? Buffer.from(JSON.stringify({ basePath: reqBasePath })).toString('base64') : '';
// ALWAYS use the registered GOOGLE_CALLBACK URI for the OAuth flow
// The 'state' parameter will carry the basePath information to route back correctly
const redirectUri = GOOGLE_CALLBACK;
const url = `https://accounts.google.com/o/oauth2/v2/auth?` +
`client_id=${GOOGLE_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&response_type=code&scope=openid%20email%20profile&access_type=offline` +
(state ? `&state=${encodeURIComponent(state)}` : '');
res.redirect(url);
});
// GET /callback — Handle Google callback
router.get('/callback', async (req: Request, res: Response) => {
try {
const { code } = req.query;
if (!code || !GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) {
return res.status(400).json({ error: 'Código ou config ausente' });
}
// Recover basePath from state
const { state } = req.query;
let currentBasePath = ''; // Default to root for Live
const targetRedirectUri = GOOGLE_CALLBACK; // Standard URI used for the code exchange
if (state) {
try {
const decodedState = JSON.parse(Buffer.from(state as string, 'base64').toString());
if (decodedState.basePath) {
currentBasePath = decodedState.basePath;
// Always ensure basePath starts with / and doesn't end with /
currentBasePath = `/${String(currentBasePath).replace(/^\/+|\/+$/g, '')}`;
if (currentBasePath === '/') currentBasePath = '';
}
} catch (e) {
ctx.logger.error(`Error decoding OAuth state: ${e}`);
}
}
// Exchange code for token
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code, client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET,
redirect_uri: targetRedirectUri, grant_type: 'authorization_code',
}),
});
const tokenData = await tokenRes.json() as any;
if (!tokenRes.ok || !tokenData?.access_token) {
ctx.logger.error(`Google OAuth token error: ${JSON.stringify(tokenData)}`);
return res.status(400).json({ error: 'Falha ao obter token do Google' });
}
// Get user info
const userRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { Authorization: `Bearer ${tokenData.access_token}` },
});
const googleUser = await userRes.json() as any;
ctx.logger.info(`Google User Info: ${JSON.stringify(googleUser)}`);
if (!userRes.ok || !googleUser?.email) {
ctx.logger.error(`Google OAuth userinfo error: ${JSON.stringify(googleUser)}`);
return res.status(400).json({ error: 'Falha ao obter e-mail do Google' });
}
// Find or create user
let user = await db('users').where({ email: googleUser.email }).first();
if (!user) {
const [id] = await db('users').insert({
name: googleUser.name, email: googleUser.email,
role: 'user', status: 'active', google_id: googleUser.id,
created_at: new Date(),
updated_at: new Date(),
});
user = { id, name: googleUser.name, email: googleUser.email, role: 'user' };
} else if (!user.google_id) {
await db('users').where({ id: user.id }).update({ google_id: googleUser.id, updated_at: new Date() });
}
if (user.deleted_at || user.status === 'banned') {
return res.status(401).json({ error: 'Conta desativada' });
}
const tokens = generateTokens(user);
await ctx.hooks.emit('user:google_login', { userId: user.id, email: user.email });
// Redirect to frontend with token
res.redirect(`${currentBasePath}/login?token=${encodeURIComponent(tokens.accessToken)}&google=1`);
} catch (err: any) {
ctx.logger.error(`Google OAuth error: ${err.message}`);
res.status(500).json({ error: 'Erro no OAuth' });
}
});
return router;
}
@@ -0,0 +1,17 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const routes_1 = require("./backend/routes");
const manifest_json_1 = __importDefault(require("./manifest.json"));
const plugin = {
manifest: manifest_json_1.default,
async activate(ctx) {
ctx.app.use('/api/auth/google', (0, routes_1.createGoogleOAuthRoutes)(ctx));
ctx.logger.info('Google OAuth routes registered');
},
async deactivate(ctx) { ctx.logger.info('Google OAuth deactivated'); },
};
exports.default = plugin;
//# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../plugins/google-oauth/index.ts"],"names":[],"mappings":";;;;;AACA,6CAA2D;AAC3D,oEAAuC;AAEvC,MAAM,MAAM,GAAmB;IAC3B,QAAQ,EAAE,uBAAe;IACzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC7B,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,kBAAkB,EAAE,IAAA,gCAAuB,EAAC,GAAG,CAAC,CAAC,CAAC;QAC9D,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,gCAAgC,CAAC,CAAC;IACtD,CAAC;IACD,KAAK,CAAC,UAAU,CAAC,GAAkB,IAAmB,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC;CACvG,CAAC;AACF,kBAAe,MAAM,CAAC"}
@@ -0,0 +1,13 @@
import { PluginInstance, PluginContext } from '../../backend/src/core/types';
import { createGoogleOAuthRoutes } from './backend/routes';
import manifest from './manifest.json';
const plugin: PluginInstance = {
manifest: manifest as any,
async activate(ctx: PluginContext): Promise<void> {
ctx.app.use('/api/auth/google', createGoogleOAuthRoutes(ctx));
ctx.logger.info('Google OAuth routes registered');
},
async deactivate(ctx: PluginContext): Promise<void> { ctx.logger.info('Google OAuth deactivated'); },
};
export default plugin;
@@ -0,0 +1,26 @@
{
"name": "google-oauth",
"displayName": "Google OAuth",
"version": "1.0.0",
"description": "Login social via Google OAuth 2.0.",
"author": "Clube67",
"category": "integration",
"enabled": true,
"canDisable": true,
"dependencies": [
"core-auth"
],
"backend": {
"routePrefix": "/api/auth/google",
"hasMigrations": false
},
"frontend": {
"menuItems": []
},
"hooks": {
"subscribes": [],
"emits": [
"user:google_login"
]
}
}
@@ -0,0 +1,235 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createLeadsRoutes = createLeadsRoutes;
const express_1 = require("express");
const auth_1 = require("../../../backend/src/middleware/auth");
const rbac_1 = require("../../../backend/src/middleware/rbac");
const STATUS_VALUES = ['new', 'contacted', 'qualified', 'converted', 'lost'];
const LEAD_TYPE_VALUES = ['cold', 'member'];
function translateLead(lead) {
if (!lead)
return lead;
return {
...lead,
source: lead.source || 'manual',
partner_id: lead.partner_id || null,
benefit_id: lead.benefit_id || null,
user_id: lead.user_id || null,
lead_type: lead.lead_type || 'cold',
};
}
function createLeadsRoutes(ctx) {
const router = (0, express_1.Router)();
const { db } = ctx;
const protectedRoutes = [auth_1.authenticate, (0, rbac_1.authorize)(['super_admin', 'admin'])];
// GET /api/leads/metrics — Global metrics for super admin
router.get('/metrics', ...protectedRoutes, async (req, res) => {
try {
const [totalRow] = await db('leads').count('id as total');
const total = Number(totalRow.total);
// Today
const [todayRow] = await db('leads')
.count('id as total')
.whereRaw('DATE(created_at) = CURDATE()');
const today = Number(todayRow.total);
// By status
const byStatus = await db('leads')
.select('status')
.count('id as total')
.groupBy('status');
// By lead type
const byType = await db('leads')
.select('lead_type')
.count('id as total')
.groupBy('lead_type');
// Converted count for conversion rate
const converted = byStatus.find((r) => r.status === 'converted');
const conversionRate = total > 0 ? ((Number(converted?.total || 0) / total) * 100).toFixed(1) : '0.0';
// By city (via users join)
const byCity = await db('leads')
.join('users', 'leads.user_id', 'users.id')
.select('users.city', 'users.state')
.count('leads.id as total')
.whereNotNull('users.city')
.groupBy('users.city', 'users.state')
.orderBy('total', 'desc')
.limit(10);
// By neighborhood (via users join)
const byNeighborhood = await db('leads')
.join('users', 'leads.user_id', 'users.id')
.select('users.neighborhood', 'users.city', 'users.state')
.count('leads.id as total')
.whereNotNull('users.neighborhood')
.groupBy('users.neighborhood', 'users.city', 'users.state')
.orderBy('total', 'desc')
.limit(10);
// By partner
const byPartner = await db('leads')
.join('partners', 'leads.partner_id', 'partners.id')
.select('partners.id as partner_id', 'partners.company_name')
.count('leads.id as total')
.groupBy('partners.id', 'partners.company_name')
.orderBy('total', 'desc')
.limit(10);
// Recent leads
const recent = await db('leads')
.leftJoin('users', 'leads.user_id', 'users.id')
.leftJoin('partners', 'leads.partner_id', 'partners.id')
.select('leads.id', 'leads.name', 'leads.email', 'leads.status', 'leads.lead_type', 'leads.source', 'leads.created_at', 'partners.company_name as partner_name', 'users.city', 'users.neighborhood')
.orderBy('leads.created_at', 'desc')
.limit(15);
res.json({
total,
today,
conversion_rate: conversionRate,
by_status: byStatus,
by_type: byType,
by_city: byCity,
by_neighborhood: byNeighborhood,
by_partner: byPartner,
recent,
});
}
catch (err) {
console.error('Error fetching lead metrics:', err);
res.status(500).json({ error: 'Erro ao buscar métricas' });
}
});
router.get('/', auth_1.authenticate, async (req, res) => {
try {
const { partner_id, status, type, page = '1', limit = '20' } = req.query;
const offset = (parseInt(page) - 1) * parseInt(limit);
const userRole = req.user?.role;
const userPartner = req.user?.partner_id;
let query = db('leads').orderBy('created_at', 'desc');
if (partner_id)
query = query.where({ partner_id });
if (userRole === 'partner' && userPartner) {
query = query.where({ partner_id: userPartner });
}
if (type && LEAD_TYPE_VALUES.includes(type)) {
query = query.where({ lead_type: type });
}
if (status && STATUS_VALUES.includes(status)) {
query = query.where({ status: status });
}
else {
query = query.where({ status: 'new' });
}
const countResult = await query.clone().count('id as count');
const leads = await query.limit(parseInt(limit)).offset(offset);
res.json({ leads: leads.map(translateLead), total: Number(countResult[0].count), page: parseInt(page) });
}
catch (err) {
res.status(500).json({ error: 'Erro ao listar leads' });
}
});
router.get('/:id', auth_1.authenticate, async (req, res) => {
try {
const id = Number(req.params.id);
const lead = await db('leads').where({ id }).first();
if (!lead)
return res.status(404).json({ error: 'Lead não encontrado' });
if (req.user?.role === 'partner' && req.user.partner_id && lead.partner_id !== req.user.partner_id) {
return res.status(403).json({ error: 'Acesso negado' });
}
const history = await db('lead_history').where({ lead_id: id }).orderBy('created_at', 'desc');
res.json({ lead: translateLead(lead), history });
}
catch (err) {
res.status(500).json({ error: 'Erro ao buscar lead' });
}
});
router.post('/', ...protectedRoutes, async (req, res) => {
try {
const { name, email, phone, partner_id, source, notes, benefit_id, assigned_to, user_id } = req.body;
const numericUserId = user_id ? Number(user_id) : null;
const parsedUserId = Number.isInteger(numericUserId) ? numericUserId : null;
const leadType = parsedUserId ? 'member' : 'cold';
const [id] = await db('leads').insert({
name,
email,
phone,
partner_id: partner_id || null,
benefit_id: benefit_id || null,
user_id: parsedUserId,
lead_type: leadType,
source: source || 'manual',
notes: notes || null,
assigned_to: assigned_to || null,
status: 'new',
owner: 'clube67',
});
await db('lead_history').insert({
lead_id: id,
action: 'created',
new_value: 'new',
created_by: req.user?.id || null,
});
await ctx.hooks.emit('lead:created', { id, email, partner_id });
const lead = await db('leads').where({ id }).first();
res.status(201).json(translateLead(lead));
}
catch (err) {
res.status(500).json({ error: 'Erro ao criar lead' });
}
});
router.patch('/:id/status', ...protectedRoutes, async (req, res) => {
try {
const id = Number(req.params.id);
const { status } = req.body;
if (!STATUS_VALUES.includes(status))
return res.status(400).json({ error: 'Status inválido' });
const lead = await db('leads').where({ id }).first();
if (!lead)
return res.status(404).json({ error: 'Lead não encontrado' });
await db('leads').where({ id }).update({ status, updated_at: db.fn.now() });
await db('lead_history').insert({
lead_id: id,
action: 'status',
old_value: lead.status,
new_value: status,
created_by: req.user?.id || null,
});
if (status === 'converted')
await ctx.hooks.emit('lead:converted', { id });
if (status === 'lost')
await ctx.hooks.emit('lead:lost', { id });
const updated = await db('leads').where({ id }).first();
res.json(translateLead(updated));
}
catch (err) {
res.status(500).json({ error: 'Erro ao atualizar status' });
}
});
router.post('/:id/notes', ...protectedRoutes, async (req, res) => {
try {
const id = Number(req.params.id);
const { note } = req.body;
if (!note)
return res.status(400).json({ error: 'Nota obrigatória' });
const lead = await db('leads').where({ id }).first();
if (!lead)
return res.status(404).json({ error: 'Lead não encontrado' });
await db('lead_history').insert({
lead_id: id,
action: 'note',
old_value: lead.notes,
new_value: lead.notes ? `${lead.notes}\n${note}` : note,
note,
created_by: req.user?.id || null,
});
await db('leads').where({ id }).update({
notes: db.raw('CONCAT(COALESCE(notes,""), "\n", ?)', [note]),
updated_at: db.fn.now(),
});
const updated = await db('leads').where({ id }).first();
res.json(translateLead(updated));
}
catch (err) {
res.status(500).json({ error: 'Erro ao salvar nota' });
}
});
return router;
}
//# sourceMappingURL=routes.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,247 @@
import { Router, Request, Response } from 'express';
import { PluginContext } from '../../../backend/src/core/types';
import { authenticate } from '../../../backend/src/middleware/auth';
import { authorize } from '../../../backend/src/middleware/rbac';
const STATUS_VALUES = ['new', 'contacted', 'qualified', 'converted', 'lost'];
const LEAD_TYPE_VALUES = ['cold', 'member'];
function translateLead(lead: any) {
if (!lead) return lead;
return {
...lead,
source: lead.source || 'manual',
partner_id: lead.partner_id || null,
benefit_id: lead.benefit_id || null,
user_id: lead.user_id || null,
lead_type: lead.lead_type || 'cold',
};
}
export function createLeadsRoutes(ctx: PluginContext): Router {
const router = Router();
const { db } = ctx;
const protectedRoutes = [authenticate, authorize(['super_admin', 'admin'])];
// GET /api/leads/metrics — Global metrics for super admin
router.get('/metrics', ...protectedRoutes, async (req: Request, res: Response) => {
try {
const [totalRow] = await db('leads').count('id as total');
const total = Number(totalRow.total);
// Today
const [todayRow] = await db('leads')
.count('id as total')
.whereRaw('DATE(created_at) = CURDATE()');
const today = Number(todayRow.total);
// By status
const byStatus = await db('leads')
.select('status')
.count('id as total')
.groupBy('status');
// By lead type
const byType = await db('leads')
.select('lead_type')
.count('id as total')
.groupBy('lead_type');
// Converted count for conversion rate
const converted = byStatus.find((r: any) => r.status === 'converted');
const conversionRate = total > 0 ? ((Number(converted?.total || 0) / total) * 100).toFixed(1) : '0.0';
// By city (via users join)
const byCity = await db('leads')
.join('users', 'leads.user_id', 'users.id')
.select('users.city', 'users.state')
.count('leads.id as total')
.whereNotNull('users.city')
.groupBy('users.city', 'users.state')
.orderBy('total', 'desc')
.limit(10);
// By neighborhood (via users join)
const byNeighborhood = await db('leads')
.join('users', 'leads.user_id', 'users.id')
.select('users.neighborhood', 'users.city', 'users.state')
.count('leads.id as total')
.whereNotNull('users.neighborhood')
.groupBy('users.neighborhood', 'users.city', 'users.state')
.orderBy('total', 'desc')
.limit(10);
// By partner
const byPartner = await db('leads')
.join('partners', 'leads.partner_id', 'partners.id')
.select('partners.id as partner_id', 'partners.company_name')
.count('leads.id as total')
.groupBy('partners.id', 'partners.company_name')
.orderBy('total', 'desc')
.limit(10);
// Recent leads
const recent = await db('leads')
.leftJoin('users', 'leads.user_id', 'users.id')
.leftJoin('partners', 'leads.partner_id', 'partners.id')
.select(
'leads.id',
'leads.name',
'leads.email',
'leads.status',
'leads.lead_type',
'leads.source',
'leads.created_at',
'partners.company_name as partner_name',
'users.city',
'users.neighborhood',
)
.orderBy('leads.created_at', 'desc')
.limit(15);
res.json({
total,
today,
conversion_rate: conversionRate,
by_status: byStatus,
by_type: byType,
by_city: byCity,
by_neighborhood: byNeighborhood,
by_partner: byPartner,
recent,
});
} catch (err: any) {
console.error('Error fetching lead metrics:', err);
res.status(500).json({ error: 'Erro ao buscar métricas' });
}
});
router.get('/', authenticate, async (req: Request, res: Response) => {
try {
const { partner_id, status, type, page = '1', limit = '20' } = req.query;
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
const userRole = req.user?.role;
const userPartner = req.user?.partner_id;
let query = db('leads').orderBy('created_at', 'desc');
if (partner_id) query = query.where({ partner_id });
if (userRole === 'partner' && userPartner) {
query = query.where({ partner_id: userPartner });
}
if (type && LEAD_TYPE_VALUES.includes(type as string)) {
query = query.where({ lead_type: type as string });
}
if (status && STATUS_VALUES.includes(status as string)) {
query = query.where({ status: status as string });
} else {
query = query.where({ status: 'new' });
}
const countResult = await query.clone().count('id as count');
const leads = await query.limit(parseInt(limit as string)).offset(offset);
res.json({ leads: leads.map(translateLead), total: Number(countResult[0].count), page: parseInt(page as string) });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao listar leads' });
}
});
router.get('/:id', authenticate, async (req: Request, res: Response) => {
try {
const id = Number(req.params.id);
const lead = await db('leads').where({ id }).first();
if (!lead) return res.status(404).json({ error: 'Lead não encontrado' });
if (req.user?.role === 'partner' && req.user.partner_id && lead.partner_id !== req.user.partner_id) {
return res.status(403).json({ error: 'Acesso negado' });
}
const history = await db('lead_history').where({ lead_id: id }).orderBy('created_at', 'desc');
res.json({ lead: translateLead(lead), history });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao buscar lead' });
}
});
router.post('/', ...protectedRoutes, async (req: Request, res: Response) => {
try {
const { name, email, phone, partner_id, source, notes, benefit_id, assigned_to, user_id } = req.body;
const numericUserId = user_id ? Number(user_id) : null;
const parsedUserId = Number.isInteger(numericUserId) ? numericUserId : null;
const leadType = parsedUserId ? 'member' : 'cold';
const [id] = await db('leads').insert({
name,
email,
phone,
partner_id: partner_id || null,
benefit_id: benefit_id || null,
user_id: parsedUserId,
lead_type: leadType,
source: source || 'manual',
notes: notes || null,
assigned_to: assigned_to || null,
status: 'new',
owner: 'clube67',
});
await db('lead_history').insert({
lead_id: id,
action: 'created',
new_value: 'new',
created_by: (req as any).user?.id || null,
});
await ctx.hooks.emit('lead:created', { id, email, partner_id });
const lead = await db('leads').where({ id }).first();
res.status(201).json(translateLead(lead));
} catch (err: any) {
res.status(500).json({ error: 'Erro ao criar lead' });
}
});
router.patch('/:id/status', ...protectedRoutes, async (req: Request, res: Response) => {
try {
const id = Number(req.params.id);
const { status } = req.body;
if (!STATUS_VALUES.includes(status)) return res.status(400).json({ error: 'Status inválido' });
const lead = await db('leads').where({ id }).first();
if (!lead) return res.status(404).json({ error: 'Lead não encontrado' });
await db('leads').where({ id }).update({ status, updated_at: db.fn.now() });
await db('lead_history').insert({
lead_id: id,
action: 'status',
old_value: lead.status,
new_value: status,
created_by: (req as any).user?.id || null,
});
if (status === 'converted') await ctx.hooks.emit('lead:converted', { id });
if (status === 'lost') await ctx.hooks.emit('lead:lost', { id });
const updated = await db('leads').where({ id }).first();
res.json(translateLead(updated));
} catch (err: any) {
res.status(500).json({ error: 'Erro ao atualizar status' });
}
});
router.post('/:id/notes', ...protectedRoutes, async (req: Request, res: Response) => {
try {
const id = Number(req.params.id);
const { note } = req.body;
if (!note) return res.status(400).json({ error: 'Nota obrigatória' });
const lead = await db('leads').where({ id }).first();
if (!lead) return res.status(404).json({ error: 'Lead não encontrado' });
await db('lead_history').insert({
lead_id: id,
action: 'note',
old_value: lead.notes,
new_value: lead.notes ? `${lead.notes}\n${note}` : note,
note,
created_by: (req as any).user?.id || null,
});
await db('leads').where({ id }).update({
notes: db.raw('CONCAT(COALESCE(notes,""), "\n", ?)', [note]),
updated_at: db.fn.now(),
});
const updated = await db('leads').where({ id }).first();
res.json(translateLead(updated));
} catch (err: any) {
res.status(500).json({ error: 'Erro ao salvar nota' });
}
});
return router;
}
@@ -0,0 +1,26 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const routes_1 = require("./backend/routes");
const manifest_json_1 = __importDefault(require("./manifest.json"));
const plugin = {
manifest: manifest_json_1.default,
async activate(ctx) {
const router = (0, routes_1.createLeadsRoutes)(ctx);
ctx.app.use('/api/leads', router);
ctx.hooks.register('user:register', async (data) => {
await ctx.db('leads').insert({
user_id: data.userId,
source: 'registration',
status: 'new',
lead_type: 'member',
}).catch(() => { });
});
ctx.logger.info('Leads routes registered');
},
async deactivate(ctx) { ctx.logger.info('Leads deactivated'); },
};
exports.default = plugin;
//# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../plugins/leads/index.ts"],"names":[],"mappings":";;;;;AACA,6CAAqD;AACrD,oEAAuC;AAEvC,MAAM,MAAM,GAAmB;IAC3B,QAAQ,EAAE,uBAAe;IACzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC7B,MAAM,MAAM,GAAG,IAAA,0BAAiB,EAAC,GAAG,CAAC,CAAC;QACtC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,MAAM,CAAC,CAAC;QAClC,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,eAAe,EAAE,KAAK,EAAE,IAAS,EAAE,EAAE;YACpD,MAAM,GAAG,CAAC,EAAE,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC;gBACzB,OAAO,EAAE,IAAI,CAAC,MAAM;gBACpB,MAAM,EAAE,cAAc;gBACtB,MAAM,EAAE,KAAK;gBACb,SAAS,EAAE,QAAQ;aACtB,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC;QACxB,CAAC,CAAC,CAAC;QACH,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,yBAAyB,CAAC,CAAC;IAC/C,CAAC;IACD,KAAK,CAAC,UAAU,CAAC,GAAkB,IAAmB,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,mBAAmB,CAAC,CAAC,CAAC,CAAC;CAChG,CAAC;AACF,kBAAe,MAAM,CAAC"}
@@ -0,0 +1,22 @@
import { PluginInstance, PluginContext } from '../../backend/src/core/types';
import { createLeadsRoutes } from './backend/routes';
import manifest from './manifest.json';
const plugin: PluginInstance = {
manifest: manifest as any,
async activate(ctx: PluginContext): Promise<void> {
const router = createLeadsRoutes(ctx);
ctx.app.use('/api/leads', router);
ctx.hooks.register('user:register', async (data: any) => {
await ctx.db('leads').insert({
user_id: data.userId,
source: 'registration',
status: 'new',
lead_type: 'member',
}).catch(() => { });
});
ctx.logger.info('Leads routes registered');
},
async deactivate(ctx: PluginContext): Promise<void> { ctx.logger.info('Leads deactivated'); },
};
export default plugin;
@@ -0,0 +1,52 @@
{
"name": "leads",
"displayName": "Leads & CRM",
"version": "1.0.0",
"description": "Captura, qualificação e acompanhamento de leads.",
"author": "Clube67",
"category": "business",
"enabled": true,
"canDisable": true,
"dependencies": [
"core-auth",
"partners"
],
"backend": {
"routePrefix": "/api/leads",
"hasMigrations": true
},
"frontend": {
"menuItems": [
{
"id": "leads-list",
"label": "Leads",
"icon": "UserPlus",
"href": "/admin/leads",
"roles": [
"super_admin"
],
"order": 35
},
{
"id": "leads-partner",
"label": "Meus Leads",
"icon": "UserPlus",
"href": "/partner/leads",
"roles": [
"partner_admin"
],
"order": 35
}
]
},
"hooks": {
"subscribes": [
"user:register"
],
"emits": [
"lead:created",
"lead:converted",
"lead:lost"
]
}
}
@@ -0,0 +1,165 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createRoutes = createRoutes;
const express_1 = require("express");
const plugin_config_1 = require("../../../backend/src/core/plugin-config");
const openai_1 = __importDefault(require("openai"));
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const https_1 = __importDefault(require("https"));
const uuid_1 = require("uuid");
const config_1 = require("../../../backend/src/config");
function createRoutes(ctx) {
const router = (0, express_1.Router)();
router.get('/', (req, res) => {
res.json({
message: 'Nanobana plugin active',
timestamp: new Date().toISOString(),
service: 'Clube67 System'
});
});
router.post('/generate-image', async (req, res) => {
try {
const { type, prompt, partnerName, city, description, partnerId } = req.body;
if (!partnerId) {
return res.status(400).json({ error: 'Partner ID is required' });
}
// Check Usage Limit (1 per type)
// FUTURE UPDATE: Add monetization logic here to allow more generations
// Check for Unlimited User (ruibto@gmail.com)
const user = await ctx.db('users').where({ partner_id: partnerId }).first();
const isUnlimited = user && (user.email === 'ruibto@gmail.com');
if (!isUnlimited) {
const usageCount = await ctx.db('nanobana_usage')
.where({ partner_id: partnerId, type })
.count('id as count')
.first();
const count = usageCount ? Number(usageCount.count) : 0;
if (count >= 1) {
return res.status(403).json({ error: `Limite gratuito atingido para ${type === 'logo' ? 'Logo' : 'Banner'}. (Máximo: 1)` });
}
}
// Get Config
const nanobanaConfig = plugin_config_1.pluginConfig.get('nanobana');
const apiKey = nanobanaConfig?.apiKey;
if (!apiKey) {
return res.status(400).json({ error: 'OpenAI API Key não configurada no plugin Nanobana.' });
}
const openai = new openai_1.default({ apiKey });
// Vision Analysis (if enabled)
let visionDescription = '';
if (req.body.useImageRef && req.body.referenceImageUrl) {
try {
const refUrl = req.body.referenceImageUrl;
let imagePath = '';
// Resolve local path
if (refUrl.includes('/uploads/')) {
const filename = path_1.default.basename(refUrl);
imagePath = path_1.default.join(config_1.config.upload.dir, filename);
}
if (imagePath && fs_1.default.existsSync(imagePath)) {
const imageBuffer = fs_1.default.readFileSync(imagePath);
const base64Image = imageBuffer.toString('base64');
const mimeType = imagePath.endsWith('.png') ? 'image/png' : 'image/jpeg';
const dataUrl = `data:${mimeType};base64,${base64Image}`;
const visionResponse = await openai.chat.completions.create({
model: "gpt-4o",
messages: [
{
role: "user",
content: [
{ type: "text", text: "Describe this image in detail. Focus on the main subject, colors, style, artistic technique, and composition. The description will be used to recreate a similar image." },
{ type: "image_url", image_url: { url: dataUrl } }
],
},
],
max_tokens: 300,
});
visionDescription = visionResponse.choices[0].message.content || '';
console.log('[Nanobana] Vision Description:', visionDescription);
}
else {
console.warn('[Nanobana] Reference image not found locally:', refUrl);
}
}
catch (visionErr) {
console.error('[Nanobana] Vision Error:', visionErr);
// Continue without vision
}
}
// Construct Prompt
let finalPrompt = '';
if (visionDescription) {
finalPrompt = `Create an image based on this visual description: "${visionDescription}".\n\nCONTEXT/MODIFICATIONS REQUESTED: ${prompt || 'Keep the style similar.'}.\n\nIdentity: ${partnerName} (${city}).`;
}
else if (prompt) {
finalPrompt = prompt;
}
else {
if (type === 'logo') {
finalPrompt = `A professional, minimalist, and modern logo for a company named "${partnerName}". Context: ${description}. Location style: ${city}. High quality, vector style, white background.`;
}
else {
finalPrompt = `A stunning, high-quality banner image for a company named "${partnerName}". Context: ${description}. The image should be wide (landscape), professional photography style, inviting, and related to the business topic.`;
}
}
// Generate Image
const response = await openai.images.generate({
model: "dall-e-3",
prompt: finalPrompt,
n: 1,
size: "1024x1024",
quality: "standard",
response_format: "url",
});
const imageUrl = response.data[0].url;
if (!imageUrl)
throw new Error('Falha ao gerar imagem na OpenAI');
// Download Image to Local Storage
const ext = '.png'; // DALL-E 3 usually png
const filename = `${(0, uuid_1.v4)()}${ext}`;
const localPath = path_1.default.join(config_1.config.upload.dir, filename);
const publicUrl = `/uploads/${filename}`;
const file = fs_1.default.createWriteStream(localPath);
await new Promise((resolve, reject) => {
https_1.default.get(imageUrl, function (response) {
response.pipe(file);
file.on('finish', () => {
file.close();
resolve(true);
});
}).on('error', function (err) {
fs_1.default.unlink(localPath, () => { });
reject(err);
});
});
// Record Usage
await ctx.db('nanobana_usage').insert({
partner_id: partnerId,
type
});
res.json({
success: true,
url: publicUrl,
originalPrompt: finalPrompt
});
}
catch (error) {
console.error('Nanobana Error:', error);
let errorMessage = error.message || 'Erro ao gerar imagem';
// Translate Safety Error
if (errorMessage.includes('safety system') || errorMessage.includes('rejected')) {
errorMessage = '⚠️ A imagem não pôde ser gerada pois a descrição ou o contexto viola as políticas de segurança da IA (conteúdo impróprio, marcas protegidas ou termos sensíveis). Tente ajustar o texto para ser mais genérico.';
}
else if (errorMessage.includes('billing') || errorMessage.includes('quota')) {
errorMessage = 'Erro de faturamento na IA. Contate o suporte.';
}
res.status(500).json({ error: errorMessage });
}
});
return router;
}
//# sourceMappingURL=routes.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,183 @@
import { Router } from 'express';
import { PluginContext } from '../../../backend/src/core/types';
import { pluginConfig } from '../../../backend/src/core/plugin-config';
import OpenAI from 'openai';
import fs from 'fs';
import path from 'path';
import https from 'https';
import { v4 as uuidv4 } from 'uuid';
import { config } from '../../../backend/src/config';
export function createRoutes(ctx: PluginContext): Router {
const router = Router();
router.get('/', (req, res) => {
res.json({
message: 'Nanobana plugin active',
timestamp: new Date().toISOString(),
service: 'Clube67 System'
});
});
router.post('/generate-image', async (req, res) => {
try {
const { type, prompt, partnerName, city, description, partnerId } = req.body;
if (!partnerId) {
return res.status(400).json({ error: 'Partner ID is required' });
}
// Check Usage Limit (1 per type)
// FUTURE UPDATE: Add monetization logic here to allow more generations
// Check for Unlimited User (ruibto@gmail.com)
const user = await ctx.db('users').where({ partner_id: partnerId }).first();
const isUnlimited = user && (user.email === 'ruibto@gmail.com');
if (!isUnlimited) {
const usageCount = await ctx.db('nanobana_usage')
.where({ partner_id: partnerId, type })
.count('id as count')
.first();
const count = usageCount ? Number(usageCount.count) : 0;
if (count >= 1) {
return res.status(403).json({ error: `Limite gratuito atingido para ${type === 'logo' ? 'Logo' : 'Banner'}. (Máximo: 1)` });
}
}
// Get Config
const nanobanaConfig = pluginConfig.get('nanobana');
const apiKey = nanobanaConfig?.apiKey;
if (!apiKey) {
return res.status(400).json({ error: 'OpenAI API Key não configurada no plugin Nanobana.' });
}
const openai = new OpenAI({ apiKey });
// Vision Analysis (if enabled)
let visionDescription = '';
if (req.body.useImageRef && req.body.referenceImageUrl) {
try {
const refUrl = req.body.referenceImageUrl;
let imagePath = '';
// Resolve local path
if (refUrl.includes('/uploads/')) {
const filename = path.basename(refUrl);
imagePath = path.join(config.upload.dir, filename);
}
if (imagePath && fs.existsSync(imagePath)) {
const imageBuffer = fs.readFileSync(imagePath);
const base64Image = imageBuffer.toString('base64');
const mimeType = imagePath.endsWith('.png') ? 'image/png' : 'image/jpeg';
const dataUrl = `data:${mimeType};base64,${base64Image}`;
const visionResponse = await openai.chat.completions.create({
model: "gpt-4o",
messages: [
{
role: "user",
content: [
{ type: "text", text: "Describe this image in detail. Focus on the main subject, colors, style, artistic technique, and composition. The description will be used to recreate a similar image." },
{ type: "image_url", image_url: { url: dataUrl } }
],
},
],
max_tokens: 300,
});
visionDescription = visionResponse.choices[0].message.content || '';
console.log('[Nanobana] Vision Description:', visionDescription);
} else {
console.warn('[Nanobana] Reference image not found locally:', refUrl);
}
} catch (visionErr) {
console.error('[Nanobana] Vision Error:', visionErr);
// Continue without vision
}
}
// Construct Prompt
let finalPrompt = '';
if (visionDescription) {
finalPrompt = `Create an image based on this visual description: "${visionDescription}".\n\nCONTEXT/MODIFICATIONS REQUESTED: ${prompt || 'Keep the style similar.'}.\n\nIdentity: ${partnerName} (${city}).`;
} else if (prompt) {
finalPrompt = prompt;
} else {
if (type === 'logo') {
finalPrompt = `A professional, minimalist, and modern logo for a company named "${partnerName}". Context: ${description}. Location style: ${city}. High quality, vector style, white background.`;
} else {
finalPrompt = `A stunning, high-quality banner image for a company named "${partnerName}". Context: ${description}. The image should be wide (landscape), professional photography style, inviting, and related to the business topic.`;
}
}
// Generate Image
const response = await openai.images.generate({
model: "dall-e-3",
prompt: finalPrompt,
n: 1,
size: "1024x1024",
quality: "standard",
response_format: "url",
});
const imageUrl = response.data[0].url;
if (!imageUrl) throw new Error('Falha ao gerar imagem na OpenAI');
// Download Image to Local Storage
const ext = '.png'; // DALL-E 3 usually png
const filename = `${uuidv4()}${ext}`;
const localPath = path.join(config.upload.dir, filename);
const publicUrl = `/uploads/${filename}`;
const file = fs.createWriteStream(localPath);
await new Promise((resolve, reject) => {
https.get(imageUrl, function (response) {
response.pipe(file);
file.on('finish', () => {
file.close();
resolve(true);
});
}).on('error', function (err) {
fs.unlink(localPath, () => { });
reject(err);
});
});
// Record Usage
await ctx.db('nanobana_usage').insert({
partner_id: partnerId,
type
});
res.json({
success: true,
url: publicUrl,
originalPrompt: finalPrompt
});
} catch (error: any) {
console.error('Nanobana Error:', error);
let errorMessage = error.message || 'Erro ao gerar imagem';
// Translate Safety Error
if (errorMessage.includes('safety system') || errorMessage.includes('rejected')) {
errorMessage = '⚠️ A imagem não pôde ser gerada pois a descrição ou o contexto viola as políticas de segurança da IA (conteúdo impróprio, marcas protegidas ou termos sensíveis). Tente ajustar o texto para ser mais genérico.';
} else if (errorMessage.includes('billing') || errorMessage.includes('quota')) {
errorMessage = 'Erro de faturamento na IA. Contate o suporte.';
}
res.status(500).json({ error: errorMessage });
}
});
return router;
}
@@ -0,0 +1,20 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const routes_1 = require("./backend/routes");
const manifest_json_1 = __importDefault(require("./manifest.json"));
const plugin = {
manifest: manifest_json_1.default,
async activate(ctx) {
ctx.logger.info('Registering Nanobana routes...');
ctx.app.use(manifest_json_1.default.backend.routePrefix, (0, routes_1.createRoutes)(ctx));
ctx.logger.info('Nanobana activated!');
},
async deactivate(ctx) {
ctx.logger.info('Nanobana deactivated');
},
};
exports.default = plugin;
//# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../plugins/nanobana/index.ts"],"names":[],"mappings":";;;;;AACA,6CAAgD;AAChD,oEAAuC;AAEvC,MAAM,MAAM,GAAmB;IAC3B,QAAQ,EAAE,uBAAe;IACzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC7B,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,gCAAgC,CAAC,CAAC;QAElD,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,uBAAQ,CAAC,OAAO,CAAC,WAAW,EAAE,IAAA,qBAAY,EAAC,GAAG,CAAC,CAAC,CAAC;QAE7D,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC;IAC3C,CAAC;IACD,KAAK,CAAC,UAAU,CAAC,GAAkB;QAC/B,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,sBAAsB,CAAC,CAAC;IAC5C,CAAC;CACJ,CAAC;AAEF,kBAAe,MAAM,CAAC"}
@@ -0,0 +1,19 @@
import { PluginInstance, PluginContext } from '../../backend/src/core/types';
import { createRoutes } from './backend/routes';
import manifest from './manifest.json';
const plugin: PluginInstance = {
manifest: manifest as any,
async activate(ctx: PluginContext): Promise<void> {
ctx.logger.info('Registering Nanobana routes...');
ctx.app.use(manifest.backend.routePrefix, createRoutes(ctx));
ctx.logger.info('Nanobana activated!');
},
async deactivate(ctx: PluginContext): Promise<void> {
ctx.logger.info('Nanobana deactivated');
},
};
export default plugin;
@@ -0,0 +1,18 @@
{
"name": "nanobana",
"displayName": "Nanobana",
"version": "1.0.0",
"description": "Exposes a global API endpoint for system consumption.",
"author": "Clube67",
"category": "utility",
"enabled": true,
"canDisable": true,
"dependencies": [],
"backend": {
"routePrefix": "/api/plugins/nanobana",
"hasMigrations": true
},
"frontend": {
"menuItems": []
}
}
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "..",
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"sourceMap": true,
"baseUrl": ".",
"paths": {
"@core/*": [
"../backend/src/core/*"
]
}
},
"include": [
"./**/*.ts"
],
"exclude": [
"node_modules",
"dist",
"**/migrations/**"
]
}
@@ -0,0 +1,65 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createUploadRoutes = createUploadRoutes;
const express_1 = require("express");
const multer_1 = __importDefault(require("multer"));
const path_1 = __importDefault(require("path"));
const fs_1 = __importDefault(require("fs"));
function createUploadRoutes(ctx) {
const router = (0, express_1.Router)();
const uploadDir = process.env.UPLOAD_DIR || path_1.default.join(process.cwd(), 'uploads');
if (!fs_1.default.existsSync(uploadDir))
fs_1.default.mkdirSync(uploadDir, { recursive: true });
const storage = multer_1.default.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadDir),
filename: (_req, file, cb) => cb(null, `${Date.now()}-${file.originalname}`),
});
const upload = (0, multer_1.default)({ storage, limits: { fileSize: 5 * 1024 * 1024 } });
// POST / — Upload file
router.post('/', upload.single('file'), async (req, res) => {
try {
if (!req.file)
return res.status(400).json({ error: 'Nenhum arquivo enviado' });
const url = `/uploads/${req.file.filename}`;
await ctx.hooks.emit('upload:completed', { filename: req.file.filename, url });
res.json({ url, filename: req.file.filename, size: req.file.size });
}
catch (err) {
res.status(500).json({ error: 'Erro no upload' });
}
});
// POST /logo — Upload partner logo
router.post('/logo', upload.single('logo'), async (req, res) => {
try {
if (!req.file)
return res.status(400).json({ error: 'Nenhum logo enviado' });
const url = `/uploads/${req.file.filename}`;
res.json({ url, filename: req.file.filename });
}
catch (err) {
res.status(500).json({ error: 'Erro no upload' });
}
});
// DELETE /:filename — Delete file
router.delete('/:filename', async (req, res) => {
try {
const filePath = path_1.default.join(uploadDir, req.params.filename);
if (fs_1.default.existsSync(filePath)) {
fs_1.default.unlinkSync(filePath);
await ctx.hooks.emit('upload:deleted', { filename: req.params.filename });
res.json({ message: 'Arquivo removido' });
}
else {
res.status(404).json({ error: 'Arquivo não encontrado' });
}
}
catch (err) {
res.status(500).json({ error: 'Erro ao remover' });
}
});
return router;
}
//# sourceMappingURL=routes.js.map
@@ -0,0 +1 @@
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../../../../../plugins/uploads/backend/routes.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,159 @@
import { Router, Request, Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { PluginContext } from '../../../backend/src/core/types';
export function createUploadRoutes(ctx: PluginContext): Router {
const router = Router();
const uploadDir = process.env.UPLOAD_DIR || path.join(process.cwd(), 'uploads');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadDir),
filename: (_req, file, cb) => cb(null, `${Date.now()}-${file.originalname}`),
});
const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 } });
// POST / — Upload file
router.post('/', upload.single('file'), async (req: Request, res: Response) => {
try {
if (!req.file) return res.status(400).json({ error: 'Nenhum arquivo enviado' });
const destination = req.query.destination as string;
const category = req.query.category as any || 'media';
let fileBuffer = fs.readFileSync(req.file.path);
let mimetype = req.file.mimetype;
// Emit transformation hook
const transformResults = await ctx.hooks.emit('upload:transform', {
buffer: fileBuffer,
mimetype: mimetype,
category: category,
originalname: req.file.originalname,
partnerId: req.body.partnerId
});
// If a plugin transformed the file, use the last result
if (transformResults && transformResults.length > 0) {
const lastResult = transformResults[transformResults.length - 1];
if (lastResult && lastResult.buffer) {
fileBuffer = lastResult.buffer;
mimetype = lastResult.mimetype || mimetype;
}
}
if (destination === 'cloud') {
if (fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path);
// Use a more robust import for the singleton
const storageCore = require('../../../../backend/src/core/StorageProvider');
const provider = storageCore.storageProvider || (storageCore.default && storageCore.default.storageProvider);
if (!provider) {
ctx.logger.error('[Uploads] StorageProvider not found in core');
return res.status(500).json({ error: 'Storage core unavailable' });
}
const cloudResult = await provider.uploadFile({
category,
partnerId: req.body.partnerId,
benefitId: req.body.benefitId,
postId: req.body.postId,
whatsappId: req.body.whatsappId,
file: {
originalname: req.file.originalname,
buffer: fileBuffer,
mimetype: mimetype
}
});
return res.json({
url: `https://${process.env.WASABI_BUCKET}.s3.wasabisys.com/${cloudResult.path}`,
filename: req.file.filename,
provider: 'wasabi'
});
}
// Local storage fallback: save the potentially transformed buffer
if (transformResults && transformResults.length > 0) {
fs.writeFileSync(req.file.path, fileBuffer);
}
const url = `/uploads/${req.file.filename}`;
await ctx.hooks.emit('upload:completed', { filename: req.file.filename, url });
res.json({ url, filename: req.file.filename, size: fileBuffer.length });
} catch (err: any) {
console.error('[Uploads] Error:', err);
res.status(500).json({ error: 'Erro no upload' });
}
});
// POST /logo — Upload partner logo
router.post('/logo', upload.single('logo'), async (req: Request, res: Response) => {
try {
if (!req.file) return res.status(400).json({ error: 'Nenhum logo enviado' });
let fileBuffer = fs.readFileSync(req.file.path);
let mimetype = req.file.mimetype;
// Emit transformation hook
const transformResults = await ctx.hooks.emit('upload:transform', {
buffer: fileBuffer,
mimetype: mimetype,
category: 'partners',
originalname: req.file.originalname
});
if (transformResults && transformResults.length > 0) {
const lastResult = transformResults[transformResults.length - 1];
if (lastResult && lastResult.buffer) {
fileBuffer = lastResult.buffer;
mimetype = lastResult.mimetype || mimetype;
// Overwrite local file with optimized version
fs.writeFileSync(req.file.path, fileBuffer);
}
}
const url = `/uploads/${req.file.filename}`;
res.json({ url, filename: req.file.filename });
} catch (err: any) {
console.error('[Uploads] Logo Error:', err);
res.status(500).json({ error: 'Erro no upload do logo' });
}
});
// DELETE /:filename — Delete file
router.delete('/:filename', async (req: Request, res: Response) => {
try {
const filePath = path.join(uploadDir, req.params.filename);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
await ctx.hooks.emit('upload:deleted', { filename: req.params.filename });
res.json({ message: 'Arquivo removido' });
} else {
res.status(404).json({ error: 'Arquivo não encontrado' });
}
} catch (err: any) { res.status(500).json({ error: 'Erro ao remover' }); }
});
// GET /:filename — Legacy access with Washabi Fallback 🛡️
router.get('/:filename', async (req: Request, res: Response) => {
const { filename } = req.params;
const localPath = path.join(uploadDir, filename);
// 🛡️ If exists locally, serve normally
if (fs.existsSync(localPath)) {
return res.sendFile(localPath);
}
// 🚀 If NOT local, redirect or proxy to UnifiedStorageProvider
// We know most legacy uploads go to the 'media' category in the proxy
const storageProxyUrl = `/api/storage/view/media/${filename}`;
ctx.logger.info(`[Uploads:Fallback] Redirecting legacy file ${filename} -> ${storageProxyUrl}`);
res.redirect(storageProxyUrl);
});
return router;
}
@@ -0,0 +1,17 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const routes_1 = require("./backend/routes");
const manifest_json_1 = __importDefault(require("./manifest.json"));
const plugin = {
manifest: manifest_json_1.default,
async activate(ctx) {
ctx.app.use('/api/uploads', (0, routes_1.createUploadRoutes)(ctx));
ctx.logger.info('Upload routes registered');
},
async deactivate(ctx) { ctx.logger.info('Uploads deactivated'); },
};
exports.default = plugin;
//# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../plugins/uploads/index.ts"],"names":[],"mappings":";;;;;AACA,6CAAsD;AACtD,oEAAuC;AAEvC,MAAM,MAAM,GAAmB;IAC3B,QAAQ,EAAE,uBAAe;IACzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC7B,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,cAAc,EAAE,IAAA,2BAAkB,EAAC,GAAG,CAAC,CAAC,CAAC;QACrD,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAC;IAChD,CAAC;IACD,KAAK,CAAC,UAAU,CAAC,GAAkB,IAAmB,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAC;CAClG,CAAC;AACF,kBAAe,MAAM,CAAC"}
@@ -0,0 +1,13 @@
import { PluginInstance, PluginContext } from '../../backend/src/core/types';
import { createUploadRoutes } from './backend/routes';
import manifest from './manifest.json';
const plugin: PluginInstance = {
manifest: manifest as any,
async activate(ctx: PluginContext): Promise<void> {
ctx.app.use('/api/uploads', createUploadRoutes(ctx));
ctx.logger.info('Upload routes registered');
},
async deactivate(ctx: PluginContext): Promise<void> { ctx.logger.info('Uploads deactivated'); },
};
export default plugin;
@@ -0,0 +1,27 @@
{
"name": "uploads",
"displayName": "Uploads & Mídia",
"version": "1.0.0",
"description": "Upload de logos, imagens e documentos com suporte a S3.",
"author": "Clube67",
"category": "core",
"enabled": true,
"canDisable": false,
"dependencies": [
"core-auth"
],
"backend": {
"routePrefix": "/api/uploads",
"hasMigrations": false
},
"frontend": {
"menuItems": []
},
"hooks": {
"subscribes": [],
"emits": [
"upload:completed",
"upload:deleted"
]
}
}