import express, { Request, Response, NextFunction } from 'express'; import path from 'path'; import jwt from 'jsonwebtoken'; import bcrypt from 'bcryptjs'; import { createServer as createViteServer } from 'vite'; import { loadDb, saveDb } from './src/db/db'; import { AsaasService } from './src/services/asaas'; import { User, Course, Module, Lesson, Note, LessonProgress, WebhookLog, SubscriptionPayment, SubscriptionStatus } from './src/types'; // 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()); // --- 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, birthDate, whatsapp } = req.body; if (!name || !email || !password || !birthDate || !whatsapp) { res.status(400).json({ error: 'Preencha todos os campos obrigatórios' }); return; } const db = loadDb(); const userExists = db.users.find(u => u.email.toLowerCase() === email.toLowerCase()); if (userExists) { res.status(400).json({ error: 'E-mail já cadastrado na plataforma' }); return; } try { // Generate Asaas Customer ID (Real call if configured, or simulated) const asaasCustomerId = await AsaasService.createCustomer(name, email); const salt = bcrypt.genSaltSync(10); const passwordHash = bcrypt.hashSync(password, salt); const createdAt = new Date(); // Calculate trial ends at const trialDays = db.settings?.trialDurationDays || 7; const trialEndsAt = new Date(createdAt); trialEndsAt.setDate(trialEndsAt.getDate() + trialDays); const newUser: User = { id: `usr_${Math.random().toString(36).substring(2, 9)}`, name, email: email.toLowerCase(), password: passwordHash, role: 'student', subscriptionStatus: 'TRIAL', // Defaults to trial asaasCustomerId, birthDate, whatsapp, trialEndsAt: trialEndsAt.toISOString(), createdAt: createdAt.toISOString() }; db.users.push(newUser); saveDb(db); // 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: { id: newUser.id, name: newUser.name, email: newUser.email, role: newUser.role, subscriptionStatus: newUser.subscriptionStatus } }); } catch (err: any) { res.status(500).json({ error: err.message || 'Erro ao registrar usuário' }); } }); // 2. Auth: Login app.post('/api/auth/login', (req: Request, res: Response) => { const { email, password } = req.body; if (!email || !password) { res.status(400).json({ error: 'Informe e-mail e senha' }); return; } const db = loadDb(); const user = db.users.find(u => u.email.toLowerCase() === email.toLowerCase()); 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: { id: user.id, name: user.name, email: user.email, role: user.role, subscriptionStatus: user.subscriptionStatus, unlockedCourses: user.unlockedCourses || [] } }); }); // 3. Auth: Me app.get('/api/auth/me', authenticateToken, (req: AuthenticatedRequest, res: Response) => { if (!req.user) { res.status(401).json({ error: 'Não autorizado' }); return; } const db = loadDb(); const user = db.users.find(u => u.id === req.user?.id); if (!user) { res.status(404).json({ error: 'Usuário não encontrado' }); return; } res.json({ id: user.id, name: user.name, email: user.email, role: user.role, subscriptionStatus: user.subscriptionStatus, unlockedCourses: user.unlockedCourses || [] }); }); // 4. Courses List (With Progress) app.get('/api/courses', authenticateToken, (req: AuthenticatedRequest, res: Response) => { const db = loadDb(); const userId = req.user!.id; const user = db.users.find(u => u.id === userId); const unlockedCourses = user?.unlockedCourses || []; const isAdmin = user?.role === 'admin'; // Enhance courses with overall user progress const coursesWithProgress = db.courses.map(course => { // Find all lessons in this course const courseModules = db.modules.filter(m => m.courseId === course.id); const courseModuleIds = courseModules.map(m => m.id); const courseLessons = db.lessons.filter(l => courseModuleIds.includes(l.moduleId)); const totalLessons = courseLessons.length; let completedLessons = 0; if (totalLessons > 0) { const userProgress = db.progress.filter(p => p.userId === userId && p.completed); const userProgressLessonIds = userProgress.map(p => p.lessonId); completedLessons = courseLessons.filter(l => userProgressLessonIds.includes(l.id)).length; } const progressPercentage = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0; return { ...course, totalLessons, completedLessons, progress: progressPercentage, isUnlocked: !course.isLocked || unlockedCourses.includes(course.id) || isAdmin }; }); res.json(coursesWithProgress); }); // 5. Course Details (with Modules, Lessons, Progress & Notes) app.get('/api/courses/:id', authenticateToken, (req: AuthenticatedRequest, res: Response) => { const db = loadDb(); const userId = req.user!.id; const courseId = req.params.id; const course = db.courses.find(c => c.id === courseId); if (!course) { res.status(404).json({ error: 'Curso não encontrado' }); return; } const user = db.users.find(u => u.id === userId); const unlockedCourses = user?.unlockedCourses || []; const isAdmin = user?.role === 'admin'; const isUnlocked = !course.isLocked || unlockedCourses.includes(course.id) || isAdmin; // Get modules const courseModules = db.modules .filter(m => m.courseId === courseId) .sort((a, b) => a.order - b.order); // Get lessons and enhance with progress const modulesWithLessons = courseModules.map(mod => { const lessons = db.lessons .filter(l => l.moduleId === mod.id) .sort((a, b) => a.order - b.order) .map(lesson => { const prog = db.progress.find(p => p.userId === userId && p.lessonId === lesson.id); const note = db.notes.find(n => n.userId === userId && 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 }; }); res.json({ course: { ...course, 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 } = req.body; const db = loadDb(); const user = db.users.find(u => u.id === userId); const course = db.courses.find(c => c.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 { let customerId = user.asaasCustomerId; const apiKey = AsaasService.getApiKey(); if (apiKey && (customerId?.startsWith('cus_sim_') || customerId?.startsWith('cus_fallback_'))) { customerId = null; } if (!customerId) { customerId = await AsaasService.createCustomer(user.name, user.email); user.asaasCustomerId = customerId; } 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: SubscriptionPayment = { id: asaasPayment.id, userId, value: course.price || 49.90, status: 'PENDING', billingType: billingType || 'PIX', invoiceUrl: asaasPayment.invoiceUrl, dueDate: asaasPayment.dueDate || new Date().toISOString().split('T')[0], paidAt: null, createdAt: new Date().toISOString() }; db.payments.push(newPayment); saveDb(db); 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' }); } }); // 6. Lesson Progress tracking app.post('/api/lessons/:id/progress', authenticateToken, (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 db = loadDb(); const lesson = db.lessons.find(l => l.id === lessonId); if (!lesson) { res.status(404).json({ error: 'Aula não encontrada' }); return; } let progIdx = db.progress.findIndex(p => p.userId === userId && p.lessonId === lessonId); const isCompleted = watchedPercentage >= 90; if (progIdx >= 0) { db.progress[progIdx].watchedPercentage = Math.max(db.progress[progIdx].watchedPercentage, watchedPercentage); if (isCompleted) { db.progress[progIdx].completed = true; } db.progress[progIdx].updatedAt = new Date().toISOString(); } else { db.progress.push({ id: `prog_${Math.random().toString(36).substring(2, 9)}`, userId, lessonId, watchedPercentage, completed: isCompleted, updatedAt: new Date().toISOString() }); } saveDb(db); res.json({ success: true, completed: isCompleted }); }); // 7. Lesson Notes and Doubts app.get('/api/lessons/:id/notes', authenticateToken, (req: AuthenticatedRequest, res: Response) => { const lessonId = req.params.id; const userId = req.user!.id; const db = loadDb(); const note = db.notes.find(n => n.userId === userId && n.lessonId === lessonId); res.json({ content: note ? note.content : '' }); }); app.post('/api/lessons/:id/notes', authenticateToken, (req: AuthenticatedRequest, res: Response) => { const { content } = req.body; const lessonId = req.params.id; const userId = req.user!.id; const db = loadDb(); let noteIdx = db.notes.findIndex(n => n.userId === userId && n.lessonId === lessonId); if (noteIdx >= 0) { db.notes[noteIdx].content = content; db.notes[noteIdx].createdAt = new Date().toISOString(); } else { db.notes.push({ id: `note_${Math.random().toString(36).substring(2, 9)}`, userId, lessonId, content, createdAt: new Date().toISOString() }); } saveDb(db); res.json({ success: true }); }); // 8. Subscription status & Invoice history app.get('/api/subscription/status', authenticateToken, (req: AuthenticatedRequest, res: Response) => { const db = loadDb(); const userId = req.user!.id; const user = db.users.find(u => u.id === userId); if (!user) { res.status(404).json({ error: 'Usuário não encontrado' }); return; } const payments = db.payments .filter(p => p.userId === userId) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 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 } = 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 db = loadDb(); const user = db.users.find(u => u.id === userId); if (!user) { res.status(404).json({ error: 'Usuário não encontrado' }); return; } try { let customerId = user.asaasCustomerId; const apiKey = AsaasService.getApiKey(); if (apiKey && (customerId?.startsWith('cus_sim_') || customerId?.startsWith('cus_fallback_'))) { customerId = null; } if (!customerId) { customerId = await AsaasService.createCustomer(user.name, user.email); user.asaasCustomerId = customerId; } // Assinatura de R$ 49.90/mês const { subscriptionId, payment } = await AsaasService.createSubscription( customerId, 49.90, billingType, creditCardInfo ); // Add subscription payment to database const newPayment: SubscriptionPayment = { id: payment.id || `pay_${Math.random().toString(36).substring(2, 9)}`, userId, value: 49.90, status: billingType === 'CREDIT_CARD' ? 'CONFIRMED' : 'PENDING', billingType, invoiceUrl: payment.invoiceUrl || `https://sandbox.asaas.com/i/${subscriptionId}`, dueDate: payment.dueDate || new Date(Date.now() + 3 * 24 * 3600 * 1000).toISOString().split('T')[0], paidAt: billingType === 'CREDIT_CARD' ? new Date().toISOString() : null, createdAt: new Date().toISOString() }; db.payments.push(newPayment); // Update user subscription status if (billingType === 'CREDIT_CARD') { user.subscriptionStatus = 'ACTIVE'; } else { user.subscriptionStatus = 'TRIAL'; // Give them trial until Pix/Boleto clears, or keep Trial } saveDb(db); res.json({ success: true, subscriptionId, payment: newPayment }); } 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 app.get('/api/admin/dashboard', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { const db = loadDb(); const totalActive = db.users.filter(u => u.role === 'student' && u.subscriptionStatus === 'ACTIVE').length; const totalOverdue = db.users.filter(u => u.role === 'student' && u.subscriptionStatus === 'OVERDUE').length; const totalCanceled = db.users.filter(u => u.role === 'student' && u.subscriptionStatus === 'CANCELED').length; const totalTrial = db.users.filter(u => u.role === 'student' && u.subscriptionStatus === 'TRIAL').length; // MRR = Active Subscribers * 49.90 const mrr = totalActive * 49.90; // Let's format recent invoice history for logs/charts const recentPayments = db.payments .map(p => { const student = db.users.find(u => u.id === p.userId); return { ...p, studentName: student ? student.name : 'Aluno Removido', studentEmail: student ? student.email : '' }; }) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .slice(0, 15); const webhookLogs = db.webhookLogs .sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()) .slice(0, 15); res.json({ stats: { mrr, activeSubscribers: totalActive, trialSubscribers: totalTrial, overdueSubscribers: totalOverdue, canceledSubscribers: totalCanceled }, recentPayments, webhookLogs }); }); // 2. Students & status management app.get('/api/admin/students', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { const db = loadDb(); const students = db.users .filter(u => u.role === 'student') .map(u => { const studentPayments = db.payments.filter(p => p.userId === u.id); const studentGrades = db.moduleGrades.filter(g => g.userId === u.id); const studentCertificates = db.certificates.filter(c => c.userId === u.id); 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: studentPayments .filter(p => p.status === 'CONFIRMED' || p.status === 'RECEIVED') .reduce((acc, curr) => acc + curr.value, 0), unlockedCourses: u.unlockedCourses || [], moduleGrades: studentGrades, certificates: studentCertificates, payments: studentPayments }; }); res.json(students); }); // Admin change student status manually app.post('/api/admin/students/:id/status', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { const { status } = req.body; const studentId = req.params.id; const allowedStatuses: SubscriptionStatus[] = ['ACTIVE', 'OVERDUE', 'CANCELED', 'TRIAL']; if (!status || !allowedStatuses.includes(status)) { res.status(400).json({ error: 'Status de assinatura inválido' }); return; } const db = loadDb(); const student = db.users.find(u => u.id === studentId && u.role === 'student'); if (!student) { res.status(404).json({ error: 'Estudante não encontrado' }); return; } student.subscriptionStatus = status; saveDb(db); res.json({ success: true, status: student.subscriptionStatus }); }); // Student / Public System Settings app.get('/api/settings', authenticateToken, (req: AuthenticatedRequest, res: Response) => { const db = loadDb(); const settings = db.settings || {}; res.json({ trialDurationDays: settings.trialDurationDays || 7, helpText: settings.helpText || '', enableBoleto: settings.enableBoleto !== false }); }); // Admin System Settings app.get('/api/admin/settings', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { const db = loadDb(); const settings = db.settings || {}; 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 || '' }); }); app.post('/api/admin/settings', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { const newSettings = req.body; const db = loadDb(); db.settings = { ...db.settings, ...newSettings }; saveDb(db); res.json({ success: true, settings: db.settings }); }); 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' }); } }); // Admin manually unlock or lock a course for a student app.post('/api/admin/students/:id/unlock-course', authenticateToken, requireAdmin, (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 db = loadDb(); const student = db.users.find(u => u.id === studentId && u.role === 'student'); if (!student) { res.status(404).json({ error: 'Estudante não encontrado' }); return; } if (!student.unlockedCourses) { student.unlockedCourses = []; } if (unlocked) { if (!student.unlockedCourses.includes(courseId)) { student.unlockedCourses.push(courseId); } } else { student.unlockedCourses = student.unlockedCourses.filter(id => id !== courseId); } saveDb(db); res.json({ success: true, unlockedCourses: student.unlockedCourses }); }); // 3. CRUD Courses app.post('/api/admin/courses', authenticateToken, requireAdmin, (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 db = loadDb(); const newCourse: Course = { id: `crs_${Math.random().toString(36).substring(2, 9)}`, title, description, thumbnail, category, isLocked: isLocked === true || isLocked === 'true', price: price ? Number(price) : 0, createdAt: new Date().toISOString() }; db.courses.push(newCourse); saveDb(db); res.status(201).json(newCourse); }); app.put('/api/admin/courses/:id', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { const { title, description, thumbnail, category, isLocked, price } = req.body; const courseId = req.params.id; const db = loadDb(); const idx = db.courses.findIndex(c => c.id === courseId); if (idx < 0) { res.status(404).json({ error: 'Curso não encontrado' }); return; } db.courses[idx] = { ...db.courses[idx], title: title || db.courses[idx].title, description: description || db.courses[idx].description, thumbnail: thumbnail || db.courses[idx].thumbnail, category: category || db.courses[idx].category, isLocked: isLocked !== undefined ? (isLocked === true || isLocked === 'true') : db.courses[idx].isLocked, price: price !== undefined ? Number(price) : db.courses[idx].price }; saveDb(db); res.json(db.courses[idx]); }); app.delete('/api/admin/courses/:id', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { const courseId = req.params.id; const db = loadDb(); const idx = db.courses.findIndex(c => c.id === courseId); if (idx < 0) { res.status(404).json({ error: 'Curso não encontrado' }); return; } // Delete modules, lessons, and course db.courses.splice(idx, 1); const affectedModules = db.modules.filter(m => m.courseId === courseId); const affectedModuleIds = affectedModules.map(m => m.id); db.modules = db.modules.filter(m => m.courseId !== courseId); db.lessons = db.lessons.filter(l => !affectedModuleIds.includes(l.moduleId)); saveDb(db); res.json({ success: true }); }); // 4. CRUD Modules app.post('/api/admin/modules', authenticateToken, requireAdmin, (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 db = loadDb(); const newModule: Module = { id: `mdl_${Math.random().toString(36).substring(2, 9)}`, courseId, title, order: order !== undefined ? Number(order) : db.modules.filter(m => m.courseId === courseId).length + 1, createdAt: new Date().toISOString() }; db.modules.push(newModule); saveDb(db); res.status(201).json(newModule); }); app.put('/api/admin/modules/:id', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { const { title, order } = req.body; const moduleId = req.params.id; const db = loadDb(); const idx = db.modules.findIndex(m => m.id === moduleId); if (idx < 0) { res.status(404).json({ error: 'Módulo não encontrado' }); return; } db.modules[idx].title = title || db.modules[idx].title; if (order !== undefined) { db.modules[idx].order = Number(order); } saveDb(db); res.json(db.modules[idx]); }); app.delete('/api/admin/modules/:id', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { const moduleId = req.params.id; const db = loadDb(); const idx = db.modules.findIndex(m => m.id === moduleId); if (idx < 0) { res.status(404).json({ error: 'Módulo não encontrado' }); return; } db.modules.splice(idx, 1); db.lessons = db.lessons.filter(l => l.moduleId !== moduleId); saveDb(db); res.json({ success: true }); }); // 5. CRUD Lessons app.post('/api/admin/lessons', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { const { moduleId, title, videoUrl, videoType, order, content } = req.body; if (!moduleId || !title || !videoUrl || !videoType) { res.status(400).json({ error: 'Campos obrigatórios faltando' }); return; } const db = loadDb(); const newLesson: Lesson = { id: `lsn_${Math.random().toString(36).substring(2, 9)}`, moduleId, title, videoUrl, videoType, order: order !== undefined ? Number(order) : db.lessons.filter(l => l.moduleId === moduleId).length + 1, content: content || '', createdAt: new Date().toISOString() }; db.lessons.push(newLesson); saveDb(db); res.status(201).json(newLesson); }); app.put('/api/admin/lessons/:id', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { const { title, videoUrl, videoType, order, content } = req.body; const lessonId = req.params.id; const db = loadDb(); const idx = db.lessons.findIndex(l => l.id === lessonId); if (idx < 0) { res.status(404).json({ error: 'Aula não encontrada' }); return; } db.lessons[idx] = { ...db.lessons[idx], title: title || db.lessons[idx].title, videoUrl: videoUrl || db.lessons[idx].videoUrl, videoType: videoType || db.lessons[idx].videoType, content: content !== undefined ? content : db.lessons[idx].content }; if (order !== undefined) { db.lessons[idx].order = Number(order); } saveDb(db); res.json(db.lessons[idx]); }); app.delete('/api/admin/lessons/:id', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { const lessonId = req.params.id; const db = loadDb(); const idx = db.lessons.findIndex(l => l.id === lessonId); if (idx < 0) { res.status(404).json({ error: 'Aula não encontrada' }); return; } db.lessons.splice(idx, 1); // Also clean up notes and progress db.notes = db.notes.filter(n => n.lessonId !== lessonId); db.progress = db.progress.filter(p => p.lessonId !== lessonId); saveDb(db); res.json({ success: true }); }); // Reorder modules or lessons (Drag-and-drop or batch reorder helper) app.post('/api/admin/reorder', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { const { type, items } = req.body; // type: 'modules' | 'lessons', items: Array of { id, order } if (!type || !items || !Array.isArray(items)) { res.status(400).json({ error: 'Parâmetros inválidos' }); return; } const db = loadDb(); if (type === 'modules') { items.forEach((item: { id: string; order: number }) => { const idx = db.modules.findIndex(m => m.id === item.id); if (idx >= 0) { db.modules[idx].order = Number(item.order); } }); } else if (type === 'lessons') { items.forEach((item: { id: string; order: number }) => { const idx = db.lessons.findIndex(l => l.id === item.id); if (idx >= 0) { db.lessons[idx].order = Number(item.order); } }); } saveDb(db); res.json({ success: true }); }); // --- ASAAS WEBHOOK HANDLER --- app.post('/api/webhooks/asaas', (req: Request, res: Response) => { const db = loadDb(); const configuredSecret = db.settings?.asaasWebhookSecret || process.env.ASAAS_WEBHOOK_TOKEN || 'devflix_webhook_secret_key'; const tokenHeader = req.headers['asaas-token'] || req.headers['authorization']; if (configuredSecret && tokenHeader !== configuredSecret) { console.warn(`[WEBHOOK SEGURANÇA] Token inválido bloqueado: ${tokenHeader}`); res.status(401).json({ error: 'Não autorizado - Token de Webhook do Asaas é inválido ou ausente.' }); return; } const { event, payment } = req.body; if (!event || !payment) { res.status(400).json({ error: 'Formato do Webhook inválido' }); return; } // Log the webhook event const logId = `wh_log_${Math.random().toString(36).substring(2, 9)}`; db.webhookLogs.push({ id: logId, event, payload: JSON.stringify(req.body), receivedAt: new Date().toISOString() }); // Find user by Asaas Customer ID const customerId = payment.customer; const user = db.users.find(u => u.asaasCustomerId === customerId); if (user) { // Process payment updates let userPaymentIdx = db.payments.findIndex(p => p.id === payment.id || (p.userId === user.id && p.status === 'PENDING')); // If we have a matching or pending payment, let's update or insert if (userPaymentIdx >= 0) { db.payments[userPaymentIdx].status = event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED' ? 'CONFIRMED' : event === 'PAYMENT_OVERDUE' ? 'OVERDUE' : event === 'PAYMENT_DELETED' ? 'DELETED' : db.payments[userPaymentIdx].status; if (event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED') { db.payments[userPaymentIdx].paidAt = new Date().toISOString(); } } else if (event !== 'PAYMENT_DELETED') { // Register a new payment log (unless it's a deletion of an unknown payment) db.payments.push({ id: payment.id || `pay_wh_${Math.random().toString(36).substring(2, 9)}`, userId: user.id, value: payment.value || 49.90, status: (event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED') ? 'CONFIRMED' : event === 'PAYMENT_OVERDUE' ? 'OVERDUE' : 'PENDING', billingType: payment.billingType || 'PIX', invoiceUrl: payment.invoiceUrl || `https://sandbox.asaas.com/i/${payment.id}`, dueDate: payment.dueDate || new Date().toISOString().split('T')[0], paidAt: (event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED') ? new Date().toISOString() : null, createdAt: new Date().toISOString() }); } // Handle subscription access control if (event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED') { const description = payment.description || ''; if (description.startsWith('unlock_course_')) { const courseId = description.replace('unlock_course_', ''); if (!user.unlockedCourses) user.unlockedCourses = []; if (!user.unlockedCourses.includes(courseId)) { user.unlockedCourses.push(courseId); } console.log(`Webhook: Curso ${courseId} LIBERADO para usuário ${user.email}`); } else { // Liberar / manter acesso user.subscriptionStatus = 'ACTIVE'; console.log(`Webhook: Acesso LIBERADO para usuário ${user.email} (${user.name})`); } } else if (event === 'PAYMENT_OVERDUE' || event === 'SUBSCRIPTION_DELETED') { // Bloquear acesso automaticamente se for assinatura const description = payment.description || ''; if (!description.startsWith('unlock_course_')) { user.subscriptionStatus = event === 'PAYMENT_OVERDUE' ? 'OVERDUE' : 'CANCELED'; console.log(`Webhook: Acesso BLOQUEADO para usuário ${user.email} (${user.name}) - Motivo: ${event}`); } } } else { console.warn(`Webhook: Cliente Asaas ${customerId} não encontrado no banco local.`); } saveDb(db); 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, (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 db = loadDb(); let activity = db.activities.find(a => a.moduleId === moduleId); if (activity) { activity.questions = questions; } else { activity = { id: `act_${Math.random().toString(36).substring(2, 9)}`, moduleId, questions, createdAt: new Date().toISOString() }; db.activities.push(activity); } saveDb(db); res.json({ success: true, activity }); }); // Admin/Student: Get module activity app.get('/api/modules/:id/activity', authenticateToken, (req: AuthenticatedRequest, res: Response) => { const moduleId = req.params.id; const db = loadDb(); const activity = db.activities.find(a => a.moduleId === moduleId); if (!activity) { res.status(404).json({ error: 'Atividade não encontrada' }); return; } // If student, remove correctIndex so they can't cheat easily by inspecting the network request if (req.user?.role === 'student') { const safeQuestions = activity.questions.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, (req: AuthenticatedRequest, res: Response) => { const courseId = req.params.id; const { mode } = req.body; // 'PER_MODULE' or 'FULL_COURSE' if (mode !== 'PER_MODULE' && mode !== 'FULL_COURSE') { res.status(400).json({ error: 'Modo inválido' }); return; } const db = loadDb(); const course = db.courses.find(c => c.id === courseId); if (!course) { res.status(404).json({ error: 'Curso não encontrado' }); return; } course.certificateMode = mode; saveDb(db); res.json({ success: true, course }); }); // Admin: Manually Unlock Certificate app.post('/api/admin/students/:userId/unlock-certificate', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { const { userId } = req.params; const { type, targetId, courseName, moduleName } = req.body; // type: 'COURSE' or 'MODULE' const db = loadDb(); const certId = `cert_${Math.random().toString(36).substring(2, 9)}`; const certificate = { id: certId, userId, courseId: type === 'COURSE' ? targetId : null, moduleId: type === 'MODULE' ? targetId : null, certificateType: type, unlockedAt: new Date().toISOString(), finalGrade: 10, // Default manual grade courseName, moduleName: moduleName || null }; db.certificates.push(certificate); saveDb(db); res.json({ success: true, certificate }); }); // Student: Submit Activity Answers app.post('/api/modules/:id/submit-activity', authenticateToken, (req: AuthenticatedRequest, res: Response) => { const moduleId = req.params.id; const { answers } = req.body; // Array of selected indexes const userId = req.user!.id; const db = loadDb(); const activity = db.activities.find(a => a.moduleId === moduleId); if (!activity) { res.status(404).json({ error: 'Atividade não encontrada' }); return; } // Calculate Grade let correctCount = 0; const totalQuestions = activity.questions.length; activity.questions.forEach((q: any, index: number) => { if (answers[index] === q.correctIndex) { correctCount++; } }); const score = totalQuestions > 0 ? (correctCount / totalQuestions) * 10 : 0; const passed = score >= 6; // Configurable passing score let gradeIdx = db.moduleGrades.findIndex(g => g.userId === userId && g.activityId === activity.id); if (gradeIdx >= 0) { db.moduleGrades[gradeIdx].score = score; db.moduleGrades[gradeIdx].passed = passed; } else { db.moduleGrades.push({ id: `grd_${Math.random().toString(36).substring(2, 9)}`, userId, activityId: activity.id, score, passed, createdAt: new Date().toISOString() }); } // --- Automatic Certificate Generation Logic --- const moduleInfo = db.modules.find(m => m.id === moduleId); if (passed && moduleInfo) { const course = db.courses.find(c => c.id === moduleInfo.courseId); if (course) { const mode = course.certificateMode || 'FULL_COURSE'; if (mode === 'PER_MODULE') { // Check if certificate already exists const exists = db.certificates.find(c => c.userId === userId && c.moduleId === moduleId && c.certificateType === 'MODULE'); if (!exists) { db.certificates.push({ id: `cert_${Math.random().toString(36).substring(2, 9)}`, userId, courseId: course.id, moduleId: moduleInfo.id, certificateType: 'MODULE', unlockedAt: new Date().toISOString(), finalGrade: score, courseName: course.title, moduleName: moduleInfo.title }); } } else { // FULL_COURSE mode // Check if student has passed all modules of this course const allCourseModules = db.modules.filter(m => m.courseId === course.id); let allPassed = true; let totalScore = 0; let evaluatedModules = 0; for (const m of allCourseModules) { const act = db.activities.find(a => a.moduleId === m.id); if (act) { const grade = db.moduleGrades.find(g => g.activityId === act.id && g.userId === userId); if (!grade || !grade.passed) { allPassed = false; break; } totalScore += grade.score; evaluatedModules++; } else { // If a module has no activity, we might consider it "passed" automatically, but let's just ignore it for grades // Or require manual progress? Let's assume if it has no activity, we just skip grade check for it // Actually, wait, let's require 100% watched percentage for modules without activities. const lessons = db.lessons.filter(l => l.moduleId === m.id); for(const lsn of lessons) { const prog = db.progress.find(p => p.lessonId === lsn.id && p.userId === userId); if (!prog || !prog.completed) { allPassed = false; break; } } } } if (allPassed) { const exists = db.certificates.find(c => c.userId === userId && c.courseId === course.id && c.certificateType === 'COURSE'); if (!exists) { const finalGrade = evaluatedModules > 0 ? (totalScore / evaluatedModules) : null; db.certificates.push({ id: `cert_${Math.random().toString(36).substring(2, 9)}`, userId, courseId: course.id, moduleId: null, certificateType: 'COURSE', unlockedAt: new Date().toISOString(), finalGrade, courseName: course.title, moduleName: null }); } } } } } saveDb(db); res.json({ success: true, score, passed }); }); // Student: Get my certificates app.get('/api/certificates', authenticateToken, (req: AuthenticatedRequest, res: Response) => { const db = loadDb(); const myCerts = db.certificates.filter(c => c.userId === req.user!.id); res.json(myCerts); }); // --- NOTIFICATIONS --- app.get('/api/notifications', authenticateToken, (req: AuthenticatedRequest, res: Response) => { const db = loadDb(); const myNotifications = db.notifications.filter(n => n.userId === req.user!.id || n.userId === 'ALL'); res.json(myNotifications.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())); }); app.put('/api/notifications/:id/read', authenticateToken, (req: AuthenticatedRequest, res: Response) => { const db = loadDb(); const notification = db.notifications.find(n => n.id === req.params.id); if (notification && (notification.userId === req.user!.id || notification.userId === 'ALL')) { notification.read = true; saveDb(db); } res.json({ success: true }); }); // --- HELP DESK --- app.get('/api/help', authenticateToken, (req: AuthenticatedRequest, res: Response) => { const db = loadDb(); res.json({ helpText: db.settings?.helpText || '' }); }); app.put('/api/admin/help', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { const db = loadDb(); if (!db.settings) db.settings = {}; db.settings.helpText = req.body.helpText; saveDb(db); res.json({ success: true }); }); // --- MODULE ACTIVITIES (ADMIN) --- app.get('/api/admin/modules/:moduleId/activity', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { const db = loadDb(); const activity = db.activities.find(a => a.moduleId === req.params.moduleId); res.json(activity || { moduleId: req.params.moduleId, questions: [] }); }); app.put('/api/admin/modules/:moduleId/activity', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { const db = loadDb(); const { moduleId } = req.params; const { questions } = req.body; let activity = db.activities.find(a => a.moduleId === moduleId); if (!activity) { activity = { id: `act_${Math.random().toString(36).substring(2, 9)}`, moduleId, questions: [], createdAt: new Date().toISOString() }; db.activities.push(activity); } activity.questions = questions; saveDb(db); res.json(activity); }); // --- VITE MIDDLEWARE / SPA FALLBACK --- async function startServer() { if (process.env.NODE_ENV !== 'production') { 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)); 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}`); }); } startServer();