diff --git a/package.json b/package.json index 7b48c61..30ff9f9 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@google/genai": "^2.4.0", + "@prisma/client": "^6.4.1", "multer": "^1.4.5-lts.1", "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", @@ -35,6 +36,7 @@ "@types/node": "^22.14.0", "autoprefixer": "^10.4.21", "esbuild": "^0.25.0", + "prisma": "^6.4.1", "tailwindcss": "^4.1.14", "tsx": "^4.21.0", "typescript": "~5.8.2", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9d2acd0..d14aa3a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -53,6 +53,11 @@ model User { role Role @default(student) subscriptionStatus SubscriptionStatus @default(TRIAL) @map("subscription_status") asaasCustomerId String? @map("asaas_customer_id") + cpf String? + whatsapp String? + birthDate String? @map("birth_date") + avatarUrl String? @map("avatar_url") @db.Text + trialEndsAt DateTime? @map("trial_ends_at") createdAt DateTime @default(now()) @map("created_at") notes Note[] @@ -60,6 +65,7 @@ model User { payments Payment[] moduleGrades ModuleGrade[] certificates Certificate[] + unlockedCourses String[] @default([]) @map("unlocked_courses") @@map("users") } @@ -71,6 +77,8 @@ model Course { thumbnail String category String certificateMode CertificateMode @default(FULL_COURSE) @map("certificate_mode") + isLocked Boolean @default(false) @map("is_locked") + price Float? createdAt DateTime @default(now()) @map("created_at") modules Module[] @@ -147,6 +155,9 @@ model Payment { invoiceUrl String @map("invoice_url") @db.Text dueDate DateTime @map("due_date") @db.Date paidAt DateTime? @map("paid_at") + planId String? @map("plan_id") + courseId String? @map("course_id") + description String? @db.Text createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -205,3 +216,29 @@ model Certificate { @@map("certificates") } + +model Plan { + id String @id @default(uuid()) + title String + description String @db.Text + price Float + cycle String // 'MONTHLY', 'YEARLY', 'LIFETIME' + active Boolean @default(true) + includedCourses String[] @default([]) @map("included_courses") + createdAt DateTime @default(now()) @map("created_at") + + @@map("plans") +} + +model SystemSettings { + id String @id @default("default") + trialDurationDays Int @default(7) @map("trial_duration_days") + asaasEnvironment String @default("sandbox") @map("asaas_environment") + asaasApiKey String @default("") @map("asaas_api_key") + asaasWebhookUrl String @default("") @map("asaas_webhook_url") + asaasWebhookSecret String @default("") @map("asaas_webhook_secret") + helpText String @db.Text @default("# Central de Ajuda") @map("help_text") + + @@map("system_settings") +} + diff --git a/server-prisma.ts b/server-prisma.ts new file mode 100644 index 0000000..604f879 --- /dev/null +++ b/server-prisma.ts @@ -0,0 +1,1730 @@ +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, (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/src/db/migrateFromJsonToPostgres.ts b/src/db/migrateFromJsonToPostgres.ts new file mode 100644 index 0000000..909a096 --- /dev/null +++ b/src/db/migrateFromJsonToPostgres.ts @@ -0,0 +1,242 @@ +import fs from 'fs'; +import path from 'path'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); +const DATA_DIR = process.env.DATA_DIR || (fs.existsSync('/app/data') ? '/app/data' : process.cwd()); +const DB_FILE = path.join(DATA_DIR, 'database.json'); + +async function main() { + console.log('🔄 Iniciando migração de JSON para PostgreSQL...'); + + if (!fs.existsSync(DB_FILE)) { + console.error('❌ Arquivo database.json não encontrado!'); + return; + } + + const data = JSON.parse(fs.readFileSync(DB_FILE, 'utf-8')); + + // 1. Settings + console.log('Migrando Settings...'); + if (data.settings) { + await prisma.systemSettings.upsert({ + where: { id: 'default' }, + update: { + trialDurationDays: data.settings.trialDurationDays || 7, + asaasEnvironment: data.settings.asaasEnvironment || 'sandbox', + asaasApiKey: data.settings.asaasApiKey || '', + asaasWebhookUrl: data.settings.asaasWebhookUrl || '', + asaasWebhookSecret: data.settings.asaasWebhookSecret || '', + helpText: data.settings.helpText || '# Central de Ajuda' + }, + create: { + id: 'default', + trialDurationDays: data.settings.trialDurationDays || 7, + asaasEnvironment: data.settings.asaasEnvironment || 'sandbox', + asaasApiKey: data.settings.asaasApiKey || '', + asaasWebhookUrl: data.settings.asaasWebhookUrl || '', + asaasWebhookSecret: data.settings.asaasWebhookSecret || '', + helpText: data.settings.helpText || '# Central de Ajuda' + } + }); + } + + // 2. Users + console.log(`Migrando ${data.users?.length || 0} Users...`); + for (const user of data.users || []) { + await prisma.user.upsert({ + where: { id: user.id }, + update: { + name: user.name, + email: user.email, + password: user.password, + role: user.role === 'admin' ? 'admin' : 'student', + subscriptionStatus: user.subscriptionStatus, + asaasCustomerId: user.asaasCustomerId, + cpf: user.cpf, + whatsapp: user.whatsapp, + birthDate: user.birthDate, + avatarUrl: user.avatarUrl, + trialEndsAt: user.trialEndsAt ? new Date(user.trialEndsAt) : null, + unlockedCourses: user.unlockedCourses || [], + createdAt: new Date(user.createdAt) + }, + create: { + id: user.id, + name: user.name, + email: user.email, + password: user.password, + role: user.role === 'admin' ? 'admin' : 'student', + subscriptionStatus: user.subscriptionStatus, + asaasCustomerId: user.asaasCustomerId, + cpf: user.cpf, + whatsapp: user.whatsapp, + birthDate: user.birthDate, + avatarUrl: user.avatarUrl, + trialEndsAt: user.trialEndsAt ? new Date(user.trialEndsAt) : null, + unlockedCourses: user.unlockedCourses || [], + createdAt: new Date(user.createdAt) + } + }); + } + + // 3. Courses + console.log(`Migrando ${data.courses?.length || 0} Courses...`); + for (const course of data.courses || []) { + await prisma.course.upsert({ + where: { id: course.id }, + update: { + title: course.title, + description: course.description, + thumbnail: course.thumbnail, + category: course.category, + certificateMode: course.certificateMode || 'FULL_COURSE', + isLocked: course.isLocked || false, + price: course.price || null, + createdAt: new Date(course.createdAt) + }, + create: { + id: course.id, + title: course.title, + description: course.description, + thumbnail: course.thumbnail, + category: course.category, + certificateMode: course.certificateMode || 'FULL_COURSE', + isLocked: course.isLocked || false, + price: course.price || null, + createdAt: new Date(course.createdAt) + } + }); + } + + // 4. Modules + console.log(`Migrando ${data.modules?.length || 0} Modules...`); + for (const mod of data.modules || []) { + await prisma.module.upsert({ + where: { id: mod.id }, + update: { + courseId: mod.courseId, + title: mod.title, + order: mod.order, + createdAt: new Date(mod.createdAt) + }, + create: { + id: mod.id, + courseId: mod.courseId, + title: mod.title, + order: mod.order, + createdAt: new Date(mod.createdAt) + } + }); + } + + // 5. Lessons + console.log(`Migrando ${data.lessons?.length || 0} Lessons...`); + for (const lesson of data.lessons || []) { + await prisma.lesson.upsert({ + where: { id: lesson.id }, + update: { + moduleId: lesson.moduleId, + title: lesson.title, + videoUrl: lesson.videoUrl, + videoType: lesson.videoType === 'youtube' ? 'youtube' : 'direct', + order: lesson.order, + content: lesson.content, + createdAt: new Date(lesson.createdAt) + }, + create: { + id: lesson.id, + moduleId: lesson.moduleId, + title: lesson.title, + videoUrl: lesson.videoUrl, + videoType: lesson.videoType === 'youtube' ? 'youtube' : 'direct', + order: lesson.order, + content: lesson.content, + createdAt: new Date(lesson.createdAt) + } + }); + } + + // 6. Plans + console.log(`Migrando ${data.plans?.length || 0} Plans...`); + for (const plan of data.plans || []) { + await prisma.plan.upsert({ + where: { id: plan.id }, + update: { + title: plan.title, + description: plan.description || '', + price: plan.price, + cycle: plan.cycle, + active: plan.active, + includedCourses: plan.includedCourses || [], + createdAt: plan.createdAt ? new Date(plan.createdAt) : new Date() + }, + create: { + id: plan.id, + title: plan.title, + description: plan.description || '', + price: plan.price, + cycle: plan.cycle, + active: plan.active, + includedCourses: plan.includedCourses || [], + createdAt: plan.createdAt ? new Date(plan.createdAt) : new Date() + } + }); + } + + // 7. Payments + console.log(`Migrando ${data.payments?.length || 0} Payments...`); + for (const p of data.payments || []) { + let billingType = 'PIX'; + if (p.billingType === 'BOLETO') billingType = 'BOLETO'; + if (p.billingType === 'CREDIT_CARD') billingType = 'CREDIT_CARD'; + + let status = 'PENDING'; + if (p.status === 'RECEIVED') status = 'RECEIVED'; + if (p.status === 'CONFIRMED') status = 'CONFIRMED'; + if (p.status === 'OVERDUE') status = 'OVERDUE'; + if (p.status === 'REFUNDED') status = 'REFUNDED'; + + await prisma.payment.upsert({ + where: { id: p.id }, + update: { + userId: p.userId, + value: p.value, + status: status as any, + billingType: billingType as any, + invoiceUrl: p.invoiceUrl || '', + dueDate: p.dueDate ? new Date(p.dueDate) : new Date(), + paidAt: p.paidAt ? new Date(p.paidAt) : null, + planId: p.planId || null, + courseId: p.courseId || null, + description: p.description || null, + createdAt: p.createdAt ? new Date(p.createdAt) : new Date() + }, + create: { + id: p.id, + userId: p.userId, + value: p.value, + status: status as any, + billingType: billingType as any, + invoiceUrl: p.invoiceUrl || '', + dueDate: p.dueDate ? new Date(p.dueDate) : new Date(), + paidAt: p.paidAt ? new Date(p.paidAt) : null, + planId: p.planId || null, + courseId: p.courseId || null, + description: p.description || null, + createdAt: p.createdAt ? new Date(p.createdAt) : new Date() + } + }); + } + + console.log('✅ Migração finalizada com sucesso!'); +} + +main() + .catch(e => { + console.error('❌ Erro na migração:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/db/prisma.ts b/src/db/prisma.ts new file mode 100644 index 0000000..9b6c4ce --- /dev/null +++ b/src/db/prisma.ts @@ -0,0 +1,3 @@ +import { PrismaClient } from '@prisma/client'; + +export const prisma = new PrismaClient();