From df1d63019eb79ebab0644440c62750c28e6a75d9 Mon Sep 17 00:00:00 2001 From: Sidney Gomes Date: Wed, 22 Jul 2026 21:50:50 -0300 Subject: [PATCH] fix(admin): use correct admin token and fix react dom crash --- server-json.ts | 1760 ++++++++++++++++++++++++ server-prisma.ts | 1727 ----------------------- server.ts | 1571 +++++++++++---------- src/components/AdminPricingManager.tsx | 6 +- 4 files changed, 2532 insertions(+), 2532 deletions(-) create mode 100644 server-json.ts delete mode 100644 server-prisma.ts diff --git a/server-json.ts b/server-json.ts new file mode 100644 index 0000000..5620f7c --- /dev/null +++ b/server-json.ts @@ -0,0 +1,1760 @@ +import express, { Request, Response, NextFunction } from 'express'; +import path from 'path'; +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcryptjs'; +import multer from 'multer'; +import { S3Client, PutObjectCommand, CreateBucketCommand, HeadBucketCommand, PutBucketPolicyCommand } from '@aws-sdk/client-s3'; +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()); + +// --- 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; + } + + 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, cpf); + + 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, + cpf, + 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, + cpf: newUser.cpf, + whatsapp: newUser.whatsapp, + birthDate: newUser.birthDate, + avatarUrl: newUser.avatarUrl, + createdAt: newUser.createdAt + } + }); + } 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 || [], + cpf: user.cpf, + whatsapp: user.whatsapp, + birthDate: user.birthDate, + avatarUrl: user.avatarUrl, + createdAt: user.createdAt + } + }); +}); + +// 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 || [], + cpf: user.cpf, + whatsapp: user.whatsapp, + birthDate: user.birthDate, + avatarUrl: user.avatarUrl, + createdAt: user.createdAt + }); +}); + +// 3.1. Profile: Update Data +app.put('/api/users/profile', authenticateToken, (req: AuthenticatedRequest, res: Response) => { + const { name, whatsapp, birthDate, cpf } = req.body; + const userId = req.user!.id; + 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; + } + + if (name !== undefined) user.name = name; + if (whatsapp !== undefined) user.whatsapp = whatsapp; + if (birthDate !== undefined) user.birthDate = birthDate; + if (cpf !== undefined) user.cpf = cpf; + + saveDb(db); + + res.json({ success: true, user }); +}); + +// 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}`; + + const db = loadDb(); + const user = db.users.find(u => u.id === userId); + if (user) { + user.avatarUrl = avatarUrl; + saveDb(db); + } + + 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, (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_') || ['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); + 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(), + courseId: courseId + }; + 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, 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 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_') || ['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); + user.asaasCustomerId = customerId; + } + + let plan = planId ? db.plans?.find(p => p.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: 'PENDING' | 'CONFIRMED' = 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') { + // Pagamento Único + 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 { + // Assinatura (Mensal ou Anual) + 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; + } + + // Add subscription payment to database + const newPayment: SubscriptionPayment = { + id: asaasPaymentId, + userId, + value: price, + status: statusAsaas, + billingType, + invoiceUrl: invoiceUrlStr, + dueDate: asaasDueDate, + paidAt: statusAsaas === 'CONFIRMED' ? new Date().toISOString() : null, + createdAt: new Date().toISOString(), + planId: planId + }; + db.payments.push(newPayment); + + // If sandbox auto-approves credit card + if (statusAsaas === 'CONFIRMED') { + if (cycle === 'LIFETIME' && plan) { + if (!plan.includedCourses || plan.includedCourses.length === 0) { + user.subscriptionStatus = 'ACTIVE'; // Libera tudo + } else { + user.unlockedCourses = [...(user.unlockedCourses || []), ...plan.includedCourses]; + } + } else { + user.subscriptionStatus = 'ACTIVE'; + } + } + + saveDb(db); + + 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 +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 Calculation + let mrr = 0; + const activeUsers = db.users.filter(u => u.role === 'student' && u.subscriptionStatus === 'ACTIVE'); + for (const u of activeUsers) { + const studentPayments = db.payments + .filter(p => p.userId === u.id && (p.status === 'CONFIRMED' || p.status === 'RECEIVED')) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + if (studentPayments.length > 0) { + const latest = studentPayments[0]; + if (latest.planId) { + const plan = db.plans?.find(p => p.id === latest.planId); + if (plan) { + if (plan.cycle === 'MONTHLY') mrr += plan.price; + else if (plan.cycle === 'YEARLY') mrr += (plan.price / 12); + // LIFETIME does not add to MRR + } else { + mrr += latest.value; // Fallback + } + } else if (latest.courseId) { + // Avulso, 0 MRR + } else { + // Legacy payment, assume monthly + mrr += latest.value; + } + } + } + + // Total Revenue Calculation + const totalRevenue = db.payments + .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 = db.payments.filter( + p => (p.status === 'CONFIRMED' || p.status === 'RECEIVED') && new Date(p.createdAt) >= thirtyDaysAgo + ); + + const revenueByDay: Record = {}; + const categoryRevenue: Record = {}; + + for (const pay of recentConfirmedPayments) { + const dateStr = new Date(pay.createdAt).toISOString().split('T')[0]; + revenueByDay[dateStr] = (revenueByDay[dateStr] || 0) + pay.value; + + let category = 'Desconhecido'; + if (pay.courseId) { + const c = db.courses.find(c => c.id === pay.courseId); + category = c ? `Curso: ${c.title}` : 'Avulso'; + } else if (pay.planId) { + const p = db.plans?.find(p => p.id === pay.planId); + category = p ? `Plano: ${p.title}` : 'Assinatura'; + } 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] + })); + + // 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); + let desc = ''; + if (p.courseId) { + desc = `Curso: ${db.courses.find(c => c.id === p.courseId)?.title || p.courseId}`; + } else if (p.planId) { + desc = `Plano: ${db.plans?.find(pl => pl.id === p.planId)?.title || p.planId}`; + } + return { + ...p, + studentName: student ? student.name : 'Aluno Removido', + studentEmail: student ? student.email : '', + description: desc + }; + }) + .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, + totalRevenue, + activeSubscribers: totalActive, + trialSubscribers: totalTrial, + overdueSubscribers: totalOverdue, + canceledSubscribers: totalCanceled + }, + revenueData, + categoryData, + 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 { event, payment } = req.body; + + // 1. Logging Antecipado (para debuggar mesmo se der 401) + const logId = `wh_log_${Math.random().toString(36).substring(2, 9)}`; + if (event) { + db.webhookLogs.push({ + id: logId, + event, + payload: JSON.stringify(req.body), + receivedAt: new Date().toISOString() + }); + saveDb(db); // Salva o log imediatamente para não perdê-lo se der return early + } + + // 2. Validação de Segurança + const configuredSecret = db.settings?.asaasWebhookSecret || process.env.ASAAS_WEBHOOK_TOKEN || 'devflix_webhook_secret_key'; + // O Asaas usa asaas-access-token por padrão + 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; + } + + // 3. Processamento do Pagamento + const customerId = payment.customer; + const user = db.users.find(u => u.asaasCustomerId === customerId); + + if (user) { + // Busca APENAS pelo ID exato da fatura + let userPaymentIdx = db.payments.findIndex(p => p.id === payment.id); + + 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; + + if (userPaymentIdx >= 0) { + if (newStatus) { + db.payments[userPaymentIdx].status = newStatus as any; + } + if (isPaid) { + db.payments[userPaymentIdx].paidAt = new Date().toISOString(); + } + } else if (!isRefunded) { + // Se não achou e não é estorno/deleção, registra a fatura enviada pelo Asaas + // Tenta achar o plano/curso da última compra do usuário para manter o histórico + const lastPayment = db.payments.filter(p => p.userId === user.id).sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0]; + + db.payments.push({ + id: payment.id, // Usa o ID exato + 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().toISOString().split('T')[0], + paidAt: isPaid ? new Date().toISOString() : null, + createdAt: new Date().toISOString(), + planId: lastPayment?.planId, + courseId: lastPayment?.courseId + }); + } + + // 4. Lógica de Liberação de Acesso / Curso + const description = payment.description || ''; + const isCoursePayment = description.startsWith('unlock_course_'); + + if (isPaid) { + if (isCoursePayment) { + 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 { + user.subscriptionStatus = 'ACTIVE'; + console.log(`Webhook: Assinatura LIBERADA para usuário ${user.email}`); + } + } else if (isOverdue || isRefunded || event === 'SUBSCRIPTION_DELETED') { + if (!isCoursePayment) { + // Bloqueia acesso da assinatura + user.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.`); + } + + 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); +}); + +// --- PUBLIC/STUDENT PLANS API --- +app.get('/api/plans', authenticateToken, (req: AuthenticatedRequest, res: Response) => { + const db = loadDb(); + const activePlans = (db.plans || []).filter(p => p.active); + res.json(activePlans); +}); + +// --- ADMIN PLANS API ENDPOINTS --- + +app.get('/api/admin/plans', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { + const db = loadDb(); + res.json(db.plans || []); +}); + +app.post('/api/admin/plans', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { + const db = loadDb(); + const { title, description, price, cycle, includedCourses, active } = req.body; + + const newPlan = { + id: `plan_${Math.random().toString(36).substring(2, 9)}`, + title: title || 'Novo Plano', + description: description || '', + price: price || 0, + cycle: cycle || 'LIFETIME', + includedCourses: includedCourses || [], + active: active !== undefined ? active : true, + createdAt: new Date().toISOString() + }; + + if (!db.plans) db.plans = []; + db.plans.push(newPlan); + saveDb(db); + res.json({ success: true, plan: newPlan }); +}); + +app.put('/api/admin/plans/:id', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { + const db = loadDb(); + const planIdx = db.plans?.findIndex(p => p.id === req.params.id); + + if (planIdx !== undefined && planIdx >= 0 && db.plans) { + db.plans[planIdx] = { ...db.plans[planIdx], ...req.body }; + saveDb(db); + res.json({ success: true, plan: db.plans[planIdx] }); + } else { + res.status(404).json({ error: 'Plano não encontrado' }); + } +}); + +app.delete('/api/admin/plans/:id', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { + const db = loadDb(); + if (db.plans) { + db.plans = db.plans.filter(p => p.id !== req.params.id); + saveDb(db); + } + res.json({ success: true }); +}); + +// Update single course price and lock status directly +app.put('/api/admin/courses/:id/price', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { + const db = loadDb(); + const courseIdx = db.courses.findIndex(c => c.id === req.params.id); + + if (courseIdx >= 0) { + db.courses[courseIdx].price = req.body.price; + db.courses[courseIdx].isLocked = req.body.isLocked; + saveDb(db); + res.json({ success: true, course: db.courses[courseIdx] }); + } else { + res.status(404).json({ error: 'Curso não encontrado' }); + } +}); + +// --- 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(); diff --git a/server-prisma.ts b/server-prisma.ts deleted file mode 100644 index 8893db3..0000000 --- a/server-prisma.ts +++ /dev/null @@ -1,1727 +0,0 @@ -import express, { Request, Response, NextFunction } from 'express'; -import path from 'path'; -import jwt from 'jsonwebtoken'; -import bcrypt from 'bcryptjs'; -import multer from 'multer'; -import { S3Client, PutObjectCommand, CreateBucketCommand, HeadBucketCommand, PutBucketPolicyCommand } from '@aws-sdk/client-s3'; -import { createServer as createViteServer } from 'vite'; -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 = 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.subscriptionPayment.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.subscriptionPayment.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 = 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.subscriptionPayment.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.subscriptionPayment.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.notification.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 || '' - }); -}); - -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' }); - } -}); - -// 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.subscriptionPayment.findUnique({ where: { id: payment.id } }); - - if (existingPayment) { - if (newStatus) { - await prisma.subscriptionPayment.update({ - where: { id: payment.id }, - data: { - status: newStatus as any, - ...(isPaid && { paidAt: new Date() }) - } - }); - } - } else if (!isRefunded) { - const lastPayment = await prisma.subscriptionPayment.findFirst({ - where: { userId: user.id }, - orderBy: { createdAt: 'desc' } - }); - - await prisma.subscriptionPayment.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() { - 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(); diff --git a/server.ts b/server.ts index 5620f7c..8893db3 100644 --- a/server.ts +++ b/server.ts @@ -5,7 +5,7 @@ import bcrypt from 'bcryptjs'; import multer from 'multer'; import { S3Client, PutObjectCommand, CreateBucketCommand, HeadBucketCommand, PutBucketPolicyCommand } from '@aws-sdk/client-s3'; import { createServer as createViteServer } from 'vite'; -import { loadDb, saveDb } from './src/db/db'; +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'; @@ -119,45 +119,40 @@ app.post('/api/auth/register', async (req: Request, res: Response) => { return; } - const db = loadDb(); - const userExists = db.users.find(u => u.email.toLowerCase() === email.toLowerCase()); + 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; - } + 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, cpf); - - 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)}`, + // 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', // Defaults to trial + subscriptionStatus: 'TRIAL', asaasCustomerId, - cpf, - birthDate, - whatsapp, - trialEndsAt: trialEndsAt.toISOString(), - createdAt: createdAt.toISOString() - }; - - db.users.push(newUser); - saveDb(db); + // @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( @@ -168,18 +163,7 @@ app.post('/api/auth/register', async (req: Request, res: Response) => { res.status(201).json({ token, - user: { - id: newUser.id, - name: newUser.name, - email: newUser.email, - role: newUser.role, - subscriptionStatus: newUser.subscriptionStatus, - cpf: newUser.cpf, - whatsapp: newUser.whatsapp, - birthDate: newUser.birthDate, - avatarUrl: newUser.avatarUrl, - createdAt: newUser.createdAt - } + user: newUser }); } catch (err: any) { res.status(500).json({ error: err.message || 'Erro ao registrar usuário' }); @@ -187,7 +171,7 @@ app.post('/api/auth/register', async (req: Request, res: Response) => { }); // 2. Auth: Login -app.post('/api/auth/login', (req: Request, res: Response) => { +app.post('/api/auth/login', async (req: Request, res: Response) => { const { email, password } = req.body; if (!email || !password) { @@ -195,94 +179,79 @@ app.post('/api/auth/login', (req: Request, res: Response) => { return; } - const db = loadDb(); - const user = db.users.find(u => u.email.toLowerCase() === email.toLowerCase()); + 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: { - id: user.id, - name: user.name, - email: user.email, - role: user.role, - subscriptionStatus: user.subscriptionStatus, - unlockedCourses: user.unlockedCourses || [], - cpf: user.cpf, - whatsapp: user.whatsapp, - birthDate: user.birthDate, - avatarUrl: user.avatarUrl, - createdAt: user.createdAt + 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, (req: AuthenticatedRequest, res: Response) => { +app.get('/api/auth/me', authenticateToken, async (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); + 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; + 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' }); } - - res.json({ - id: user.id, - name: user.name, - email: user.email, - role: user.role, - subscriptionStatus: user.subscriptionStatus, - unlockedCourses: user.unlockedCourses || [], - cpf: user.cpf, - whatsapp: user.whatsapp, - birthDate: user.birthDate, - avatarUrl: user.avatarUrl, - createdAt: user.createdAt - }); }); // 3.1. Profile: Update Data -app.put('/api/users/profile', authenticateToken, (req: AuthenticatedRequest, res: Response) => { +app.put('/api/users/profile', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const { name, whatsapp, birthDate, cpf } = req.body; const userId = req.user!.id; - const db = loadDb(); - const user = db.users.find(u => u.id === userId); - if (!user) { + 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' }); - return; } - - if (name !== undefined) user.name = name; - if (whatsapp !== undefined) user.whatsapp = whatsapp; - if (birthDate !== undefined) user.birthDate = birthDate; - if (cpf !== undefined) user.cpf = cpf; - - saveDb(db); - - res.json({ success: true, user }); }); // 3.2. Profile: Avatar Upload @@ -307,12 +276,10 @@ app.post('/api/users/avatar', authenticateToken, upload.single('avatar'), async // URL Pública configurada via Traefik const avatarUrl = `https://s3-estudo.microtecinformaticacurso.com.br/microtecflix/${filename}`; - const db = loadDb(); - const user = db.users.find(u => u.id === userId); - if (user) { - user.avatarUrl = avatarUrl; - saveDb(db); - } + await prisma.user.update({ + where: { id: userId }, + data: { avatarUrl } + }); res.json({ success: true, avatarUrl }); } catch (err: any) { @@ -322,33 +289,51 @@ app.post('/api/users/avatar', authenticateToken, upload.single('avatar'), async }); // 4. Courses List (With Progress) -app.get('/api/courses', authenticateToken, (req: AuthenticatedRequest, res: Response) => { - const db = loadDb(); +app.get('/api/courses', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const userId = req.user!.id; - const user = db.users.find(u => u.id === userId); + + const user = await prisma.user.findUnique({ + where: { 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 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) { - 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, + 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, @@ -360,45 +345,58 @@ app.get('/api/courses', authenticateToken, (req: AuthenticatedRequest, res: Resp }); // 5. Course Details (with Modules, Lessons, Progress & Notes) -app.get('/api/courses/:id', authenticateToken, (req: AuthenticatedRequest, res: Response) => { - const db = loadDb(); +app.get('/api/courses/:id', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const userId = req.user!.id; const courseId = req.params.id; - const course = db.courses.find(c => c.id === courseId); + 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 = db.users.find(u => u.id === userId); + 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; - // Get modules - const courseModules = db.modules - .filter(m => m.courseId === courseId) - .sort((a, b) => a.order - b.order); + 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) } } } + }); - // 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); + 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 { + ...lesson, + progress: prog ? { + watchedPercentage: prog.watchedPercentage, + completed: prog.completed + } : { watchedPercentage: 0, completed: false }, + note: note ? note.content : '' + }; + }); return { ...mod, @@ -406,9 +404,10 @@ app.get('/api/courses/:id', authenticateToken, (req: AuthenticatedRequest, res: }; }); + const { modules, ...courseData } = course; res.json({ course: { - ...course, + ...courseData, isUnlocked }, modules: modulesWithLessons @@ -446,9 +445,8 @@ app.post('/api/courses/:id/unlock', authenticateToken, checkoutRateLimiter, asyn 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); + 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' }); @@ -467,6 +465,10 @@ app.post('/api/courses/:id/unlock', authenticateToken, checkoutRateLimiter, asyn } 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; } @@ -505,20 +507,18 @@ app.post('/api/courses/:id/unlock', authenticateToken, checkoutRateLimiter, asyn 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(), - courseId: courseId - }; - db.payments.push(newPayment); - saveDb(db); + const newPayment = await prisma.subscriptionPayment.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) { @@ -528,7 +528,7 @@ app.post('/api/courses/:id/unlock', authenticateToken, checkoutRateLimiter, asyn }); // 6. Lesson Progress tracking -app.post('/api/lessons/:id/progress', authenticateToken, (req: AuthenticatedRequest, res: Response) => { +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; @@ -538,87 +538,99 @@ app.post('/api/lessons/:id/progress', authenticateToken, (req: AuthenticatedRequ return; } - const db = loadDb(); - const lesson = db.lessons.find(l => l.id === lessonId); + const lesson = await prisma.lesson.findUnique({ where: { 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() + try { + const existingProgress = await prisma.progress.findFirst({ + where: { userId, lessonId } }); - } - saveDb(db); - res.json({ success: true, completed: isCompleted }); + 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, (req: AuthenticatedRequest, res: Response) => { +app.get('/api/lessons/:id/notes', authenticateToken, async (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); + const note = await prisma.note.findFirst({ + where: { userId, lessonId } + }); res.json({ content: note ? note.content : '' }); }); -app.post('/api/lessons/:id/notes', authenticateToken, (req: AuthenticatedRequest, res: Response) => { +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; - 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() + try { + const existingNote = await prisma.note.findFirst({ + where: { userId, lessonId } }); - } - saveDb(db); - res.json({ success: true }); + 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, (req: AuthenticatedRequest, res: Response) => { - const db = loadDb(); +app.get('/api/subscription/status', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const userId = req.user!.id; - const user = db.users.find(u => u.id === userId); + const user = await prisma.user.findUnique({ where: { 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()); + const payments = await prisma.subscriptionPayment.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' } + }); res.json({ status: user.subscriptionStatus, @@ -637,8 +649,7 @@ app.post('/api/subscription/subscribe', authenticateToken, async (req: Authentic return; } - const db = loadDb(); - const user = db.users.find(u => u.id === userId); + const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) { res.status(404).json({ error: 'Usuário não encontrado' }); return; @@ -652,22 +663,25 @@ app.post('/api/subscription/subscribe', authenticateToken, async (req: Authentic } 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 ? db.plans?.find(p => p.id === planId) : null; + 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: 'PENDING' | 'CONFIRMED' = billingType === 'CREDIT_CARD' ? 'CONFIRMED' : 'PENDING'; + 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') { - // Pagamento Único let processedCardInfo = creditCardInfo; if (billingType === 'CREDIT_CARD' && creditCardInfo?.creditCard?.number) { try { @@ -702,7 +716,6 @@ app.post('/api/subscription/subscribe', authenticateToken, async (req: Authentic pixData = await AsaasService.getPixQrCode(asaasPayment.id); } } else { - // Assinatura (Mensal ou Anual) const { subscriptionId, payment } = await AsaasService.createSubscription( customerId, price, @@ -717,36 +730,45 @@ app.post('/api/subscription/subscribe', authenticateToken, async (req: Authentic asaasDueDate = payment.dueDate || asaasDueDate; } - // Add subscription payment to database - const newPayment: SubscriptionPayment = { - id: asaasPaymentId, - userId, - value: price, - status: statusAsaas, - billingType, - invoiceUrl: invoiceUrlStr, - dueDate: asaasDueDate, - paidAt: statusAsaas === 'CONFIRMED' ? new Date().toISOString() : null, - createdAt: new Date().toISOString(), - planId: planId - }; - db.payments.push(newPayment); + const newPayment = await prisma.subscriptionPayment.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 sandbox auto-approves credit card if (statusAsaas === 'CONFIRMED') { if (cycle === 'LIFETIME' && plan) { if (!plan.includedCourses || plan.includedCourses.length === 0) { - user.subscriptionStatus = 'ACTIVE'; // Libera tudo + await prisma.user.update({ + where: { id: userId }, + data: { subscriptionStatus: 'ACTIVE' } + }); } else { - user.unlockedCourses = [...(user.unlockedCourses || []), ...plan.includedCourses]; + await prisma.user.update({ + where: { id: userId }, + data: { + unlockedCourses: { + set: [...new Set([...(user.unlockedCourses || []), ...plan.includedCourses])] + } + } + }); } } else { - user.subscriptionStatus = 'ACTIVE'; + await prisma.user.update({ + where: { id: userId }, + data: { subscriptionStatus: 'ACTIVE' } + }); } } - saveDb(db); - res.json({ success: true, payment: newPayment, pixData }); } catch (err: any) { console.error('Subscription error:', err); @@ -757,44 +779,45 @@ app.post('/api/subscription/subscribe', authenticateToken, async (req: Authentic // --- ADMIN API ENDPOINTS --- // 1. Dashboard statistics -app.get('/api/admin/dashboard', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { - const db = loadDb(); +// 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 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; + const activeUsersIds = users.filter(u => u.subscriptionStatus === 'ACTIVE').map(u => u.id); + + const allPayments = await prisma.subscriptionPayment.findMany({ + include: { + user: true, + course: true, + plan: true + }, + orderBy: { createdAt: 'desc' } + }); // MRR Calculation let mrr = 0; - const activeUsers = db.users.filter(u => u.role === 'student' && u.subscriptionStatus === 'ACTIVE'); - for (const u of activeUsers) { - const studentPayments = db.payments - .filter(p => p.userId === u.id && (p.status === 'CONFIRMED' || p.status === 'RECEIVED')) - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + 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 (studentPayments.length > 0) { - const latest = studentPayments[0]; - if (latest.planId) { - const plan = db.plans?.find(p => p.id === latest.planId); - if (plan) { - if (plan.cycle === 'MONTHLY') mrr += plan.price; - else if (plan.cycle === 'YEARLY') mrr += (plan.price / 12); - // LIFETIME does not add to MRR - } else { - mrr += latest.value; // Fallback - } - } else if (latest.courseId) { - // Avulso, 0 MRR - } else { - // Legacy payment, assume monthly - mrr += latest.value; + 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 = db.payments + const totalRevenue = allPayments .filter(p => p.status === 'CONFIRMED' || p.status === 'RECEIVED') .reduce((sum, p) => sum + p.value, 0); @@ -802,24 +825,22 @@ app.get('/api/admin/dashboard', authenticateToken, requireAdmin, (req: Authentic const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - const recentConfirmedPayments = db.payments.filter( - p => (p.status === 'CONFIRMED' || p.status === 'RECEIVED') && new Date(p.createdAt) >= thirtyDaysAgo + 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 = new Date(pay.createdAt).toISOString().split('T')[0]; + const dateStr = pay.createdAt.toISOString().split('T')[0]; revenueByDay[dateStr] = (revenueByDay[dateStr] || 0) + pay.value; let category = 'Desconhecido'; - if (pay.courseId) { - const c = db.courses.find(c => c.id === pay.courseId); - category = c ? `Curso: ${c.title}` : 'Avulso'; - } else if (pay.planId) { - const p = db.plans?.find(p => p.id === pay.planId); - category = p ? `Plano: ${p.title}` : 'Assinatura'; + 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'; } @@ -836,29 +857,35 @@ app.get('/api/admin/dashboard', authenticateToken, requireAdmin, (req: Authentic value: categoryRevenue[name] })); - // Let's format recent invoice history for logs/charts - const recentPayments = db.payments + const recentPaymentsFormatted = allPayments + .slice(0, 15) .map(p => { - const student = db.users.find(u => u.id === p.userId); let desc = ''; - if (p.courseId) { - desc = `Curso: ${db.courses.find(c => c.id === p.courseId)?.title || p.courseId}`; - } else if (p.planId) { - desc = `Plano: ${db.plans?.find(pl => pl.id === p.planId)?.title || p.planId}`; - } + if (p.course) desc = `Curso: ${p.course.title}`; + else if (p.plan) desc = `Plano: ${p.plan.title}`; + return { - ...p, - studentName: student ? student.name : 'Aluno Removido', - studentEmail: student ? student.email : '', + 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 }; - }) - .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); + const webhookLogs = await prisma.notification.findMany({ + orderBy: { createdAt: 'desc' }, + take: 15 + }); res.json({ stats: { @@ -871,104 +898,115 @@ app.get('/api/admin/dashboard', authenticateToken, requireAdmin, (req: Authentic }, revenueData, categoryData, - recentPayments, - webhookLogs + 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, (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 - }; - }); +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 + } + }); - res.json(students); + 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, (req: AuthenticatedRequest, res: Response) => { +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: SubscriptionStatus[] = ['ACTIVE', 'OVERDUE', 'CANCELED', 'TRIAL']; - if (!status || !allowedStatuses.includes(status)) { + const allowedStatuses = ['ACTIVE', 'OVERDUE', 'CANCELED', 'TRIAL']; + if (!status || !allowedStatuses.includes(status as any)) { 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) { + 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' }); - 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 || {}; +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 + 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 || {}; +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 || '' + 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) => { +app.post('/api/admin/settings', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const newSettings = req.body; - const db = loadDb(); - db.settings = { - ...db.settings, - ...newSettings - }; + 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 + }); + } - saveDb(db); - res.json({ success: true, settings: db.settings }); + 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) => { @@ -982,7 +1020,7 @@ app.post('/api/admin/test-asaas', authenticateToken, requireAdmin, async (req: A }); // Admin manually unlock or lock a course for a student -app.post('/api/admin/students/:id/unlock-course', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { +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; @@ -991,32 +1029,33 @@ app.post('/api/admin/students/:id/unlock-course', authenticateToken, requireAdmi return; } - const db = loadDb(); - const student = db.users.find(u => u.id === studentId && u.role === 'student'); + const student = await prisma.user.findUnique({ where: { id: studentId, role: 'student' } }); if (!student) { res.status(404).json({ error: 'Estudante não encontrado' }); return; } - if (!student.unlockedCourses) { - student.unlockedCourses = []; - } + let newUnlocked = student.unlockedCourses || []; if (unlocked) { - if (!student.unlockedCourses.includes(courseId)) { - student.unlockedCourses.push(courseId); + if (!newUnlocked.includes(courseId)) { + newUnlocked.push(courseId); } } else { - student.unlockedCourses = student.unlockedCourses.filter(id => id !== courseId); + newUnlocked = newUnlocked.filter(id => id !== courseId); } - saveDb(db); - res.json({ success: true, unlockedCourses: student.unlockedCourses }); + 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, (req: AuthenticatedRequest, res: Response) => { +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) { @@ -1024,75 +1063,56 @@ app.post('/api/admin/courses', authenticateToken, requireAdmin, (req: Authentica 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); + 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, (req: AuthenticatedRequest, res: Response) => { +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; - const db = loadDb(); - const idx = db.courses.findIndex(c => c.id === courseId); - - if (idx < 0) { + 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' }); - 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) => { +app.delete('/api/admin/courses/:id', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const courseId = req.params.id; - const db = loadDb(); - const idx = db.courses.findIndex(c => c.id === courseId); - - if (idx < 0) { + try { + await prisma.course.delete({ where: { id: courseId } }); + res.json({ success: true }); + } catch (error) { 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) => { +app.post('/api/admin/modules', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const { courseId, title, order } = req.body; if (!courseId || !title) { @@ -1100,62 +1120,50 @@ app.post('/api/admin/modules', authenticateToken, requireAdmin, (req: Authentica 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); + 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, (req: AuthenticatedRequest, res: Response) => { +app.put('/api/admin/modules/:id', authenticateToken, requireAdmin, async (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) { + 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' }); - 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) => { +app.delete('/api/admin/modules/:id', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const moduleId = req.params.id; - const db = loadDb(); - const idx = db.modules.findIndex(m => m.id === moduleId); - - if (idx < 0) { + try { + await prisma.module.delete({ where: { id: moduleId } }); + res.json({ success: true }); + } catch (error) { 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) => { +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) { @@ -1163,123 +1171,103 @@ app.post('/api/admin/lessons', authenticateToken, requireAdmin, (req: Authentica 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() - }; + const count = await prisma.lesson.count({ where: { moduleId } }); - db.lessons.push(newLesson); - saveDb(db); + 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, (req: AuthenticatedRequest, res: Response) => { +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; - const db = loadDb(); - const idx = db.lessons.findIndex(l => l.id === lessonId); - - if (idx < 0) { + 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' }); - 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) => { +app.delete('/api/admin/lessons/:id', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const lessonId = req.params.id; - const db = loadDb(); - const idx = db.lessons.findIndex(l => l.id === lessonId); - - if (idx < 0) { + try { + await prisma.lesson.delete({ where: { id: lessonId } }); + res.json({ success: true }); + } catch (error) { 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 } +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; } - 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); + 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') { - 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); + } 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' }); } - - saveDb(db); - res.json({ success: true }); }); // --- ASAAS WEBHOOK HANDLER --- -app.post('/api/webhooks/asaas', (req: Request, res: Response) => { - const db = loadDb(); +app.post('/api/webhooks/asaas', async (req: Request, res: Response) => { const { event, payment } = req.body; - // 1. Logging Antecipado (para debuggar mesmo se der 401) - const logId = `wh_log_${Math.random().toString(36).substring(2, 9)}`; if (event) { - db.webhookLogs.push({ - id: logId, - event, - payload: JSON.stringify(req.body), - receivedAt: new Date().toISOString() + await prisma.notification.create({ + data: { + userId: 'ALL', // Log events to ALL as webhookLogs + type: event, + message: JSON.stringify(req.body), + read: false + } }); - saveDb(db); // Salva o log imediatamente para não perdê-lo se der return early } - // 2. Validação de Segurança - const configuredSecret = db.settings?.asaasWebhookSecret || process.env.ASAAS_WEBHOOK_TOKEN || 'devflix_webhook_secret_key'; - // O Asaas usa asaas-access-token por padrão + 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) { @@ -1296,14 +1284,10 @@ app.post('/api/webhooks/asaas', (req: Request, res: Response) => { return; } - // 3. Processamento do Pagamento const customerId = payment.customer; - const user = db.users.find(u => u.asaasCustomerId === customerId); + const user = await prisma.user.findFirst({ where: { asaasCustomerId: customerId } }); if (user) { - // Busca APENAS pelo ID exato da fatura - let userPaymentIdx = db.payments.findIndex(p => p.id === payment.id); - const isPaid = event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED'; const isOverdue = event === 'PAYMENT_OVERDUE'; const isRefunded = event === 'PAYMENT_REFUNDED' || event === 'PAYMENT_DELETED'; @@ -1313,53 +1297,68 @@ app.post('/api/webhooks/asaas', (req: Request, res: Response) => { isRefunded ? 'REFUNDED' : event === 'PAYMENT_RESTORED' ? 'PENDING' : null; - if (userPaymentIdx >= 0) { + let existingPayment = await prisma.subscriptionPayment.findUnique({ where: { id: payment.id } }); + + if (existingPayment) { if (newStatus) { - db.payments[userPaymentIdx].status = newStatus as any; - } - if (isPaid) { - db.payments[userPaymentIdx].paidAt = new Date().toISOString(); + await prisma.subscriptionPayment.update({ + where: { id: payment.id }, + data: { + status: newStatus as any, + ...(isPaid && { paidAt: new Date() }) + } + }); } } else if (!isRefunded) { - // Se não achou e não é estorno/deleção, registra a fatura enviada pelo Asaas - // Tenta achar o plano/curso da última compra do usuário para manter o histórico - const lastPayment = db.payments.filter(p => p.userId === user.id).sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0]; + const lastPayment = await prisma.subscriptionPayment.findFirst({ + where: { userId: user.id }, + orderBy: { createdAt: 'desc' } + }); - db.payments.push({ - id: payment.id, // Usa o ID exato - 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().toISOString().split('T')[0], - paidAt: isPaid ? new Date().toISOString() : null, - createdAt: new Date().toISOString(), - planId: lastPayment?.planId, - courseId: lastPayment?.courseId + await prisma.subscriptionPayment.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 + } }); } - // 4. Lógica de Liberação de Acesso / Curso const description = payment.description || ''; const isCoursePayment = description.startsWith('unlock_course_'); if (isPaid) { if (isCoursePayment) { const courseId = description.replace('unlock_course_', ''); - if (!user.unlockedCourses) user.unlockedCourses = []; - if (!user.unlockedCourses.includes(courseId)) { - user.unlockedCourses.push(courseId); + 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 { - user.subscriptionStatus = 'ACTIVE'; + 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) { - // Bloqueia acesso da assinatura - user.subscriptionStatus = isOverdue ? 'OVERDUE' : 'CANCELED'; + 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}`); @@ -1369,14 +1368,13 @@ app.post('/api/webhooks/asaas', (req: Request, res: Response) => { 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) => { +app.post('/api/admin/modules/:id/activity', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const moduleId = req.params.id; const { questions } = req.body; @@ -1385,39 +1383,37 @@ app.post('/api/admin/modules/:id/activity', authenticateToken, requireAdmin, (re return; } - const db = loadDb(); - let activity = db.activities.find(a => a.moduleId === moduleId); + const existingActivity = await prisma.moduleActivity.findUnique({ where: { moduleId } }); - if (activity) { - activity.questions = questions; + if (existingActivity) { + const updated = await prisma.moduleActivity.update({ + where: { moduleId }, + data: { questions: questions as any } + }); + res.json({ success: true, activity: updated }); } else { - activity = { - id: `act_${Math.random().toString(36).substring(2, 9)}`, - moduleId, - questions, - createdAt: new Date().toISOString() - }; - db.activities.push(activity); + const newActivity = await prisma.moduleActivity.create({ + data: { + moduleId, + questions: questions as any + } + }); + res.json({ success: true, activity: newActivity }); } - - saveDb(db); - res.json({ success: true, activity }); }); // Admin/Student: Get module activity -app.get('/api/modules/:id/activity', authenticateToken, (req: AuthenticatedRequest, res: Response) => { +app.get('/api/modules/:id/activity', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const moduleId = req.params.id; - const db = loadDb(); - const activity = db.activities.find(a => a.moduleId === moduleId); + const activity = await prisma.moduleActivity.findUnique({ where: { 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) => ({ + const safeQuestions = (activity.questions as any[]).map((q: any) => ({ question: q.question, options: q.options })); @@ -1428,310 +1424,281 @@ app.get('/api/modules/:id/activity', authenticateToken, (req: AuthenticatedReque }); // Admin: Change Course Certificate Mode -app.post('/api/admin/courses/:id/certificate-mode', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { +app.post('/api/admin/courses/:id/certificate-mode', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const courseId = req.params.id; - const { mode } = req.body; // 'PER_MODULE' or 'FULL_COURSE' + const { mode } = req.body; 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) { + 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' }); - 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) => { +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; // type: 'COURSE' or 'MODULE' + const { type, targetId, courseName, moduleName } = req.body; - 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); + 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, (req: AuthenticatedRequest, res: Response) => { +app.post('/api/modules/:id/submit-activity', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const moduleId = req.params.id; - const { answers } = req.body; // Array of selected indexes + const { answers } = req.body; const userId = req.user!.id; - const db = loadDb(); - const activity = db.activities.find(a => a.moduleId === moduleId); + const activity = await prisma.moduleActivity.findUnique({ where: { moduleId } }); if (!activity) { res.status(404).json({ error: 'Atividade não encontrada' }); return; } - // Calculate Grade let correctCount = 0; - const totalQuestions = activity.questions.length; + const questions = activity.questions as any[]; + const totalQuestions = questions.length; - activity.questions.forEach((q: any, index: number) => { + 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() - }); - } + const passed = score >= 6; - // --- 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)}`, + 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', - 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; - } - } } + }); + } + } 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 = 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)}`, + } + + 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, - moduleId: null, certificateType: 'COURSE', - unlockedAt: new Date().toISOString(), finalGrade, - courseName: course.title, - moduleName: null - }); - } + courseName: course.title + } + }); } } } } - 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); +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, (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.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, (req: AuthenticatedRequest, res: Response) => { - const db = loadDb(); - const notification = db.notifications.find(n => n.id === req.params.id); +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')) { - notification.read = true; - saveDb(db); + await prisma.notification.update({ + where: { id: notification.id }, + data: { read: true } + }); } 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.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, (req: AuthenticatedRequest, res: Response) => { - const db = loadDb(); - if (!db.settings) db.settings = {}; - db.settings.helpText = req.body.helpText; - saveDb(db); +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, (req: AuthenticatedRequest, res: Response) => { - const db = loadDb(); - const activity = db.activities.find(a => a.moduleId === req.params.moduleId); +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, (req: AuthenticatedRequest, res: Response) => { - const db = loadDb(); +app.put('/api/admin/modules/:moduleId/activity', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { 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); + 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, (req: AuthenticatedRequest, res: Response) => { - const db = loadDb(); - const activePlans = (db.plans || []).filter(p => p.active); +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, (req: AuthenticatedRequest, res: Response) => { - const db = loadDb(); - res.json(db.plans || []); +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, (req: AuthenticatedRequest, res: Response) => { - const db = loadDb(); +app.post('/api/admin/plans', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const { title, description, price, cycle, includedCourses, active } = req.body; - const newPlan = { - id: `plan_${Math.random().toString(36).substring(2, 9)}`, - title: title || 'Novo Plano', - description: description || '', - price: price || 0, - cycle: cycle || 'LIFETIME', - includedCourses: includedCourses || [], - active: active !== undefined ? active : true, - createdAt: new Date().toISOString() - }; - - if (!db.plans) db.plans = []; - db.plans.push(newPlan); - saveDb(db); + 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, (req: AuthenticatedRequest, res: Response) => { - const db = loadDb(); - const planIdx = db.plans?.findIndex(p => p.id === req.params.id); - - if (planIdx !== undefined && planIdx >= 0 && db.plans) { - db.plans[planIdx] = { ...db.plans[planIdx], ...req.body }; - saveDb(db); - res.json({ success: true, plan: db.plans[planIdx] }); - } else { +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, (req: AuthenticatedRequest, res: Response) => { - const db = loadDb(); - if (db.plans) { - db.plans = db.plans.filter(p => p.id !== req.params.id); - saveDb(db); +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' }); } - res.json({ success: true }); }); // Update single course price and lock status directly -app.put('/api/admin/courses/:id/price', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { - const db = loadDb(); - const courseIdx = db.courses.findIndex(c => c.id === req.params.id); - - if (courseIdx >= 0) { - db.courses[courseIdx].price = req.body.price; - db.courses[courseIdx].isLocked = req.body.isLocked; - saveDb(db); - res.json({ success: true, course: db.courses[courseIdx] }); - } else { +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' }); } }); diff --git a/src/components/AdminPricingManager.tsx b/src/components/AdminPricingManager.tsx index 962f218..6f66ec5 100644 --- a/src/components/AdminPricingManager.tsx +++ b/src/components/AdminPricingManager.tsx @@ -32,7 +32,7 @@ function CoursePricingCard({ course, saveLoading, onSave }: { course: Course, sa localLocked ? 'bg-amber-500/20 text-amber-400 border border-amber-500/30' : 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30' }`} > - {localLocked ? '💰 Pago (Bloqueado)' : '🔓 Gratuito (Livre)'} + {localLocked ? 💰 Pago (Bloqueado) : 🔓 Gratuito (Livre)} @@ -67,7 +67,7 @@ function CoursePricingCard({ course, saveLoading, onSave }: { course: Course, sa ) : ( )} - Salvar Alteração + Salvar Alteração ); @@ -82,7 +82,7 @@ export const AdminPricingManager: React.FC = ({ course const [showPlanModal, setShowPlanModal] = useState(false); const [planForm, setPlanForm] = useState>({ cycle: 'LIFETIME', active: true, includedCourses: [] }); - const token = localStorage.getItem('devflix_token'); + const token = localStorage.getItem('devflix_admin_token'); const fetchPlans = async () => { setLoading(true);