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 { prisma } from './src/db/prisma'; 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()); // --- 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 initMinio(); // --- 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; if (!email || !password) { res.status(400).json({ error: 'Informe e-mail e senha' }); return; } try { const user = await prisma.user.findUnique({ where: { email: 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 }); } catch (err: any) { res.status(500).json({ error: 'Erro no servidor' }); } }); // 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' }); } }); // 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 courseLessons = course.modules.flatMap(m => m.lessons); 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, thumbnailUrl: course.thumbnailUrl, videoUrl: course.videoUrl, isLocked: course.isLocked, price: course.price, category: course.category, order: course.order, 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, 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 } = 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 { 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, user.cpf); await prisma.user.update({ where: { id: userId }, data: { asaasCustomerId: customerId } }); 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 = 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' }); } }); // 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 } = 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 { 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, user.cpf); await prisma.user.update({ where: { id: userId }, data: { asaasCustomerId: customerId } }); user.asaasCustomerId = customerId; } 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; } const newPayment = await prisma.payment.create({ data: { id: asaasPaymentId, userId: userId, value: price, status: statusAsaas, 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 += latestSubscription.value; } } } // Total Revenue Calculation const totalRevenue = allPayments .filter(p => p.status === 'CONFIRMED' || p.status === 'RECEIVED') .reduce((sum, p) => sum + 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) + 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) + 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: { createdAt: '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.type, paymentId: '', customer: w.userId, receivedAt: w.createdAt, status: w.read ? 'READ' : 'UNREAD' })) }); }); // 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 + curr.value, 0), unlockedCourses: u.unlockedCourses || [], moduleGrades: u.moduleGrades, certificates: u.certificates, payments: u.payments }; }); 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', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const settings = await prisma.systemSettings.findFirst(); res.json({ trialDurationDays: settings?.trialDurationDays || 7, helpText: settings?.helpText || '', enableBoleto: settings?.enableBoleto !== false }); }); // 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 || '' }); }); app.post('/api/admin/settings', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const newSettings = req.body; const settings = await prisma.systemSettings.findFirst(); if (settings) { await prisma.systemSettings.update({ where: { id: settings.id }, data: newSettings }); } else { await prisma.systemSettings.create({ data: newSettings }); } 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' }); } }); // --- 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 }, { 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/logout/${instance}`, { headers: { apikey: apiKey } }); } catch (err: any) { // ignore 404 or others during logout } 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) const cleanPhone = phone.replace(/\D/g, ''); if (!cleanPhone) return false; const apiUrl = settings.evolutionApiUrl.replace(/\/$/, ''); const instance = settings.evolutionInstance || 'microtecflix'; await axios.post(`${apiUrl}/message/sendText/${instance}`, { number: cleanPhone, options: { delay: 1200 }, textMessage: { text } }, { 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, thumbnailUrl: thumbnail, category, isLocked: isLocked === true || isLocked === 'true', price: price ? Number(price) : 0, order: await prisma.course.count() + 1 } }); 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 } = 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 || '', 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 } = 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 }), ...(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.notification.create({ data: { userId: 'ALL', // Log events to ALL as webhookLogs type: event, message: JSON.stringify(req.body), read: false } }); } 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); } 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)); 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();