chore(ops): migrate clube67 to newwhats.clube67.com directory
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
This commit is contained in:
-63
@@ -1,63 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
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;
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
-296
@@ -1,296 +0,0 @@
|
||||
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;
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
"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
|
||||
-1
File diff suppressed because one or more lines are too long
@@ -1,180 +0,0 @@
|
||||
// ============================================================
|
||||
// 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;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
"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
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,23 +0,0 @@
|
||||
// ============================================================
|
||||
// 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;
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
-117
@@ -1,117 +0,0 @@
|
||||
"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
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../../../../../plugins/financeiro/backend/routes.ts"],"names":[],"mappings":"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"}
|
||||
-123
@@ -1,123 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
"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
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,25 +0,0 @@
|
||||
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;
|
||||
@@ -1,66 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
"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
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../../../../../plugins/google-oauth/backend/routes.ts"],"names":[],"mappings":"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"}
|
||||
-120
@@ -1,120 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
"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
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,13 +0,0 @@
|
||||
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;
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
"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
@@ -1,247 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
"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
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,22 +0,0 @@
|
||||
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;
|
||||
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
"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
|
||||
-1
File diff suppressed because one or more lines are too long
@@ -1,183 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
"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
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,19 +0,0 @@
|
||||
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;
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"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/**"
|
||||
]
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
"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
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../../../../../plugins/uploads/backend/routes.ts"],"names":[],"mappings":"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"}
|
||||
@@ -1,159 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
"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
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,13 +0,0 @@
|
||||
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;
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user