import express, { Request, Response, NextFunction } from 'express'; import path from 'path'; import jwt from 'jsonwebtoken'; import bcrypt from 'bcryptjs'; import multer from 'multer'; import axios from 'axios'; import { execSync } from 'child_process'; import { S3Client, PutObjectCommand, CreateBucketCommand, HeadBucketCommand, PutBucketPolicyCommand } from '@aws-sdk/client-s3'; import crypto from 'crypto'; import nodemailer from 'nodemailer'; import { prisma } from './src/db/prisma'; import { Prisma } from '@prisma/client'; import { AsaasService } from './src/services/asaas'; import { User, Course, Module, Lesson, Note, LessonProgress, WebhookLog, SubscriptionPayment, SubscriptionStatus } from './src/types'; // Override default serialization of Decimal fields to return a normal JavaScript number (Prisma.Decimal.prototype as any).toJSON = function () { return this.toNumber(); }; // JWT Secret Key const JWT_SECRET = process.env.JWT_SECRET || 'devflix_secret_token_key_13579'; const app = express(); const PORT = 3000; app.use(express.json()); // --- MINIO / S3 CONFIGURATION --- const s3 = new S3Client({ region: 'us-east-1', endpoint: 'http://minio-microtecflix:9000', credentials: { accessKeyId: process.env.MINIO_ROOT_USER || 'microtecflix_admin', secretAccessKey: process.env.MINIO_ROOT_PASSWORD || 'MicrotecFlixS3SecurePass2026!' }, forcePathStyle: true }); const upload = multer({ storage: multer.memoryStorage() }); async function initMinio() { const bucketName = 'microtecflix'; try { await s3.send(new HeadBucketCommand({ Bucket: bucketName })); console.log('✅ MinIO bucket already exists.'); } catch (err: any) { if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404 || err.message.includes('404')) { console.log('⚠️ Creating MinIO bucket...'); try { await s3.send(new CreateBucketCommand({ Bucket: bucketName })); const policy = { Version: "2012-10-17", Statement: [ { Sid: "PublicReadGetObject", Effect: "Allow", Principal: "*", Action: "s3:GetObject", Resource: `arn:aws:s3:::${bucketName}/*` } ] }; await s3.send(new PutBucketPolicyCommand({ Bucket: bucketName, Policy: JSON.stringify(policy) })); console.log('✅ MinIO bucket created and set to public read.'); } catch (createErr) { console.error('❌ Failed to create or configure MinIO bucket:', createErr); } } else { console.error('❌ Error checking MinIO bucket:', err); } } } // Start initialization async, don't block server start // Start initialization async, don't block server start initMinio(); // --- SMTP EMAILS CONFIGURATION --- async function getTransporter() { const settings = await prisma.systemSettings.findFirst(); if (!settings || !settings.smtpHost) { throw new Error('As credenciais SMTP não estão configuradas.'); } return nodemailer.createTransport({ host: settings.smtpHost, port: settings.smtpPort || 587, secure: settings.smtpSecure, auth: { user: settings.smtpUser, pass: settings.smtpPass, }, }); } // --- JWT AUTH MIDDLEWARE --- interface AuthenticatedRequest extends Request { user?: { id: string; email: string; role: 'admin' | 'student'; subscriptionStatus: SubscriptionStatus; }; } function authenticateToken(req: AuthenticatedRequest, res: Response, next: NextFunction): void { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; if (!token) { res.status(401).json({ error: 'Token de autenticação não fornecido' }); return; } jwt.verify(token, JWT_SECRET, (err: any, decoded: any) => { if (err) { res.status(403).json({ error: 'Token inválido ou expirado' }); return; } req.user = decoded; next(); }); } function requireAdmin(req: AuthenticatedRequest, res: Response, next: NextFunction): void { if (!req.user || req.user.role !== 'admin') { res.status(403).json({ error: 'Acesso restrito para administradores' }); return; } next(); } // --- API ROUTES --- // 1. Auth: Register app.post('/api/auth/register', async (req: Request, res: Response) => { const { name, email, password, cpf, birthDate, whatsapp } = req.body; if (!name || !email || !password || !cpf || !birthDate || !whatsapp) { res.status(400).json({ error: 'Preencha todos os campos obrigatórios' }); return; } try { const userExists = await prisma.user.findUnique({ where: { email: email.toLowerCase() } }); if (userExists) { res.status(400).json({ error: 'E-mail já cadastrado na plataforma' }); return; } // Generate Asaas Customer ID (Real call if configured, or simulated) const asaasCustomerId = await AsaasService.createCustomer(name, email, cpf); const salt = bcrypt.genSaltSync(10); const passwordHash = bcrypt.hashSync(password, salt); const createdAt = new Date(); // Calculate trial ends at const settings = await prisma.systemSettings.findUnique({ where: { id: 'default' } }); const trialDays = settings?.trialDurationDays || 7; const trialEndsAt = new Date(createdAt); trialEndsAt.setDate(trialEndsAt.getDate() + trialDays); const newUser = await prisma.user.create({ data: { name, email: email.toLowerCase(), password: passwordHash, role: 'student', subscriptionStatus: 'TRIAL', asaasCustomerId, // @ts-ignore cpf, whatsapp, birthDate, trialEndsAt: trialEndsAt.toISOString(), // Assuming these are added to schema later or stored differently if not in schema. Wait, these aren't in prisma schema! I need to add them. } }); // Generate JWT const token = jwt.sign( { id: newUser.id, email: newUser.email, role: newUser.role, subscriptionStatus: newUser.subscriptionStatus }, JWT_SECRET, { expiresIn: '7d' } ); res.status(201).json({ token, user: newUser }); } catch (err: any) { res.status(500).json({ error: err.message || 'Erro ao registrar usuário' }); } }); // 2. Auth: Login app.post('/api/auth/login', async (req: Request, res: Response) => { const { email, password } = req.body; // email field represents identifier (email or whatsapp) if (!email || !password) { res.status(400).json({ error: 'E-mail ou WhatsApp e senha são obrigatórios' }); return; } try { let user; if (email.includes('@')) { user = await prisma.user.findFirst({ where: { email: email.toLowerCase() } }); } else { const cleanPhone = email.replace(/\D/g, ''); const withoutDDI = cleanPhone.replace(/^55/, ''); const withDDI = '55' + withoutDDI; user = await prisma.user.findFirst({ where: { OR: [ { whatsapp: cleanPhone }, { whatsapp: withoutDDI }, { whatsapp: withDDI } ] } }); } if (!user || !user.password) { res.status(401).json({ error: 'Credenciais inválidas' }); return; } const passwordValid = bcrypt.compareSync(password, user.password); if (!passwordValid) { res.status(401).json({ error: 'Credenciais inválidas' }); return; } const token = jwt.sign( { id: user.id, email: user.email, role: user.role, subscriptionStatus: user.subscriptionStatus }, JWT_SECRET, { expiresIn: '7d' } ); res.json({ token, user }); } catch (err: any) { res.status(500).json({ error: 'Erro no servidor' }); } }); // 2.5 Auth: Forgot Password app.post('/api/auth/forgot-password', async (req: Request, res: Response) => { const { identifier, channel } = req.body; if (!identifier || !channel) { res.status(400).json({ error: 'Informe o identificador e o canal (email ou whatsapp).' }); return; } try { let user; if (channel === 'whatsapp') { const cleanIdentifier = identifier.replace(/\D/g, ''); const numberWithoutDDI = cleanIdentifier.replace(/^55/, ''); const numberWithDDI = '55' + numberWithoutDDI; user = await prisma.user.findFirst({ where: { OR: [ { whatsapp: cleanIdentifier }, { whatsapp: numberWithoutDDI }, { whatsapp: numberWithDDI } ] } }); } else { user = await prisma.user.findFirst({ where: { email: identifier.toLowerCase() } }); } if (!user) { res.json({ success: true, message: 'Se o usuário existir, o link foi enviado.' }); return; } const token = crypto.randomBytes(32).toString('hex'); const expires = new Date(Date.now() + 3600000); // 1 hour await prisma.user.update({ where: { id: user.id }, data: { resetPasswordToken: token, resetPasswordExpires: expires } }); const resetUrl = `https://estudo.microtecinformaticacurso.com.br/reset-password?token=${token}`; if (channel === 'whatsapp') { const text = `*Recuperação de Senha - MicrotecFlix*\n\nVocê solicitou a recuperação da sua senha.\nClique no link abaixo para criar uma nova senha:\n\n${resetUrl}\n\n_Se você não solicitou isso, ignore esta mensagem._`; await sendWhatsappNotification(user.whatsapp || identifier, text); } else { const transporter = await getTransporter(); const settings = await prisma.systemSettings.findFirst(); await transporter.sendMail({ from: `"${settings?.brandName || 'Microtec'}Flix" <${settings?.smtpFrom || 'no-reply@microtecinformaticacurso.com.br'}>`, to: user.email, subject: 'Recuperação de Senha', html: `

${settings?.brandName || 'MICROTEC'}FLIX

Recuperação de Senha

Você solicitou a recuperação da sua senha. Clique no botão abaixo para redefini-la (válido por 1 hora).

Redefinir Senha

Se você não solicitou, apenas ignore este e-mail.

` }); } res.json({ success: true, message: 'Link de recuperação enviado com sucesso.' }); } catch (error: any) { console.error('Forgot password error:', error); res.status(500).json({ error: error.message || 'Erro ao enviar link de recuperação.' }); } }); // 2.6 Auth: Reset Password app.post('/api/auth/reset-password', async (req: Request, res: Response) => { const { token, newPassword } = req.body; if (!token || !newPassword) { res.status(400).json({ error: 'Token e nova senha são obrigatórios.' }); return; } try { const user = await prisma.user.findFirst({ where: { resetPasswordToken: token, resetPasswordExpires: { gte: new Date() } } }); if (!user) { res.status(400).json({ error: 'Token inválido ou expirado.' }); return; } const hashedPassword = bcrypt.hashSync(newPassword, 10); await prisma.user.update({ where: { id: user.id }, data: { password: hashedPassword, resetPasswordToken: null, resetPasswordExpires: null } }); res.json({ success: true, message: 'Senha redefinida com sucesso!' }); } catch (error) { console.error('Reset password error:', error); res.status(500).json({ error: 'Erro ao redefinir senha.' }); } }); // 3. Auth: Me app.get('/api/auth/me', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { if (!req.user) { res.status(401).json({ error: 'Não autorizado' }); return; } try { const user = await prisma.user.findUnique({ where: { id: req.user.id } }); if (!user) { res.status(404).json({ error: 'Usuário não encontrado' }); return; } res.json(user); } catch(err) { res.status(500).json({ error: 'Erro no servidor' }); } }); // 3.1. Profile: Update Data app.put('/api/users/profile', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const { name, whatsapp, birthDate, cpf } = req.body; const userId = req.user!.id; try { const user = await prisma.user.update({ where: { id: userId }, data: { ...(name !== undefined && { name }), ...(whatsapp !== undefined && { whatsapp }), ...(birthDate !== undefined && { birthDate }), ...(cpf !== undefined && { cpf }) } }); res.json({ success: true, user }); } catch (error) { res.status(404).json({ error: 'Usuário não encontrado' }); } }); // 3.2. Profile: Avatar Upload app.post('/api/users/avatar', authenticateToken, upload.single('avatar'), async (req: AuthenticatedRequest, res: Response) => { const userId = req.user!.id; if (!req.file) { res.status(400).json({ error: 'Nenhum arquivo enviado' }); return; } const ext = req.file.originalname.split('.').pop(); const filename = `avatars/${userId}_${Date.now()}.${ext}`; try { await s3.send(new PutObjectCommand({ Bucket: 'microtecflix', Key: filename, Body: req.file.buffer, ContentType: req.file.mimetype })); // URL Pública configurada via Traefik const avatarUrl = `https://s3-estudo.microtecinformaticacurso.com.br/microtecflix/${filename}`; await prisma.user.update({ where: { id: userId }, data: { avatarUrl } }); res.json({ success: true, avatarUrl }); } catch (err: any) { console.error('Erro no upload da foto:', err); res.status(500).json({ error: 'Erro ao salvar foto no MinIO' }); } }); // Admin Image/File Upload directly to MinIO S3 app.post('/api/admin/upload', authenticateToken, upload.single('file'), async (req: AuthenticatedRequest, res: Response) => { if (!req.file) { res.status(400).json({ error: 'Nenhum arquivo enviado' }); return; } const ext = req.file.originalname.split('.').pop(); const filename = `uploads/${Date.now()}_${Math.random().toString(36).substring(2, 8)}.${ext}`; try { await s3.send(new PutObjectCommand({ Bucket: 'microtecflix', Key: filename, Body: req.file.buffer, ContentType: req.file.mimetype })); const url = `https://s3-estudo.microtecinformaticacurso.com.br/microtecflix/${filename}`; res.json({ success: true, url }); } catch (err: any) { console.error('Erro no upload para S3:', err); res.status(500).json({ error: 'Erro ao salvar arquivo no S3' }); } }); // 4. Courses List (With Progress) app.get('/api/courses', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const userId = req.user!.id; const user = await prisma.user.findUnique({ where: { id: userId } }); const unlockedCourses = user?.unlockedCourses || []; const isAdmin = user?.role === 'admin'; const courses = await prisma.course.findMany({ include: { modules: { include: { lessons: true } } } }); const userProgress = await prisma.progress.findMany({ where: { userId, completed: true } }); const userProgressLessonIds = userProgress.map(p => p.lessonId); const coursesWithProgress = courses.map(course => { const sortedModules = [...course.modules].sort((a, b) => a.order - b.order); const courseLessons = sortedModules.flatMap(m => { const sortedLessons = [...m.lessons].sort((a, b) => a.order - b.order); return sortedLessons.map(l => ({ id: l.id, title: l.title, thumbnailUrl: l.thumbnailUrl, order: l.order, moduleId: m.id })); }); const totalLessons = courseLessons.length; let completedLessons = 0; if (totalLessons > 0) { completedLessons = courseLessons.filter(l => userProgressLessonIds.includes(l.id)).length; } const progressPercentage = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0; return { id: course.id, title: course.title, description: course.description, thumbnail: course.thumbnail, isLocked: course.isLocked, price: course.price, category: course.category, totalLessons, completedLessons, progress: progressPercentage, isUnlocked: !course.isLocked || unlockedCourses.includes(course.id) || isAdmin, lessons: courseLessons }; }); res.json(coursesWithProgress); }); // 5. Course Details (with Modules, Lessons, Progress & Notes) app.get('/api/courses/:id', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const userId = req.user!.id; const courseId = req.params.id; const course = await prisma.course.findUnique({ where: { id: courseId }, include: { modules: { orderBy: { order: 'asc' }, include: { lessons: { orderBy: { order: 'asc' } } } } } }); if (!course) { res.status(404).json({ error: 'Curso não encontrado' }); return; } const user = await prisma.user.findUnique({ where: { id: userId } }); const unlockedCourses = user?.unlockedCourses || []; const isAdmin = user?.role === 'admin'; const isUnlocked = !course.isLocked || unlockedCourses.includes(course.id) || isAdmin; const progressRecords = await prisma.progress.findMany({ where: { userId, lesson: { moduleId: { in: course.modules.map(m => m.id) } } } }); const noteRecords = await prisma.note.findMany({ where: { userId, lesson: { moduleId: { in: course.modules.map(m => m.id) } } } }); const modulesWithLessons = course.modules.map(mod => { const lessons = mod.lessons.map(lesson => { const prog = progressRecords.find(p => p.lessonId === lesson.id); const note = noteRecords.find(n => n.lessonId === lesson.id); return { ...lesson, progress: prog ? { watchedPercentage: prog.watchedPercentage, completed: prog.completed } : { watchedPercentage: 0, completed: false }, note: note ? note.content : '' }; }); return { ...mod, lessons }; }); const { modules, ...courseData } = course; res.json({ course: { ...courseData, isUnlocked }, modules: modulesWithLessons }); }); // Rate limiting protection for payment checkout endpoints const checkoutRateLimitMap = new Map(); const checkoutRateLimiter = (req: Request, res: Response, next: Function) => { const ip = req.ip || req.socket.remoteAddress || 'unknown'; const now = Date.now(); const record = checkoutRateLimitMap.get(ip) || { count: 0, resetAt: now + 60000 }; if (now > record.resetAt) { record.count = 1; record.resetAt = now + 60000; } else { record.count += 1; } checkoutRateLimitMap.set(ip, record); if (record.count > 15) { res.status(429).json({ error: 'Muitas tentativas de pagamento num curto período. Aguarde um minuto e tente novamente.' }); return; } next(); }; // 5.5 Unlock Course app.post('/api/courses/:id/unlock', authenticateToken, checkoutRateLimiter, async (req: AuthenticatedRequest, res: Response) => { const courseId = req.params.id; const userId = req.user!.id; const { billingType, creditCardInfo, installmentCount, cpf } = req.body; const user = await prisma.user.findUnique({ where: { id: userId } }); const course = await prisma.course.findUnique({ where: { id: courseId } }); if (!user) { res.status(404).json({ error: 'Usuário não encontrado' }); return; } if (!course) { res.status(404).json({ error: 'Curso não encontrado' }); return; } try { const activeCpf = cpf || user.cpf; if (!activeCpf || activeCpf === '000.000.000-00') { res.status(400).json({ error: 'Para realizar o pagamento é obrigatório fornecer um CPF ou CNPJ válido.' }); return; } if (activeCpf && activeCpf !== user.cpf) { await prisma.user.update({ where: { id: userId }, data: { cpf: activeCpf } }); user.cpf = activeCpf; } let customerId = user.asaasCustomerId; const apiKey = await AsaasService.getApiKey(); if (apiKey && (customerId?.startsWith('cus_sim_') || customerId?.startsWith('cus_fallback_') || ['cus_test', 'cus_00001', 'cus_00002', 'cus_00003', 'cus_00004'].includes(customerId || ''))) { customerId = null; } if (!customerId) { customerId = await AsaasService.createCustomer(user.name, user.email, activeCpf); await prisma.user.update({ where: { id: userId }, data: { asaasCustomerId: customerId } }); user.asaasCustomerId = customerId; } else { await AsaasService.updateCustomer(customerId, { cpfCnpj: activeCpf }); } let processedCardInfo = creditCardInfo; // Tokenize credit card if raw info is provided (PCI-DSS Compliance) if (billingType === 'CREDIT_CARD' && creditCardInfo?.creditCard?.number) { try { const tokenResult = await AsaasService.tokenizeCreditCard( customerId, creditCardInfo.creditCard, creditCardInfo.creditCardHolderInfo ); processedCardInfo = { creditCardToken: tokenResult.creditCardToken, creditCardNumberLast4: tokenResult.creditCardNumber }; } catch (tokErr) { console.warn('Tokenization fallback to direct API transmission:', tokErr); } } const installments = installmentCount ? Number(installmentCount) : 1; const asaasPayment = await AsaasService.createPayment( customerId, course.price || 49.90, billingType || 'PIX', `unlock_course_${courseId}`, processedCardInfo, installments ); let pixData = null; if ((billingType || 'PIX') === 'PIX') { pixData = await AsaasService.getPixQrCode(asaasPayment.id); } const newPayment = await prisma.payment.create({ data: { id: asaasPayment.id, userId: userId, value: course.price || 49.90, status: 'PENDING', billingType: billingType || 'PIX', invoiceUrl: asaasPayment.invoiceUrl, dueDate: asaasPayment.dueDate ? new Date(asaasPayment.dueDate) : new Date(), courseId: courseId } }); res.json({ success: true, payment: newPayment, pixData }); } catch (err: any) { console.error('Course unlock error:', err); res.status(400).json({ error: err.message || 'Erro ao processar pagamento do curso' }); } }); // Route to cancel a pending payment app.post('/api/payments/:id/cancel', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const paymentId = req.params.id; try { const payment = await prisma.payment.findUnique({ where: { id: paymentId } }); if (!payment) { res.status(404).json({ error: 'Pagamento não encontrado.' }); return; } if (payment.status === 'PENDING') { const deleted = await AsaasService.deletePayment(paymentId); if (deleted) { await prisma.payment.update({ where: { id: paymentId }, data: { status: 'CANCELED' } }); res.json({ success: true, message: 'Pagamento cancelado com sucesso.' }); return; } } res.status(400).json({ error: 'Não foi possível cancelar este pagamento.' }); } catch (err: any) { console.error('Erro ao cancelar pagamento:', err.message); res.status(500).json({ error: 'Erro no servidor.' }); } }); // 6. Lesson Progress tracking app.post('/api/lessons/:id/progress', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const { watchedPercentage } = req.body; const lessonId = req.params.id; const userId = req.user!.id; if (watchedPercentage === undefined) { res.status(400).json({ error: 'watchedPercentage é obrigatório' }); return; } const lesson = await prisma.lesson.findUnique({ where: { id: lessonId } }); if (!lesson) { res.status(404).json({ error: 'Aula não encontrada' }); return; } const isCompleted = watchedPercentage >= 90; try { const existingProgress = await prisma.progress.findFirst({ where: { userId, lessonId } }); if (existingProgress) { await prisma.progress.update({ where: { id: existingProgress.id }, data: { watchedPercentage: Math.max(existingProgress.watchedPercentage, watchedPercentage), completed: isCompleted ? true : existingProgress.completed } }); } else { await prisma.progress.create({ data: { userId, lessonId, watchedPercentage, completed: isCompleted } }); } res.json({ success: true, completed: isCompleted }); } catch (err: any) { res.status(500).json({ error: 'Erro ao salvar progresso' }); } }); // 7. Lesson Notes and Doubts app.get('/api/lessons/:id/notes', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const lessonId = req.params.id; const userId = req.user!.id; const note = await prisma.note.findFirst({ where: { userId, lessonId } }); res.json({ content: note ? note.content : '' }); }); app.post('/api/lessons/:id/notes', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const { content } = req.body; const lessonId = req.params.id; const userId = req.user!.id; try { const existingNote = await prisma.note.findFirst({ where: { userId, lessonId } }); if (existingNote) { await prisma.note.update({ where: { id: existingNote.id }, data: { content } }); } else { await prisma.note.create({ data: { userId, lessonId, content } }); } res.json({ success: true }); } catch (err: any) { res.status(500).json({ error: 'Erro ao salvar anotação' }); } }); // 8. Subscription status & Invoice history app.get('/api/subscription/status', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const userId = req.user!.id; const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) { res.status(404).json({ error: 'Usuário não encontrado' }); return; } const payments = await prisma.payment.findMany({ where: { userId }, orderBy: { createdAt: 'desc' } }); res.json({ status: user.subscriptionStatus, asaasCustomerId: user.asaasCustomerId, payments }); }); // 9. Subscribe to plan app.post('/api/subscription/subscribe', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const { billingType, creditCardInfo, planId, cpf } = req.body; const userId = req.user!.id; if (!billingType) { res.status(400).json({ error: 'Tipo de cobrança é obrigatório (PIX, CREDIT_CARD ou BOLETO)' }); return; } const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) { res.status(404).json({ error: 'Usuário não encontrado' }); return; } try { const activeCpf = cpf || user.cpf; if (!activeCpf || activeCpf === '000.000.000-00') { res.status(400).json({ error: 'Para realizar a assinatura é obrigatório fornecer um CPF ou CNPJ válido.' }); return; } if (activeCpf && activeCpf !== user.cpf) { await prisma.user.update({ where: { id: userId }, data: { cpf: activeCpf } }); user.cpf = activeCpf; } let customerId = user.asaasCustomerId; const apiKey = await AsaasService.getApiKey(); if (apiKey && (customerId?.startsWith('cus_sim_') || customerId?.startsWith('cus_fallback_') || ['cus_test', 'cus_00001', 'cus_00002', 'cus_00003', 'cus_00004'].includes(customerId || ''))) { customerId = null; } if (!customerId) { customerId = await AsaasService.createCustomer(user.name, user.email, activeCpf); await prisma.user.update({ where: { id: userId }, data: { asaasCustomerId: customerId } }); user.asaasCustomerId = customerId; } else { await AsaasService.updateCustomer(customerId, { cpfCnpj: activeCpf }); } let plan = planId ? await prisma.plan.findUnique({ where: { id: planId } }) : null; let price = plan ? plan.price : 49.90; let cycle = plan ? plan.cycle : 'MONTHLY'; let description = plan ? `Plano: ${plan.title}` : 'Assinatura Mensal DevFlix'; let asaasPaymentId = ''; let invoiceUrlStr = ''; let statusAsaas = billingType === 'CREDIT_CARD' ? 'CONFIRMED' : 'PENDING'; let asaasDueDate = new Date(Date.now() + 3 * 24 * 3600 * 1000).toISOString().split('T')[0]; let pixData = null; if (cycle === 'LIFETIME') { let processedCardInfo = creditCardInfo; if (billingType === 'CREDIT_CARD' && creditCardInfo?.creditCard?.number) { try { const tokenResult = await AsaasService.tokenizeCreditCard( customerId, creditCardInfo.creditCard, creditCardInfo.creditCardHolderInfo ); processedCardInfo = { creditCardToken: tokenResult.creditCardToken, creditCardNumberLast4: tokenResult.creditCardNumber }; } catch (tokErr) { console.warn('Tokenization fallback to direct API transmission:', tokErr); } } const asaasPayment = await AsaasService.createPayment( customerId, price, billingType, `plan_${planId}`, processedCardInfo, 1 ); asaasPaymentId = asaasPayment.id; invoiceUrlStr = asaasPayment.invoiceUrl; asaasDueDate = asaasPayment.dueDate || asaasDueDate; if (billingType === 'PIX') { pixData = await AsaasService.getPixQrCode(asaasPayment.id); } } else { const { subscriptionId, payment } = await AsaasService.createSubscription( customerId, price, billingType, creditCardInfo, cycle as any, description ); asaasPaymentId = payment.id || `pay_${Math.random().toString(36).substring(2, 9)}`; invoiceUrlStr = payment.invoiceUrl || ''; asaasDueDate = payment.dueDate || asaasDueDate; // Also fetch PIX QR code for the first installment of recurring plans if (billingType === 'PIX' && asaasPaymentId && !asaasPaymentId.startsWith('pay_')) { try { pixData = await AsaasService.getPixQrCode(asaasPaymentId); } catch (pixErr) { console.warn('Could not fetch PIX QR Code for subscription payment:', pixErr); } } } const newPayment = await prisma.payment.create({ data: { id: asaasPaymentId, userId: userId, value: price, status: statusAsaas as any, billingType: billingType, invoiceUrl: invoiceUrlStr, dueDate: new Date(asaasDueDate), paidAt: statusAsaas === 'CONFIRMED' ? new Date() : null, planId: planId } }); if (statusAsaas === 'CONFIRMED') { if (cycle === 'LIFETIME' && plan) { if (!plan.includedCourses || plan.includedCourses.length === 0) { await prisma.user.update({ where: { id: userId }, data: { subscriptionStatus: 'ACTIVE' } }); } else { await prisma.user.update({ where: { id: userId }, data: { unlockedCourses: { set: [...new Set([...(user.unlockedCourses || []), ...plan.includedCourses])] } } }); } } else { await prisma.user.update({ where: { id: userId }, data: { subscriptionStatus: 'ACTIVE' } }); } } res.json({ success: true, payment: newPayment, pixData }); } catch (err: any) { console.error('Subscription error:', err); res.status(400).json({ error: err.message || 'Erro ao processar assinatura' }); } }); // --- ADMIN API ENDPOINTS --- // 1. Dashboard statistics // 1. Dashboard statistics app.get('/api/admin/dashboard', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const users = await prisma.user.findMany({ where: { role: 'student' } }); const totalActive = users.filter(u => u.subscriptionStatus === 'ACTIVE').length; const totalOverdue = users.filter(u => u.subscriptionStatus === 'OVERDUE').length; const totalCanceled = users.filter(u => u.subscriptionStatus === 'CANCELED').length; const totalTrial = users.filter(u => u.subscriptionStatus === 'TRIAL').length; const activeUsersIds = users.filter(u => u.subscriptionStatus === 'ACTIVE').map(u => u.id); const allPayments = await prisma.payment.findMany({ include: { user: true, course: true, plan: true }, orderBy: { createdAt: 'desc' } }); // MRR Calculation let mrr = 0; for (const userId of activeUsersIds) { const studentPayments = allPayments.filter(p => p.userId === userId && (p.status === 'CONFIRMED' || p.status === 'RECEIVED')); // Find the latest payment that is a subscription (either a plan or no courseId) const latestSubscription = studentPayments.find(p => p.planId || (!p.courseId && !p.planId)); if (latestSubscription) { if (latestSubscription.planId && latestSubscription.plan) { if (latestSubscription.plan.cycle === 'MONTHLY') mrr += latestSubscription.plan.price; else if (latestSubscription.plan.cycle === 'YEARLY') mrr += (latestSubscription.plan.price / 12); } else if (!latestSubscription.courseId) { mrr += Number(latestSubscription.value); } } } // Total Revenue Calculation const totalRevenue = allPayments .filter(p => p.status === 'CONFIRMED' || p.status === 'RECEIVED') .reduce((sum, p) => sum + Number(p.value), 0); // Generate Revenue Data for Charts (Last 30 Days) const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); const recentConfirmedPayments = allPayments.filter( p => (p.status === 'CONFIRMED' || p.status === 'RECEIVED') && p.createdAt >= thirtyDaysAgo ); const revenueByDay: Record = {}; const categoryRevenue: Record = {}; for (const pay of recentConfirmedPayments) { const dateStr = pay.createdAt.toISOString().split('T')[0]; revenueByDay[dateStr] = (revenueByDay[dateStr] || 0) + Number(pay.value); let category = 'Desconhecido'; if (pay.courseId && pay.course) { category = `Curso: ${pay.course.title}`; } else if (pay.planId && pay.plan) { category = `Plano: ${pay.plan.title}`; } else { category = 'Assinatura Legado'; } categoryRevenue[category] = (categoryRevenue[category] || 0) + Number(pay.value); } const revenueData = Object.keys(revenueByDay).sort().map(date => ({ date, value: revenueByDay[date] })); const categoryData = Object.keys(categoryRevenue).map(name => ({ name, value: categoryRevenue[name] })); const recentPaymentsFormatted = allPayments .slice(0, 15) .map(p => { let desc = ''; if (p.course) desc = `Curso: ${p.course.title}`; else if (p.plan) desc = `Plano: ${p.plan.title}`; return { id: p.id, userId: p.userId, value: p.value, status: p.status, billingType: p.billingType, invoiceUrl: p.invoiceUrl, dueDate: p.dueDate, paidAt: p.paidAt, createdAt: p.createdAt, courseId: p.courseId, planId: p.planId, studentName: p.user?.name || 'Aluno Removido', studentEmail: p.user?.email || '', description: desc }; }); const webhookLogs = await prisma.webhookLog.findMany({ orderBy: { receivedAt: 'desc' }, take: 15 }); res.json({ stats: { mrr, totalRevenue, activeSubscribers: totalActive, trialSubscribers: totalTrial, overdueSubscribers: totalOverdue, canceledSubscribers: totalCanceled }, revenueData, categoryData, recentPayments: recentPaymentsFormatted, webhookLogs: webhookLogs.map(w => ({ id: w.id, event: w.event, payload: w.payload, receivedAt: w.receivedAt })) }); }); // 2. Students & status management app.get('/api/admin/students', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const students = await prisma.user.findMany({ where: { role: 'student' }, include: { payments: true, moduleGrades: true, certificates: true } }); const studentsFormatted = students.map(u => { return { id: u.id, name: u.name, email: u.email, subscriptionStatus: u.subscriptionStatus, asaasCustomerId: u.asaasCustomerId, createdAt: u.createdAt, birthDate: u.birthDate, whatsapp: u.whatsapp, trialEndsAt: u.trialEndsAt, totalPaid: u.payments .filter(p => p.status === 'CONFIRMED' || p.status === 'RECEIVED') .reduce((acc, curr) => acc + Number(curr.value), 0), unlockedCourses: u.unlockedCourses || [], moduleGrades: u.moduleGrades, certificates: u.certificates, payments: u.payments.map(p => ({ ...p, value: Number(p.value) })) }; }); res.json(studentsFormatted); }); // Admin change student status manually app.post('/api/admin/students/:id/status', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const { status } = req.body; const studentId = req.params.id; const allowedStatuses = ['ACTIVE', 'OVERDUE', 'CANCELED', 'TRIAL']; if (!status || !allowedStatuses.includes(status as any)) { res.status(400).json({ error: 'Status de assinatura inválido' }); return; } try { const student = await prisma.user.update({ where: { id: studentId, role: 'student' }, data: { subscriptionStatus: status as any } }); res.json({ success: true, status: student.subscriptionStatus }); } catch (error) { res.status(404).json({ error: 'Estudante não encontrado' }); } }); // Student / Public System Settings app.get('/api/settings', async (req: Request, res: Response) => { const settings = await prisma.systemSettings.findFirst(); res.json({ trialDurationDays: settings?.trialDurationDays || 7, helpText: settings?.helpText || '', enableBoleto: settings?.enableBoleto !== false, enableInstallmentBoleto: settings?.enableInstallmentBoleto === true, maxBoletoInstallments: settings?.maxBoletoInstallments || 3, brandName: settings?.brandName || 'MICROTEC', brandSlogan: settings?.brandSlogan || 'Acelere seus estudos em informática e pacote Office no estilo Netflix.', cnpj: settings?.cnpj || '', address: settings?.address || '', termsOfUse: settings?.termsOfUse || '', privacyPolicy: settings?.privacyPolicy || '' }); }); // Admin System Settings app.get('/api/admin/settings', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const settings = await prisma.systemSettings.findFirst(); res.json({ trialDurationDays: settings?.trialDurationDays || 7, asaasEnvironment: settings?.asaasEnvironment || 'sandbox', asaasApiKey: settings?.asaasApiKey || '', asaasWebhookUrl: settings?.asaasWebhookUrl || 'https://admin-estudo.microtecinformaticacurso.com.br/api/webhooks/asaas', asaasWebhookSecret: settings?.asaasWebhookSecret || 'devflix_webhook_secret_key', helpText: settings?.helpText || '', evolutionApiUrl: settings?.evolutionApiUrl || 'https://evolution.microtecinformaticacurso.com.br', evolutionApiKey: settings?.evolutionApiKey || '', evolutionInstance: settings?.evolutionInstance || 'microtecflix', whatsappConnected: settings?.whatsappConnected || false, whatsappPhoneNumber: settings?.whatsappPhoneNumber || '', brandName: settings?.brandName || 'MICROTEC', brandSlogan: settings?.brandSlogan || 'Acelere seus estudos em informática e pacote Office no estilo Netflix.', cnpj: settings?.cnpj || '', address: settings?.address || '', smtpHost: settings?.smtpHost || '', smtpPort: settings?.smtpPort || 587, smtpUser: settings?.smtpUser || '', smtpPass: settings?.smtpPass || '', smtpFrom: settings?.smtpFrom || 'no-reply@microtecinformaticacurso.com.br', smtpSecure: settings?.smtpSecure || false, termsOfUse: settings?.termsOfUse || '', privacyPolicy: settings?.privacyPolicy || '' }); }); app.post('/api/admin/settings', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const newSettings = req.body; const allowedKeys = [ 'trialDurationDays', 'asaasEnvironment', 'asaasApiKey', 'asaasWebhookUrl', 'asaasWebhookSecret', 'helpText', 'enableBoleto', 'enableInstallmentBoleto', 'maxBoletoInstallments', 'evolutionApiUrl', 'evolutionApiKey', 'evolutionInstance', 'whatsappConnected', 'whatsappPhoneNumber', 'brandName', 'brandSlogan', 'cnpj', 'address', 'smtpHost', 'smtpPort', 'smtpUser', 'smtpPass', 'smtpFrom', 'smtpSecure', 'termsOfUse', 'privacyPolicy' ]; const cleanedSettings: any = {}; for (const key of allowedKeys) { if (newSettings[key] !== undefined) { cleanedSettings[key] = newSettings[key]; } } const settings = await prisma.systemSettings.findFirst(); if (settings) { await prisma.systemSettings.update({ where: { id: settings.id }, data: cleanedSettings }); } else { await prisma.systemSettings.create({ data: cleanedSettings }); } const updatedSettings = await prisma.systemSettings.findFirst(); res.json({ success: true, settings: updatedSettings }); }); app.post('/api/admin/test-asaas', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { try { const { asaasApiKey, asaasEnvironment } = req.body || {}; const result = await AsaasService.testConnection(asaasApiKey, asaasEnvironment); res.json(result); } catch (err: any) { res.status(500).json({ success: false, message: err.message || 'Erro ao testar conexão Asaas' }); } }); app.post('/api/admin/test-smtp', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { try { const { smtpHost, smtpPort, smtpSecure, smtpUser, smtpPass, smtpFrom } = req.body; const transporter = nodemailer.createTransport({ host: smtpHost, port: smtpPort, secure: smtpSecure, auth: { user: smtpUser, pass: smtpPass } }); await transporter.verify(); // Envia um email de teste para o próprio remetente (ou admin) await transporter.sendMail({ from: smtpFrom, to: req.user!.email, subject: 'Teste de Conexão SMTP - MicrotecFlix', text: 'Se você está recebendo este e-mail, sua configuração SMTP na MicrotecFlix foi realizada com sucesso!' }); res.json({ success: true, message: 'Conexão estabelecida e e-mail de teste enviado com sucesso!' }); } catch (err: any) { res.status(500).json({ success: false, message: err.message || 'Erro ao testar conexão SMTP' }); } }); // --- EVOLUTION API WHATSAPP --- app.get('/api/admin/whatsapp/qrcode', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { try { const settings = await prisma.systemSettings.findFirst(); if (!settings || !settings.evolutionApiUrl || !settings.evolutionApiKey) { return res.status(400).json({ error: 'Credenciais da Evolution API não configuradas.' }); } const apiUrl = settings.evolutionApiUrl.replace(/\/$/, ''); const instance = settings.evolutionInstance || 'microtecflix'; const apiKey = settings.evolutionApiKey; const headers = { apikey: apiKey }; // Check connection state try { const stateRes = await axios.get(`${apiUrl}/instance/connectionState/${instance}`, { headers }); if (stateRes.data && stateRes.data.instance && stateRes.data.instance.state === 'open') { if (!settings.whatsappConnected) { await prisma.systemSettings.update({ where: { id: settings.id }, data: { whatsappConnected: true } }); } return res.json({ connected: true, status: 'open' }); } } catch (err: any) { if (err.response?.status === 404) { // Instance does not exist, create it await axios.post(`${apiUrl}/instance/create`, { instanceName: instance, qrcode: true, integration: 'WHATSAPP-BAILEYS' }, { headers }); } else { throw err; } } // Get QR Code const qrRes = await axios.get(`${apiUrl}/instance/connect/${instance}`, { headers }); // Some versions of evolution return { base64: "...", pairingCode: "..." } res.json({ connected: false, qrCode: qrRes.data.base64 || qrRes.data.qrcode || null, pairingCode: qrRes.data.pairingCode || null }); } catch (err: any) { console.error('Erro na Evolution API (QR Code):', err.message); res.status(500).json({ error: 'Falha ao comunicar com Evolution API.' }); } }); app.post('/api/admin/whatsapp/logout', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { try { const settings = await prisma.systemSettings.findFirst(); if (!settings || !settings.evolutionApiUrl || !settings.evolutionApiKey) { return res.status(400).json({ error: 'Credenciais não configuradas.' }); } const apiUrl = settings.evolutionApiUrl.replace(/\/$/, ''); const instance = settings.evolutionInstance || 'microtecflix'; const apiKey = settings.evolutionApiKey; try { await axios.delete(`${apiUrl}/instance/delete/${instance}`, { headers: { apikey: apiKey } }); } catch (err: any) { // ignore errors during delete } await prisma.systemSettings.update({ where: { id: settings.id }, data: { whatsappConnected: false } }); res.json({ success: true }); } catch (err: any) { console.error('Erro na Evolution API (Logout):', err.message); res.status(500).json({ error: 'Falha ao desconectar Evolution API.' }); } }); export async function sendWhatsappNotification(phone: string, text: string) { try { const settings = await prisma.systemSettings.findFirst(); if (!settings?.whatsappConnected || !settings.evolutionApiUrl || !settings.evolutionApiKey) { return false; // Not configured or not connected } // Clean phone number (leave only digits) let cleanPhone = phone.replace(/\D/g, ''); if (!cleanPhone) return false; // Add DDI 55 for Brazil if not present if (cleanPhone.length === 10 || cleanPhone.length === 11) { cleanPhone = '55' + cleanPhone; } const apiUrl = settings.evolutionApiUrl.replace(/\/$/, ''); const instance = settings.evolutionInstance || 'microtecflix'; await axios.post(`${apiUrl}/message/sendText/${instance}`, { number: cleanPhone, text: text, delay: 1200 }, { headers: { apikey: settings.evolutionApiKey } }); return true; } catch (err) { console.error('Falha ao enviar WhatsApp:', err); return false; } } // Admin manually unlock or lock a course for a student app.post('/api/admin/students/:id/unlock-course', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const { courseId, unlocked } = req.body; const studentId = req.params.id; if (!courseId || unlocked === undefined) { res.status(400).json({ error: 'Parâmetros courseId e unlocked são obrigatórios' }); return; } const student = await prisma.user.findUnique({ where: { id: studentId, role: 'student' } }); if (!student) { res.status(404).json({ error: 'Estudante não encontrado' }); return; } let newUnlocked = student.unlockedCourses || []; if (unlocked) { if (!newUnlocked.includes(courseId)) { newUnlocked.push(courseId); } } else { newUnlocked = newUnlocked.filter(id => id !== courseId); } await prisma.user.update({ where: { id: studentId }, data: { unlockedCourses: newUnlocked } }); res.json({ success: true, unlockedCourses: newUnlocked }); }); // 3. CRUD Courses app.post('/api/admin/courses', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const { title, description, thumbnail, category, isLocked, price } = req.body; if (!title || !description || !thumbnail || !category) { res.status(400).json({ error: 'Campos obrigatórios faltando' }); return; } const newCourse = await prisma.course.create({ data: { title, description, thumbnail: thumbnail, category, isLocked: isLocked === true || isLocked === 'true', price: price ? Number(price) : 0 } }); res.status(201).json(newCourse); }); app.put('/api/admin/courses/:id', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const { title, description, thumbnail, category, isLocked, price } = req.body; const courseId = req.params.id; try { const course = await prisma.course.update({ where: { id: courseId }, data: { ...(title && { title }), ...(description && { description }), ...(thumbnail && { thumbnailUrl: thumbnail }), ...(category && { category }), ...(isLocked !== undefined && { isLocked: isLocked === true || isLocked === 'true' }), ...(price !== undefined && { price: Number(price) }) } }); res.json(course); } catch (error) { res.status(404).json({ error: 'Curso não encontrado' }); } }); app.delete('/api/admin/courses/:id', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const courseId = req.params.id; try { await prisma.course.delete({ where: { id: courseId } }); res.json({ success: true }); } catch (error) { res.status(404).json({ error: 'Curso não encontrado' }); } }); // 4. CRUD Modules app.post('/api/admin/modules', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const { courseId, title, order } = req.body; if (!courseId || !title) { res.status(400).json({ error: 'Curso e título são obrigatórios' }); return; } const count = await prisma.module.count({ where: { courseId } }); const newModule = await prisma.module.create({ data: { courseId, title, order: order !== undefined ? Number(order) : count + 1 } }); res.status(201).json(newModule); }); app.put('/api/admin/modules/:id', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const { title, order } = req.body; const moduleId = req.params.id; try { const updatedModule = await prisma.module.update({ where: { id: moduleId }, data: { ...(title && { title }), ...(order !== undefined && { order: Number(order) }) } }); res.json(updatedModule); } catch (error) { res.status(404).json({ error: 'Módulo não encontrado' }); } }); app.delete('/api/admin/modules/:id', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const moduleId = req.params.id; try { await prisma.module.delete({ where: { id: moduleId } }); res.json({ success: true }); } catch (error) { res.status(404).json({ error: 'Módulo não encontrado' }); } }); // 5. CRUD Lessons app.post('/api/admin/lessons', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const { moduleId, title, videoUrl, videoType, order, content, thumbnailUrl } = req.body; if (!moduleId || !title || !videoUrl || !videoType) { res.status(400).json({ error: 'Campos obrigatórios faltando' }); return; } const count = await prisma.lesson.count({ where: { moduleId } }); const newLesson = await prisma.lesson.create({ data: { moduleId, title, videoUrl, videoType, content: content || '', thumbnailUrl: thumbnailUrl || '', order: order !== undefined ? Number(order) : count + 1 } }); res.status(201).json(newLesson); }); app.put('/api/admin/lessons/:id', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const { title, videoUrl, videoType, order, content, thumbnailUrl } = req.body; const lessonId = req.params.id; try { const updatedLesson = await prisma.lesson.update({ where: { id: lessonId }, data: { ...(title && { title }), ...(videoUrl && { videoUrl }), ...(videoType && { videoType }), ...(content !== undefined && { content }), ...(thumbnailUrl !== undefined && { thumbnailUrl }), ...(order !== undefined && { order: Number(order) }) } }); res.json(updatedLesson); } catch (error) { res.status(404).json({ error: 'Aula não encontrada' }); } }); app.delete('/api/admin/lessons/:id', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const lessonId = req.params.id; try { await prisma.lesson.delete({ where: { id: lessonId } }); res.json({ success: true }); } catch (error) { res.status(404).json({ error: 'Aula não encontrada' }); } }); // Reorder modules or lessons (Drag-and-drop or batch reorder helper) app.post('/api/admin/reorder', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const { type, items } = req.body; if (!type || !items || !Array.isArray(items)) { res.status(400).json({ error: 'Parâmetros inválidos' }); return; } try { if (type === 'modules') { for (const item of items) { await prisma.module.update({ where: { id: item.id }, data: { order: Number(item.order) } }); } } else if (type === 'lessons') { for (const item of items) { await prisma.lesson.update({ where: { id: item.id }, data: { order: Number(item.order) } }); } } res.json({ success: true }); } catch (error) { console.error('Reorder error:', error); res.status(500).json({ error: 'Erro ao reordenar' }); } }); // --- ASAAS WEBHOOK HANDLER --- app.post('/api/webhooks/asaas', async (req: Request, res: Response) => { const { event, payment } = req.body; if (event) { await prisma.webhookLog.create({ data: { event: event, payload: JSON.stringify(req.body) } }); } const settings = await prisma.systemSettings.findFirst(); const configuredSecret = settings?.asaasWebhookSecret || process.env.ASAAS_WEBHOOK_TOKEN || 'devflix_webhook_secret_key'; const tokenHeader = req.headers['asaas-access-token'] || req.headers['asaas-token'] || req.headers['authorization']; if (configuredSecret && tokenHeader !== configuredSecret) { console.warn(`[WEBHOOK SEGURANÇA] Token inválido bloqueado: Recebido ${tokenHeader}`); res.status(401).json({ error: 'Não autorizado - Token de Webhook do Asaas é inválido ou ausente.', dica_para_o_admin: `O Asaas enviou o token: '${tokenHeader || 'NENHUM'}', mas o servidor esperava o token: '${configuredSecret}'. Por favor, vá no painel do Asaas Sandbox > Integração > Webhooks e coloque exatamente o token esperado no campo 'Token de Acesso (Access Token)'.` }); return; } if (!event || !payment) { res.status(400).json({ error: 'Formato do Webhook inválido' }); return; } const customerId = payment.customer; const user = await prisma.user.findFirst({ where: { asaasCustomerId: customerId } }); if (user) { const isPaid = event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED'; const isOverdue = event === 'PAYMENT_OVERDUE'; const isRefunded = event === 'PAYMENT_REFUNDED' || event === 'PAYMENT_DELETED'; const newStatus = isPaid ? 'CONFIRMED' : isOverdue ? 'OVERDUE' : isRefunded ? 'REFUNDED' : event === 'PAYMENT_RESTORED' ? 'PENDING' : null; let existingPayment = await prisma.payment.findUnique({ where: { id: payment.id } }); if (existingPayment) { if (newStatus) { await prisma.payment.update({ where: { id: payment.id }, data: { status: newStatus as any, ...(isPaid && { paidAt: new Date() }) } }); } } else if (!isRefunded) { const lastPayment = await prisma.payment.findFirst({ where: { userId: user.id }, orderBy: { createdAt: 'desc' } }); await prisma.payment.create({ data: { id: payment.id, userId: user.id, value: payment.value || 49.90, status: (newStatus || 'PENDING') as any, billingType: payment.billingType || 'PIX', invoiceUrl: payment.invoiceUrl || `https://sandbox.asaas.com/i/${payment.id}`, dueDate: payment.dueDate ? new Date(payment.dueDate) : new Date(), paidAt: isPaid ? new Date() : null, planId: lastPayment?.planId, courseId: lastPayment?.courseId } }); } const description = payment.description || ''; const isCoursePayment = description.startsWith('unlock_course_'); if (isPaid) { if (isCoursePayment) { const courseId = description.replace('unlock_course_', ''); let newUnlocked = user.unlockedCourses || []; if (!newUnlocked.includes(courseId)) { newUnlocked.push(courseId); } await prisma.user.update({ where: { id: user.id }, data: { unlockedCourses: newUnlocked } }); console.log(`Webhook: Curso ${courseId} LIBERADO para usuário ${user.email}`); } else { await prisma.user.update({ where: { id: user.id }, data: { subscriptionStatus: 'ACTIVE' } }); console.log(`Webhook: Assinatura LIBERADA para usuário ${user.email}`); } } else if (isOverdue || isRefunded || event === 'SUBSCRIPTION_DELETED') { if (!isCoursePayment) { await prisma.user.update({ where: { id: user.id }, data: { subscriptionStatus: isOverdue ? 'OVERDUE' : 'CANCELED' } }); console.log(`Webhook: Assinatura BLOQUEADA para usuário ${user.email} - Motivo: ${event}`); } else if (isRefunded) { console.log(`Webhook: Pagamento de Curso REEMBOLSADO/DELETADO para usuário ${user.email}`); } } } else { console.warn(`Webhook: Cliente Asaas ${customerId} não encontrado no banco local.`); } res.status(200).json({ received: true }); }); // --- ACTIVITIES & CERTIFICATES API ENDPOINTS --- // Admin: Save or update module activity app.post('/api/admin/modules/:id/activity', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const moduleId = req.params.id; const { questions } = req.body; if (!questions || !Array.isArray(questions)) { res.status(400).json({ error: 'Lista de perguntas inválida' }); return; } const existingActivity = await prisma.moduleActivity.findUnique({ where: { moduleId } }); if (existingActivity) { const updated = await prisma.moduleActivity.update({ where: { moduleId }, data: { questions: questions as any } }); res.json({ success: true, activity: updated }); } else { const newActivity = await prisma.moduleActivity.create({ data: { moduleId, questions: questions as any } }); res.json({ success: true, activity: newActivity }); } }); // Admin/Student: Get module activity app.get('/api/modules/:id/activity', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const moduleId = req.params.id; const activity = await prisma.moduleActivity.findUnique({ where: { moduleId } }); if (!activity) { res.status(404).json({ error: 'Atividade não encontrada' }); return; } if (req.user?.role === 'student') { const safeQuestions = (activity.questions as any[]).map((q: any) => ({ question: q.question, options: q.options })); res.json({ id: activity.id, moduleId: activity.moduleId, questions: safeQuestions }); } else { res.json(activity); } }); // Admin: Change Course Certificate Mode app.post('/api/admin/courses/:id/certificate-mode', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const courseId = req.params.id; const { mode } = req.body; if (mode !== 'PER_MODULE' && mode !== 'FULL_COURSE') { res.status(400).json({ error: 'Modo inválido' }); return; } try { const course = await prisma.course.update({ where: { id: courseId }, data: { certificateMode: mode } }); res.json({ success: true, course }); } catch (error) { res.status(404).json({ error: 'Curso não encontrado' }); } }); // Admin: Manually Unlock Certificate app.post('/api/admin/students/:userId/unlock-certificate', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const { userId } = req.params; const { type, targetId, courseName, moduleName } = req.body; const certificate = await prisma.certificate.create({ data: { userId, courseId: type === 'COURSE' ? targetId : null, moduleId: type === 'MODULE' ? targetId : null, certificateType: type, finalGrade: 10, courseName, moduleName: moduleName || null } }); res.json({ success: true, certificate }); }); // Student: Submit Activity Answers app.post('/api/modules/:id/submit-activity', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const moduleId = req.params.id; const { answers } = req.body; const userId = req.user!.id; const activity = await prisma.moduleActivity.findUnique({ where: { moduleId } }); if (!activity) { res.status(404).json({ error: 'Atividade não encontrada' }); return; } let correctCount = 0; const questions = activity.questions as any[]; const totalQuestions = questions.length; questions.forEach((q: any, index: number) => { if (answers[index] === q.correctIndex) { correctCount++; } }); const score = totalQuestions > 0 ? (correctCount / totalQuestions) * 10 : 0; const passed = score >= 6; await prisma.moduleGrade.upsert({ where: { userId_activityId: { userId, activityId: activity.id } }, update: { score, passed }, create: { userId, activityId: activity.id, score, passed } }); const moduleInfo = await prisma.module.findUnique({ where: { id: moduleId }, include: { course: true } }); if (passed && moduleInfo && moduleInfo.course) { const course = moduleInfo.course; const mode = course.certificateMode || 'FULL_COURSE'; if (mode === 'PER_MODULE') { const exists = await prisma.certificate.findFirst({ where: { userId, moduleId, certificateType: 'MODULE' } }); if (!exists) { await prisma.certificate.create({ data: { userId, courseId: course.id, moduleId: moduleInfo.id, certificateType: 'MODULE', finalGrade: score, courseName: course.title, moduleName: moduleInfo.title } }); } } else { const allCourseModules = await prisma.module.findMany({ where: { courseId: course.id } }); let allPassed = true; let totalScore = 0; let evaluatedModules = 0; for (const m of allCourseModules) { const act = await prisma.moduleActivity.findUnique({ where: { moduleId: m.id } }); if (act) { const grade = await prisma.moduleGrade.findUnique({ where: { userId_activityId: { userId, activityId: act.id } } }); if (!grade || !grade.passed) { allPassed = false; break; } totalScore += grade.score; evaluatedModules++; } else { const lessons = await prisma.lesson.findMany({ where: { moduleId: m.id } }); for (const lsn of lessons) { const prog = await prisma.progress.findUnique({ where: { userId_lessonId: { userId, lessonId: lsn.id } } }); if (!prog || !prog.completed) { allPassed = false; break; } } } } if (allPassed) { const exists = await prisma.certificate.findFirst({ where: { userId, courseId: course.id, certificateType: 'COURSE' } }); if (!exists) { const finalGrade = evaluatedModules > 0 ? (totalScore / evaluatedModules) : null; await prisma.certificate.create({ data: { userId, courseId: course.id, certificateType: 'COURSE', finalGrade, courseName: course.title } }); } } } } res.json({ success: true, score, passed }); }); // Student: Get my certificates app.get('/api/certificates', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const myCerts = await prisma.certificate.findMany({ where: { userId: req.user!.id } }); res.json(myCerts); }); // --- NOTIFICATIONS --- app.get('/api/notifications', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const myNotifications = await prisma.notification.findMany({ where: { OR: [{ userId: req.user!.id }, { userId: 'ALL' }] }, orderBy: { createdAt: 'desc' } }); res.json(myNotifications); }); app.put('/api/notifications/:id/read', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const notification = await prisma.notification.findUnique({ where: { id: req.params.id } }); if (notification && (notification.userId === req.user!.id || notification.userId === 'ALL')) { await prisma.notification.update({ where: { id: notification.id }, data: { read: true } }); } res.json({ success: true }); }); // --- HELP DESK --- app.get('/api/help', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const settings = await prisma.systemSettings.findFirst(); res.json({ helpText: settings?.helpText || '' }); }); app.put('/api/admin/help', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const settings = await prisma.systemSettings.findFirst(); if (settings) { await prisma.systemSettings.update({ where: { id: settings.id }, data: { helpText: req.body.helpText } }); } else { await prisma.systemSettings.create({ data: { helpText: req.body.helpText } }); } res.json({ success: true }); }); // --- MODULE ACTIVITIES (ADMIN) --- app.get('/api/admin/modules/:moduleId/activity', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const activity = await prisma.moduleActivity.findUnique({ where: { moduleId: req.params.moduleId } }); res.json(activity || { moduleId: req.params.moduleId, questions: [] }); }); app.put('/api/admin/modules/:moduleId/activity', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const { moduleId } = req.params; const { questions } = req.body; const updated = await prisma.moduleActivity.upsert({ where: { moduleId }, update: { questions: questions as any }, create: { moduleId, questions: questions as any } }); res.json(updated); }); // --- PUBLIC/STUDENT PLANS API --- app.get('/api/plans', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const activePlans = await prisma.plan.findMany({ where: { active: true } }); res.json(activePlans); }); // --- ADMIN PLANS API ENDPOINTS --- app.get('/api/admin/plans', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const plans = await prisma.plan.findMany(); res.json(plans); }); app.post('/api/admin/plans', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const { title, description, price, cycle, includedCourses, active } = req.body; const newPlan = await prisma.plan.create({ data: { title: title || 'Novo Plano', description: description || '', price: price || 0, cycle: cycle || 'LIFETIME', includedCourses: includedCourses || [], active: active !== undefined ? active : true } }); res.json({ success: true, plan: newPlan }); }); app.put('/api/admin/plans/:id', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { try { const plan = await prisma.plan.update({ where: { id: req.params.id }, data: req.body }); res.json({ success: true, plan }); } catch (error) { res.status(404).json({ error: 'Plano não encontrado' }); } }); app.delete('/api/admin/plans/:id', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { try { await prisma.plan.delete({ where: { id: req.params.id } }); res.json({ success: true }); } catch (error) { res.status(404).json({ error: 'Plano não encontrado' }); } }); // Update single course price and lock status directly app.put('/api/admin/courses/:id/price', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { try { const course = await prisma.course.update({ where: { id: req.params.id }, data: { price: req.body.price !== undefined ? Number(req.body.price) : undefined, isLocked: req.body.isLocked } }); res.json({ success: true, course }); } catch (error) { res.status(404).json({ error: 'Curso não encontrado' }); } }); // --- VITE MIDDLEWARE / SPA FALLBACK --- async function startServer() { try { console.log('🔄 Sincronizando banco de dados com Prisma...'); let retries = 5; while (retries > 0) { try { execSync('npx prisma db push --accept-data-loss', { stdio: 'inherit' }); console.log('✅ Banco de dados sincronizado!'); break; } catch (err) { console.error(`⚠️ Erro ao sincronizar banco (tentativas restantes: ${retries - 1})... Aguardando Postgres...`); retries -= 1; if (retries === 0) { console.error('❌ Falha definitiva ao inicializar banco de dados:', err); // Don't throw, let it attempt anyway (might just be a timeout issue, though count() will probably fail) } else { // Wait 3 seconds synchronously using a busy wait or just use a helper await new Promise(res => setTimeout(res, 3000)); } } } // Seed test users if DB is empty const adminCount = await prisma.user.count({ where: { email: 'sidney.gomes1989@gmail.com' } }); if (adminCount === 0) { console.log('🌱 Semeando usuários de teste...'); const defaultPassword = bcrypt.hashSync('admin123', 10); const studentPassword = bcrypt.hashSync('aluno123', 10); await prisma.user.createMany({ data: [ { email: 'sidney.gomes1989@gmail.com', password: defaultPassword, name: 'Administrador Sidney', role: 'admin', subscriptionStatus: 'ACTIVE', }, { email: 'aluno@microtecflix.com', password: studentPassword, name: 'Aluno Teste', role: 'student', subscriptionStatus: 'ACTIVE', }, { email: 'joao@microtecflix.com', password: studentPassword, name: 'João Silva (Excel)', role: 'student', subscriptionStatus: 'ACTIVE', }, { email: 'maria@microtecflix.com', password: studentPassword, name: 'Maria Ativa', role: 'student', subscriptionStatus: 'ACTIVE', }, { email: 'pedro@microtecflix.com', password: studentPassword, name: 'Pedro Atrasado', role: 'student', subscriptionStatus: 'OVERDUE', }, { email: 'ana@microtecflix.com', password: studentPassword, name: 'Ana Cancelada', role: 'student', subscriptionStatus: 'CANCELED', } ], skipDuplicates: true }); console.log('✅ Usuários de teste semeados com sucesso!'); } } catch (dbError) { console.error('❌ Falha ao inicializar banco de dados:', dbError); } // --- Suporte / Mensagens --- // Aluno envia um chamado de suporte/ajuda app.post('/api/support-tickets', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { try { const { content } = req.body; if (!content) return res.status(400).json({ error: 'Conteúdo é obrigatório' }); const ticket = await prisma.supportTicket.create({ data: { userId: req.user!.id, content, } }); res.status(201).json(ticket); } catch (error) { console.error('Error creating support ticket:', error); res.status(500).json({ error: 'Internal server error' }); } }); // Admin puxa todos os chamados e anotações app.get('/api/admin/messages', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { try { const tickets = await prisma.supportTicket.findMany({ include: { user: { select: { id: true, name: true, email: true, avatarUrl: true, whatsapp: true } } }, orderBy: { createdAt: 'desc' } }); const notes = await prisma.note.findMany({ include: { user: { select: { id: true, name: true, email: true, avatarUrl: true, whatsapp: true } }, lesson: { select: { title: true, module: { select: { course: { select: { title: true } } } } } } }, orderBy: { createdAt: 'desc' } }); res.json({ tickets, notes }); } catch (error) { console.error('Error fetching admin messages:', error); res.status(500).json({ error: 'Internal server error' }); } }); // Admin responde um chamado ou anotação app.post('/api/admin/reply-message', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { try { const { userId, message, ticketId } = req.body; if (!userId || !message) return res.status(400).json({ error: 'userId e message são obrigatórios' }); // 1. Criar notificação no portal (Sininho) await prisma.notification.create({ data: { userId, title: 'Nova mensagem de Suporte', message, type: 'info' } }); if (ticketId) { await prisma.supportTicket.update({ where: { id: ticketId }, data: { status: 'REPLIED' } }); } // 2. Enviar WhatsApp via Evolution API se configurado const settings = await prisma.systemSettings.findUnique({ where: { id: 'default' } }); const user = await prisma.user.findUnique({ where: { id: userId } }); if (settings?.whatsappConnected && settings.evolutionApiUrl && settings.evolutionApiKey && user?.whatsapp) { try { const cleanPhone = user.whatsapp.replace(/\D/g, ''); if (cleanPhone.length >= 10) { const numberId = `${cleanPhone}@s.whatsapp.net`; const url = `${settings.evolutionApiUrl}/message/sendText/${settings.evolutionInstance}`; await axios.post(url, { number: numberId, text: message, delay: 1200, linkPreview: true }, { headers: { 'apikey': settings.evolutionApiKey, 'Content-Type': 'application/json' } }); console.log(`WhatsApp reply sent to ${cleanPhone}`); } } catch (waError: any) { console.error('Failed to send WhatsApp reply:', waError?.response?.data || waError.message); } } res.json({ success: true }); } catch (error) { console.error('Error replying to message:', error); res.status(500).json({ error: 'Internal server error' }); } }); if (process.env.NODE_ENV !== 'production') { const { createServer: createViteServer } = await import('vite'); const vite = await createViteServer({ server: { middlewareMode: true }, appType: 'spa', }); app.use(vite.middlewares); } else { const distPath = path.join(process.cwd(), 'dist'); app.use(express.static(distPath)); // Fallback para SPA - Isso DEVE estar no final app.get('*', (req: Request, res: Response) => { res.sendFile(path.join(distPath, 'index.html')); }); } app.listen(PORT, '0.0.0.0', () => { console.log(`DevFlix Server running on port ${PORT}`); }); // Background job to clean up expired PIX payments (older than 5 minutes) and Boleto payments (older than 2 days) setInterval(async () => { try { const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); // Clean PIX const expiredPix = await prisma.payment.findMany({ where: { billingType: 'PIX', status: 'PENDING', createdAt: { lt: fiveMinutesAgo } } }); for (const payment of expiredPix) { console.log(`[PIX Expire Job] Expirando e deletando PIX ${payment.id}...`); const deletedInAsaas = await AsaasService.deletePayment(payment.id); if (deletedInAsaas) { await prisma.payment.update({ where: { id: payment.id }, data: { status: 'CANCELED' } }); console.log(`[PIX Expire Job] PIX ${payment.id} cancelado com sucesso.`); } } // Clean BOLETO const expiredBoleto = await prisma.payment.findMany({ where: { billingType: 'BOLETO', status: 'PENDING', createdAt: { lt: twoDaysAgo } } }); for (const payment of expiredBoleto) { console.log(`[Boleto Expire Job] Expirando e deletando Boleto ${payment.id}...`); const deletedInAsaas = await AsaasService.deletePayment(payment.id); if (deletedInAsaas) { await prisma.payment.update({ where: { id: payment.id }, data: { status: 'CANCELED' } }); console.log(`[Boleto Expire Job] Boleto ${payment.id} cancelado com sucesso.`); } } } catch (err: any) { console.error('[Expire Job] Erro ao limpar pagamentos expirados:', err.message); } }, 60 * 1000); // Check every 60 seconds (1 minute) } startServer();