microtecflix/server.ts

1296 lines
40 KiB
TypeScript

import express, { Request, Response, NextFunction } from 'express';
import path from 'path';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import { createServer as createViteServer } from 'vite';
import { loadDb, saveDb } from './src/db/db';
import { AsaasService } from './src/services/asaas';
import { User, Course, Module, Lesson, Note, LessonProgress, WebhookLog, SubscriptionPayment, SubscriptionStatus } from './src/types';
// JWT Secret Key
const JWT_SECRET = process.env.JWT_SECRET || 'devflix_secret_token_key_13579';
const app = express();
const PORT = 3000;
app.use(express.json());
// --- JWT AUTH MIDDLEWARE ---
interface AuthenticatedRequest extends Request {
user?: {
id: string;
email: string;
role: 'admin' | 'student';
subscriptionStatus: SubscriptionStatus;
};
}
function authenticateToken(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
res.status(401).json({ error: 'Token de autenticação não fornecido' });
return;
}
jwt.verify(token, JWT_SECRET, (err: any, decoded: any) => {
if (err) {
res.status(403).json({ error: 'Token inválido ou expirado' });
return;
}
req.user = decoded;
next();
});
}
function requireAdmin(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
if (!req.user || req.user.role !== 'admin') {
res.status(403).json({ error: 'Acesso restrito para administradores' });
return;
}
next();
}
// --- API ROUTES ---
// 1. Auth: Register
app.post('/api/auth/register', async (req: Request, res: Response) => {
const { name, email, password, birthDate, whatsapp } = req.body;
if (!name || !email || !password || !birthDate || !whatsapp) {
res.status(400).json({ error: 'Preencha todos os campos obrigatórios' });
return;
}
const db = loadDb();
const userExists = db.users.find(u => u.email.toLowerCase() === email.toLowerCase());
if (userExists) {
res.status(400).json({ error: 'E-mail já cadastrado na plataforma' });
return;
}
try {
// Generate Asaas Customer ID (Real call if configured, or simulated)
const asaasCustomerId = await AsaasService.createCustomer(name, email);
const salt = bcrypt.genSaltSync(10);
const passwordHash = bcrypt.hashSync(password, salt);
const createdAt = new Date();
// Calculate trial ends at
const trialDays = db.settings?.trialDurationDays || 7;
const trialEndsAt = new Date(createdAt);
trialEndsAt.setDate(trialEndsAt.getDate() + trialDays);
const newUser: User = {
id: `usr_${Math.random().toString(36).substring(2, 9)}`,
name,
email: email.toLowerCase(),
password: passwordHash,
role: 'student',
subscriptionStatus: 'TRIAL', // Defaults to trial
asaasCustomerId,
birthDate,
whatsapp,
trialEndsAt: trialEndsAt.toISOString(),
createdAt: createdAt.toISOString()
};
db.users.push(newUser);
saveDb(db);
// Generate JWT
const token = jwt.sign(
{ id: newUser.id, email: newUser.email, role: newUser.role, subscriptionStatus: newUser.subscriptionStatus },
JWT_SECRET,
{ expiresIn: '7d' }
);
res.status(201).json({
token,
user: {
id: newUser.id,
name: newUser.name,
email: newUser.email,
role: newUser.role,
subscriptionStatus: newUser.subscriptionStatus
}
});
} catch (err: any) {
res.status(500).json({ error: err.message || 'Erro ao registrar usuário' });
}
});
// 2. Auth: Login
app.post('/api/auth/login', (req: Request, res: Response) => {
const { email, password } = req.body;
if (!email || !password) {
res.status(400).json({ error: 'Informe e-mail e senha' });
return;
}
const db = loadDb();
const user = db.users.find(u => u.email.toLowerCase() === email.toLowerCase());
if (!user || !user.password) {
res.status(401).json({ error: 'Credenciais inválidas' });
return;
}
const passwordValid = bcrypt.compareSync(password, user.password);
if (!passwordValid) {
res.status(401).json({ error: 'Credenciais inválidas' });
return;
}
const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role, subscriptionStatus: user.subscriptionStatus },
JWT_SECRET,
{ expiresIn: '7d' }
);
res.json({
token,
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
subscriptionStatus: user.subscriptionStatus,
unlockedCourses: user.unlockedCourses || []
}
});
});
// 3. Auth: Me
app.get('/api/auth/me', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
if (!req.user) {
res.status(401).json({ error: 'Não autorizado' });
return;
}
const db = loadDb();
const user = db.users.find(u => u.id === req.user?.id);
if (!user) {
res.status(404).json({ error: 'Usuário não encontrado' });
return;
}
res.json({
id: user.id,
name: user.name,
email: user.email,
role: user.role,
subscriptionStatus: user.subscriptionStatus,
unlockedCourses: user.unlockedCourses || []
});
});
// 4. Courses List (With Progress)
app.get('/api/courses', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
const db = loadDb();
const userId = req.user!.id;
const user = db.users.find(u => u.id === userId);
const unlockedCourses = user?.unlockedCourses || [];
const isAdmin = user?.role === 'admin';
// Enhance courses with overall user progress
const coursesWithProgress = db.courses.map(course => {
// Find all lessons in this course
const courseModules = db.modules.filter(m => m.courseId === course.id);
const courseModuleIds = courseModules.map(m => m.id);
const courseLessons = db.lessons.filter(l => courseModuleIds.includes(l.moduleId));
const totalLessons = courseLessons.length;
let completedLessons = 0;
if (totalLessons > 0) {
const userProgress = db.progress.filter(p => p.userId === userId && p.completed);
const userProgressLessonIds = userProgress.map(p => p.lessonId);
completedLessons = courseLessons.filter(l => userProgressLessonIds.includes(l.id)).length;
}
const progressPercentage = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0;
return {
...course,
totalLessons,
completedLessons,
progress: progressPercentage,
isUnlocked: !course.isLocked || unlockedCourses.includes(course.id) || isAdmin
};
});
res.json(coursesWithProgress);
});
// 5. Course Details (with Modules, Lessons, Progress & Notes)
app.get('/api/courses/:id', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
const db = loadDb();
const userId = req.user!.id;
const courseId = req.params.id;
const course = db.courses.find(c => c.id === courseId);
if (!course) {
res.status(404).json({ error: 'Curso não encontrado' });
return;
}
const user = db.users.find(u => u.id === userId);
const unlockedCourses = user?.unlockedCourses || [];
const isAdmin = user?.role === 'admin';
const isUnlocked = !course.isLocked || unlockedCourses.includes(course.id) || isAdmin;
// Get modules
const courseModules = db.modules
.filter(m => m.courseId === courseId)
.sort((a, b) => a.order - b.order);
// Get lessons and enhance with progress
const modulesWithLessons = courseModules.map(mod => {
const lessons = db.lessons
.filter(l => l.moduleId === mod.id)
.sort((a, b) => a.order - b.order)
.map(lesson => {
const prog = db.progress.find(p => p.userId === userId && p.lessonId === lesson.id);
const note = db.notes.find(n => n.userId === userId && n.lessonId === lesson.id);
return {
...lesson,
progress: prog ? {
watchedPercentage: prog.watchedPercentage,
completed: prog.completed
} : { watchedPercentage: 0, completed: false },
note: note ? note.content : ''
};
});
return {
...mod,
lessons
};
});
res.json({
course: {
...course,
isUnlocked
},
modules: modulesWithLessons
});
});
// 5.5 Unlock Course
app.post('/api/courses/:id/unlock', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
const courseId = req.params.id;
const userId = req.user!.id;
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;
}
if (!user.unlockedCourses) {
user.unlockedCourses = [];
}
if (!user.unlockedCourses.includes(courseId)) {
user.unlockedCourses.push(courseId);
}
// Record an Asaas invoice/payment for this curso unlock
const newPayment: SubscriptionPayment = {
id: `pay_crs_${Math.random().toString(36).substring(2, 9)}`,
userId,
value: course.price || 49.90,
status: 'CONFIRMED',
billingType: 'PIX',
invoiceUrl: `https://www.asaas.com/i/unlock_${courseId}`,
dueDate: new Date().toISOString().split('T')[0],
paidAt: new Date().toISOString(),
createdAt: new Date().toISOString()
};
db.payments.push(newPayment);
saveDb(db);
res.json({ success: true, unlockedCourses: user.unlockedCourses });
});
// 6. Lesson Progress tracking
app.post('/api/lessons/:id/progress', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
const { watchedPercentage } = req.body;
const lessonId = req.params.id;
const userId = req.user!.id;
if (watchedPercentage === undefined) {
res.status(400).json({ error: 'watchedPercentage é obrigatório' });
return;
}
const db = loadDb();
const lesson = db.lessons.find(l => l.id === lessonId);
if (!lesson) {
res.status(404).json({ error: 'Aula não encontrada' });
return;
}
let progIdx = db.progress.findIndex(p => p.userId === userId && p.lessonId === lessonId);
const isCompleted = watchedPercentage >= 90;
if (progIdx >= 0) {
db.progress[progIdx].watchedPercentage = Math.max(db.progress[progIdx].watchedPercentage, watchedPercentage);
if (isCompleted) {
db.progress[progIdx].completed = true;
}
db.progress[progIdx].updatedAt = new Date().toISOString();
} else {
db.progress.push({
id: `prog_${Math.random().toString(36).substring(2, 9)}`,
userId,
lessonId,
watchedPercentage,
completed: isCompleted,
updatedAt: new Date().toISOString()
});
}
saveDb(db);
res.json({ success: true, completed: isCompleted });
});
// 7. Lesson Notes and Doubts
app.get('/api/lessons/:id/notes', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
const lessonId = req.params.id;
const userId = req.user!.id;
const db = loadDb();
const note = db.notes.find(n => n.userId === userId && n.lessonId === lessonId);
res.json({ content: note ? note.content : '' });
});
app.post('/api/lessons/:id/notes', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
const { content } = req.body;
const lessonId = req.params.id;
const userId = req.user!.id;
const db = loadDb();
let noteIdx = db.notes.findIndex(n => n.userId === userId && n.lessonId === lessonId);
if (noteIdx >= 0) {
db.notes[noteIdx].content = content;
db.notes[noteIdx].createdAt = new Date().toISOString();
} else {
db.notes.push({
id: `note_${Math.random().toString(36).substring(2, 9)}`,
userId,
lessonId,
content,
createdAt: new Date().toISOString()
});
}
saveDb(db);
res.json({ success: true });
});
// 8. Subscription status & Invoice history
app.get('/api/subscription/status', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
const db = loadDb();
const userId = req.user!.id;
const user = db.users.find(u => u.id === userId);
if (!user) {
res.status(404).json({ error: 'Usuário não encontrado' });
return;
}
const payments = db.payments
.filter(p => p.userId === userId)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
res.json({
status: user.subscriptionStatus,
asaasCustomerId: user.asaasCustomerId,
payments
});
});
// 9. Subscribe to plan
app.post('/api/subscription/subscribe', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const { billingType, creditCardInfo } = req.body;
const userId = req.user!.id;
if (!billingType) {
res.status(400).json({ error: 'Tipo de cobrança é obrigatório (PIX, CREDIT_CARD ou BOLETO)' });
return;
}
const db = loadDb();
const user = db.users.find(u => u.id === userId);
if (!user) {
res.status(404).json({ error: 'Usuário não encontrado' });
return;
}
try {
let customerId = user.asaasCustomerId;
if (!customerId) {
customerId = await AsaasService.createCustomer(user.name, user.email);
user.asaasCustomerId = customerId;
}
// Assinatura de R$ 49.90/mês
const { subscriptionId, payment } = await AsaasService.createSubscription(
customerId,
49.90,
billingType,
creditCardInfo
);
// Add subscription payment to database
const newPayment: SubscriptionPayment = {
id: payment.id || `pay_${Math.random().toString(36).substring(2, 9)}`,
userId,
value: 49.90,
status: billingType === 'CREDIT_CARD' ? 'CONFIRMED' : 'PENDING',
billingType,
invoiceUrl: payment.invoiceUrl || `https://sandbox.asaas.com/i/${subscriptionId}`,
dueDate: payment.dueDate || new Date(Date.now() + 3 * 24 * 3600 * 1000).toISOString().split('T')[0],
paidAt: billingType === 'CREDIT_CARD' ? new Date().toISOString() : null,
createdAt: new Date().toISOString()
};
db.payments.push(newPayment);
// Update user subscription status
if (billingType === 'CREDIT_CARD') {
user.subscriptionStatus = 'ACTIVE';
} else {
user.subscriptionStatus = 'TRIAL'; // Give them trial until Pix/Boleto clears, or keep Trial
}
saveDb(db);
res.json({
success: true,
subscriptionId,
payment: newPayment
});
} catch (err: any) {
res.status(500).json({ error: err.message || 'Erro ao processar assinatura' });
}
});
// --- ADMIN API ENDPOINTS ---
// 1. Dashboard statistics
app.get('/api/admin/dashboard', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const db = loadDb();
const totalActive = db.users.filter(u => u.role === 'student' && u.subscriptionStatus === 'ACTIVE').length;
const totalOverdue = db.users.filter(u => u.role === 'student' && u.subscriptionStatus === 'OVERDUE').length;
const totalCanceled = db.users.filter(u => u.role === 'student' && u.subscriptionStatus === 'CANCELED').length;
const totalTrial = db.users.filter(u => u.role === 'student' && u.subscriptionStatus === 'TRIAL').length;
// MRR = Active Subscribers * 49.90
const mrr = totalActive * 49.90;
// Let's format recent invoice history for logs/charts
const recentPayments = db.payments
.map(p => {
const student = db.users.find(u => u.id === p.userId);
return {
...p,
studentName: student ? student.name : 'Aluno Removido',
studentEmail: student ? student.email : ''
};
})
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 15);
const webhookLogs = db.webhookLogs
.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime())
.slice(0, 15);
res.json({
stats: {
mrr,
activeSubscribers: totalActive,
trialSubscribers: totalTrial,
overdueSubscribers: totalOverdue,
canceledSubscribers: totalCanceled
},
recentPayments,
webhookLogs
});
});
// 2. Students & status management
app.get('/api/admin/students', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const db = loadDb();
const students = db.users
.filter(u => u.role === 'student')
.map(u => {
const studentPayments = db.payments.filter(p => p.userId === u.id);
const studentGrades = db.moduleGrades.filter(g => g.userId === u.id);
const studentCertificates = db.certificates.filter(c => c.userId === u.id);
return {
id: u.id,
name: u.name,
email: u.email,
subscriptionStatus: u.subscriptionStatus,
asaasCustomerId: u.asaasCustomerId,
createdAt: u.createdAt,
birthDate: u.birthDate,
whatsapp: u.whatsapp,
trialEndsAt: u.trialEndsAt,
totalPaid: studentPayments
.filter(p => p.status === 'CONFIRMED' || p.status === 'RECEIVED')
.reduce((acc, curr) => acc + curr.value, 0),
unlockedCourses: u.unlockedCourses || [],
moduleGrades: studentGrades,
certificates: studentCertificates,
payments: studentPayments
};
});
res.json(students);
});
// Admin change student status manually
app.post('/api/admin/students/:id/status', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const { status } = req.body;
const studentId = req.params.id;
const allowedStatuses: SubscriptionStatus[] = ['ACTIVE', 'OVERDUE', 'CANCELED', 'TRIAL'];
if (!status || !allowedStatuses.includes(status)) {
res.status(400).json({ error: 'Status de assinatura inválido' });
return;
}
const db = loadDb();
const student = db.users.find(u => u.id === studentId && u.role === 'student');
if (!student) {
res.status(404).json({ error: 'Estudante não encontrado' });
return;
}
student.subscriptionStatus = status;
saveDb(db);
res.json({ success: true, status: student.subscriptionStatus });
});
// Admin System Settings
app.get('/api/admin/settings', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const db = loadDb();
res.json(db.settings || {
trialDurationDays: 7,
asaasEnvironment: 'sandbox',
asaasApiKey: '',
asaasWebhookUrl: '',
asaasWebhookSecret: ''
});
});
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 });
});
// 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 ---
// Standard webhook validation token, can be verified in Production
const ASAAS_WEBHOOK_TOKEN = process.env.ASAAS_WEBHOOK_TOKEN || 'devflix_webhook_secret_key';
app.post('/api/webhooks/asaas', (req: Request, res: Response) => {
// Optional token verification
const token = req.headers['asaas-token'] || req.headers['authorization'];
if (process.env.ASAAS_WEBHOOK_TOKEN && token !== ASAAS_WEBHOOK_TOKEN) {
res.status(401).json({ error: 'Não autorizado - Token de Webhook incorreto' });
return;
}
const { event, payment } = req.body;
if (!event || !payment) {
res.status(400).json({ error: 'Formato do Webhook inválido' });
return;
}
const db = loadDb();
// Log the webhook event
const logId = `wh_log_${Math.random().toString(36).substring(2, 9)}`;
db.webhookLogs.push({
id: logId,
event,
payload: JSON.stringify(req.body),
receivedAt: new Date().toISOString()
});
// Find user by Asaas Customer ID
const customerId = payment.customer;
const user = db.users.find(u => u.asaasCustomerId === customerId);
if (user) {
// Process payment updates
let userPaymentIdx = db.payments.findIndex(p => p.id === payment.id || (p.userId === user.id && p.status === 'PENDING'));
// If we have a matching or pending payment, let's update or insert
if (userPaymentIdx >= 0) {
db.payments[userPaymentIdx].status =
event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED' ? 'CONFIRMED' :
event === 'PAYMENT_OVERDUE' ? 'OVERDUE' : db.payments[userPaymentIdx].status;
if (event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED') {
db.payments[userPaymentIdx].paidAt = new Date().toISOString();
}
} else {
// Register a new payment log
db.payments.push({
id: payment.id || `pay_wh_${Math.random().toString(36).substring(2, 9)}`,
userId: user.id,
value: payment.value || 49.90,
status: (event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED') ? 'CONFIRMED' :
event === 'PAYMENT_OVERDUE' ? 'OVERDUE' : 'PENDING',
billingType: payment.billingType || 'PIX',
invoiceUrl: payment.invoiceUrl || `https://sandbox.asaas.com/i/${payment.id}`,
dueDate: payment.dueDate || new Date().toISOString().split('T')[0],
paidAt: (event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED') ? new Date().toISOString() : null,
createdAt: new Date().toISOString()
});
}
// Handle subscription access control
if (event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED') {
// Liberar / manter acesso
user.subscriptionStatus = 'ACTIVE';
console.log(`Webhook: Acesso LIBERADO para usuário ${user.email} (${user.name})`);
} else if (event === 'PAYMENT_OVERDUE' || event === 'SUBSCRIPTION_DELETED') {
// Bloquear acesso automaticamente
user.subscriptionStatus = event === 'PAYMENT_OVERDUE' ? 'OVERDUE' : 'CANCELED';
console.log(`Webhook: Acesso BLOQUEADO para usuário ${user.email} (${user.name}) - Motivo: ${event}`);
}
} else {
console.warn(`Webhook: Cliente Asaas ${customerId} não encontrado no banco local.`);
}
saveDb(db);
res.status(200).json({ received: true });
});
// --- ACTIVITIES & CERTIFICATES API ENDPOINTS ---
// Admin: Save or update module activity
app.post('/api/admin/modules/:id/activity', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const moduleId = req.params.id;
const { questions } = req.body;
if (!questions || !Array.isArray(questions)) {
res.status(400).json({ error: 'Lista de perguntas inválida' });
return;
}
const db = loadDb();
let activity = db.activities.find(a => a.moduleId === moduleId);
if (activity) {
activity.questions = questions;
} else {
activity = {
id: `act_${Math.random().toString(36).substring(2, 9)}`,
moduleId,
questions,
createdAt: new Date().toISOString()
};
db.activities.push(activity);
}
saveDb(db);
res.json({ success: true, activity });
});
// Admin/Student: Get module activity
app.get('/api/modules/:id/activity', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
const moduleId = req.params.id;
const db = loadDb();
const activity = db.activities.find(a => a.moduleId === moduleId);
if (!activity) {
res.status(404).json({ error: 'Atividade não encontrada' });
return;
}
// If student, remove correctIndex so they can't cheat easily by inspecting the network request
if (req.user?.role === 'student') {
const safeQuestions = activity.questions.map((q: any) => ({
question: q.question,
options: q.options
}));
res.json({ id: activity.id, moduleId: activity.moduleId, questions: safeQuestions });
} else {
res.json(activity);
}
});
// Admin: Change Course Certificate Mode
app.post('/api/admin/courses/:id/certificate-mode', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const courseId = req.params.id;
const { mode } = req.body; // 'PER_MODULE' or 'FULL_COURSE'
if (mode !== 'PER_MODULE' && mode !== 'FULL_COURSE') {
res.status(400).json({ error: 'Modo inválido' });
return;
}
const db = loadDb();
const course = db.courses.find(c => c.id === courseId);
if (!course) {
res.status(404).json({ error: 'Curso não encontrado' });
return;
}
course.certificateMode = mode;
saveDb(db);
res.json({ success: true, course });
});
// Admin: Manually Unlock Certificate
app.post('/api/admin/students/:userId/unlock-certificate', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const { userId } = req.params;
const { type, targetId, courseName, moduleName } = req.body; // type: 'COURSE' or 'MODULE'
const db = loadDb();
const certId = `cert_${Math.random().toString(36).substring(2, 9)}`;
const certificate = {
id: certId,
userId,
courseId: type === 'COURSE' ? targetId : null,
moduleId: type === 'MODULE' ? targetId : null,
certificateType: type,
unlockedAt: new Date().toISOString(),
finalGrade: 10, // Default manual grade
courseName,
moduleName: moduleName || null
};
db.certificates.push(certificate);
saveDb(db);
res.json({ success: true, certificate });
});
// Student: Submit Activity Answers
app.post('/api/modules/:id/submit-activity', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
const moduleId = req.params.id;
const { answers } = req.body; // Array of selected indexes
const userId = req.user!.id;
const db = loadDb();
const activity = db.activities.find(a => a.moduleId === moduleId);
if (!activity) {
res.status(404).json({ error: 'Atividade não encontrada' });
return;
}
// Calculate Grade
let correctCount = 0;
const totalQuestions = activity.questions.length;
activity.questions.forEach((q: any, index: number) => {
if (answers[index] === q.correctIndex) {
correctCount++;
}
});
const score = totalQuestions > 0 ? (correctCount / totalQuestions) * 10 : 0;
const passed = score >= 6; // Configurable passing score
let gradeIdx = db.moduleGrades.findIndex(g => g.userId === userId && g.activityId === activity.id);
if (gradeIdx >= 0) {
db.moduleGrades[gradeIdx].score = score;
db.moduleGrades[gradeIdx].passed = passed;
} else {
db.moduleGrades.push({
id: `grd_${Math.random().toString(36).substring(2, 9)}`,
userId,
activityId: activity.id,
score,
passed,
createdAt: new Date().toISOString()
});
}
// --- Automatic Certificate Generation Logic ---
const moduleInfo = db.modules.find(m => m.id === moduleId);
if (passed && moduleInfo) {
const course = db.courses.find(c => c.id === moduleInfo.courseId);
if (course) {
const mode = course.certificateMode || 'FULL_COURSE';
if (mode === 'PER_MODULE') {
// Check if certificate already exists
const exists = db.certificates.find(c => c.userId === userId && c.moduleId === moduleId && c.certificateType === 'MODULE');
if (!exists) {
db.certificates.push({
id: `cert_${Math.random().toString(36).substring(2, 9)}`,
userId,
courseId: course.id,
moduleId: moduleInfo.id,
certificateType: 'MODULE',
unlockedAt: new Date().toISOString(),
finalGrade: score,
courseName: course.title,
moduleName: moduleInfo.title
});
}
} else {
// FULL_COURSE mode
// Check if student has passed all modules of this course
const allCourseModules = db.modules.filter(m => m.courseId === course.id);
let allPassed = true;
let totalScore = 0;
let evaluatedModules = 0;
for (const m of allCourseModules) {
const act = db.activities.find(a => a.moduleId === m.id);
if (act) {
const grade = db.moduleGrades.find(g => g.activityId === act.id && g.userId === userId);
if (!grade || !grade.passed) {
allPassed = false;
break;
}
totalScore += grade.score;
evaluatedModules++;
} else {
// If a module has no activity, we might consider it "passed" automatically, but let's just ignore it for grades
// Or require manual progress? Let's assume if it has no activity, we just skip grade check for it
// Actually, wait, let's require 100% watched percentage for modules without activities.
const lessons = db.lessons.filter(l => l.moduleId === m.id);
for(const lsn of lessons) {
const prog = db.progress.find(p => p.lessonId === lsn.id && p.userId === userId);
if (!prog || !prog.completed) {
allPassed = false;
break;
}
}
}
}
if (allPassed) {
const exists = db.certificates.find(c => c.userId === userId && c.courseId === course.id && c.certificateType === 'COURSE');
if (!exists) {
const finalGrade = evaluatedModules > 0 ? (totalScore / evaluatedModules) : null;
db.certificates.push({
id: `cert_${Math.random().toString(36).substring(2, 9)}`,
userId,
courseId: course.id,
moduleId: null,
certificateType: 'COURSE',
unlockedAt: new Date().toISOString(),
finalGrade,
courseName: course.title,
moduleName: null
});
}
}
}
}
}
saveDb(db);
res.json({ success: true, score, passed });
});
// Student: Get my certificates
app.get('/api/certificates', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
const db = loadDb();
const myCerts = db.certificates.filter(c => c.userId === req.user!.id);
res.json(myCerts);
});
// --- NOTIFICATIONS ---
app.get('/api/notifications', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
const db = loadDb();
const myNotifications = db.notifications.filter(n => n.userId === req.user!.id || n.userId === 'ALL');
res.json(myNotifications.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
});
app.put('/api/notifications/:id/read', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
const db = loadDb();
const notification = db.notifications.find(n => n.id === req.params.id);
if (notification && (notification.userId === req.user!.id || notification.userId === 'ALL')) {
notification.read = true;
saveDb(db);
}
res.json({ success: true });
});
// --- HELP DESK ---
app.get('/api/help', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
const db = loadDb();
res.json({ helpText: db.settings?.helpText || '' });
});
app.put('/api/admin/help', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const db = loadDb();
if (!db.settings) db.settings = {};
db.settings.helpText = req.body.helpText;
saveDb(db);
res.json({ success: true });
});
// --- MODULE ACTIVITIES (ADMIN) ---
app.get('/api/admin/modules/:moduleId/activity', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const db = loadDb();
const activity = db.activities.find(a => a.moduleId === req.params.moduleId);
res.json(activity || { moduleId: req.params.moduleId, questions: [] });
});
app.put('/api/admin/modules/:moduleId/activity', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const db = loadDb();
const { moduleId } = req.params;
const { questions } = req.body;
let activity = db.activities.find(a => a.moduleId === moduleId);
if (!activity) {
activity = {
id: `act_${Math.random().toString(36).substring(2, 9)}`,
moduleId,
questions: [],
createdAt: new Date().toISOString()
};
db.activities.push(activity);
}
activity.questions = questions;
saveDb(db);
res.json(activity);
});
// --- VITE MIDDLEWARE / SPA FALLBACK ---
async function startServer() {
if (process.env.NODE_ENV !== 'production') {
const vite = await createViteServer({
server: { middlewareMode: true },
appType: 'spa',
});
app.use(vite.middlewares);
} else {
const distPath = path.join(process.cwd(), 'dist');
app.use(express.static(distPath));
app.get('*', (req: Request, res: Response) => {
res.sendFile(path.join(distPath, 'index.html'));
});
}
app.listen(PORT, '0.0.0.0', () => {
console.log(`DevFlix Server running on port ${PORT}`);
});
}
startServer();