1336 lines
41 KiB
TypeScript
1336 lines
41 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, async (req: AuthenticatedRequest, res: Response) => {
|
|
const courseId = req.params.id;
|
|
const userId = req.user!.id;
|
|
const { billingType, creditCardInfo } = 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;
|
|
if (!customerId) {
|
|
customerId = await AsaasService.createCustomer(user.name, user.email);
|
|
user.asaasCustomerId = customerId;
|
|
}
|
|
|
|
const asaasPayment = await AsaasService.createPayment(
|
|
customerId,
|
|
course.price || 49.90,
|
|
billingType || 'PIX',
|
|
`unlock_course_${courseId}`,
|
|
creditCardInfo
|
|
);
|
|
|
|
let pixData = null;
|
|
if ((billingType || 'PIX') === 'PIX') {
|
|
pixData = await AsaasService.getPixQrCode(asaasPayment.id);
|
|
}
|
|
|
|
const newPayment: SubscriptionPayment = {
|
|
id: asaasPayment.id,
|
|
userId,
|
|
value: course.price || 49.90,
|
|
status: 'PENDING',
|
|
billingType: billingType || 'PIX',
|
|
invoiceUrl: asaasPayment.invoiceUrl,
|
|
dueDate: asaasPayment.dueDate || new Date().toISOString().split('T')[0],
|
|
paidAt: null,
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
db.payments.push(newPayment);
|
|
saveDb(db);
|
|
|
|
res.json({ success: true, payment: newPayment, pixData });
|
|
} catch (err: any) {
|
|
console.error('Course unlock error:', err);
|
|
res.status(500).json({ error: err.message || 'Erro ao processar pagamento do curso' });
|
|
}
|
|
});
|
|
|
|
// 6. Lesson Progress tracking
|
|
app.post('/api/lessons/:id/progress', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
|
|
const { watchedPercentage } = req.body;
|
|
const lessonId = req.params.id;
|
|
const userId = req.user!.id;
|
|
|
|
if (watchedPercentage === undefined) {
|
|
res.status(400).json({ error: 'watchedPercentage é obrigatório' });
|
|
return;
|
|
}
|
|
|
|
const db = loadDb();
|
|
const lesson = db.lessons.find(l => l.id === lessonId);
|
|
if (!lesson) {
|
|
res.status(404).json({ error: 'Aula não encontrada' });
|
|
return;
|
|
}
|
|
|
|
let progIdx = db.progress.findIndex(p => p.userId === userId && p.lessonId === lessonId);
|
|
const isCompleted = watchedPercentage >= 90;
|
|
|
|
if (progIdx >= 0) {
|
|
db.progress[progIdx].watchedPercentage = Math.max(db.progress[progIdx].watchedPercentage, watchedPercentage);
|
|
if (isCompleted) {
|
|
db.progress[progIdx].completed = true;
|
|
}
|
|
db.progress[progIdx].updatedAt = new Date().toISOString();
|
|
} else {
|
|
db.progress.push({
|
|
id: `prog_${Math.random().toString(36).substring(2, 9)}`,
|
|
userId,
|
|
lessonId,
|
|
watchedPercentage,
|
|
completed: isCompleted,
|
|
updatedAt: new Date().toISOString()
|
|
});
|
|
}
|
|
|
|
saveDb(db);
|
|
res.json({ success: true, completed: isCompleted });
|
|
});
|
|
|
|
// 7. Lesson Notes and Doubts
|
|
app.get('/api/lessons/:id/notes', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
|
|
const lessonId = req.params.id;
|
|
const userId = req.user!.id;
|
|
|
|
const db = loadDb();
|
|
const note = db.notes.find(n => n.userId === userId && n.lessonId === lessonId);
|
|
|
|
res.json({ content: note ? note.content : '' });
|
|
});
|
|
|
|
app.post('/api/lessons/:id/notes', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
|
|
const { content } = req.body;
|
|
const lessonId = req.params.id;
|
|
const userId = req.user!.id;
|
|
|
|
const db = loadDb();
|
|
let noteIdx = db.notes.findIndex(n => n.userId === userId && n.lessonId === lessonId);
|
|
|
|
if (noteIdx >= 0) {
|
|
db.notes[noteIdx].content = content;
|
|
db.notes[noteIdx].createdAt = new Date().toISOString();
|
|
} else {
|
|
db.notes.push({
|
|
id: `note_${Math.random().toString(36).substring(2, 9)}`,
|
|
userId,
|
|
lessonId,
|
|
content,
|
|
createdAt: new Date().toISOString()
|
|
});
|
|
}
|
|
|
|
saveDb(db);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// 8. Subscription status & Invoice history
|
|
app.get('/api/subscription/status', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
|
|
const db = loadDb();
|
|
const userId = req.user!.id;
|
|
|
|
const user = db.users.find(u => u.id === userId);
|
|
if (!user) {
|
|
res.status(404).json({ error: 'Usuário não encontrado' });
|
|
return;
|
|
}
|
|
|
|
const payments = db.payments
|
|
.filter(p => p.userId === userId)
|
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
|
|
res.json({
|
|
status: user.subscriptionStatus,
|
|
asaasCustomerId: user.asaasCustomerId,
|
|
payments
|
|
});
|
|
});
|
|
|
|
// 9. Subscribe to plan
|
|
app.post('/api/subscription/subscribe', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
|
const { billingType, creditCardInfo } = req.body;
|
|
const userId = req.user!.id;
|
|
|
|
if (!billingType) {
|
|
res.status(400).json({ error: 'Tipo de cobrança é obrigatório (PIX, CREDIT_CARD ou BOLETO)' });
|
|
return;
|
|
}
|
|
|
|
const db = loadDb();
|
|
const user = db.users.find(u => u.id === userId);
|
|
if (!user) {
|
|
res.status(404).json({ error: 'Usuário não encontrado' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let customerId = user.asaasCustomerId;
|
|
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 });
|
|
});
|
|
|
|
app.get('/api/admin/test-asaas', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
|
|
try {
|
|
const result = await AsaasService.testConnection();
|
|
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 ---
|
|
// 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') {
|
|
const description = payment.description || '';
|
|
|
|
if (description.startsWith('unlock_course_')) {
|
|
const courseId = description.replace('unlock_course_', '');
|
|
if (!user.unlockedCourses) user.unlockedCourses = [];
|
|
if (!user.unlockedCourses.includes(courseId)) {
|
|
user.unlockedCourses.push(courseId);
|
|
}
|
|
console.log(`Webhook: Curso ${courseId} LIBERADO para usuário ${user.email}`);
|
|
} else {
|
|
// Liberar / manter acesso
|
|
user.subscriptionStatus = 'ACTIVE';
|
|
console.log(`Webhook: Acesso LIBERADO para usuário ${user.email} (${user.name})`);
|
|
}
|
|
} else if (event === 'PAYMENT_OVERDUE' || event === 'SUBSCRIPTION_DELETED') {
|
|
// Bloquear acesso automaticamente se for assinatura
|
|
const description = payment.description || '';
|
|
if (!description.startsWith('unlock_course_')) {
|
|
user.subscriptionStatus = event === 'PAYMENT_OVERDUE' ? 'OVERDUE' : 'CANCELED';
|
|
console.log(`Webhook: Acesso BLOQUEADO para usuário ${user.email} (${user.name}) - Motivo: ${event}`);
|
|
}
|
|
}
|
|
} else {
|
|
console.warn(`Webhook: Cliente Asaas ${customerId} não encontrado no banco local.`);
|
|
}
|
|
|
|
saveDb(db);
|
|
res.status(200).json({ received: true });
|
|
});
|
|
|
|
// --- ACTIVITIES & CERTIFICATES API ENDPOINTS ---
|
|
|
|
// Admin: Save or update module activity
|
|
app.post('/api/admin/modules/:id/activity', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
|
|
const moduleId = req.params.id;
|
|
const { questions } = req.body;
|
|
|
|
if (!questions || !Array.isArray(questions)) {
|
|
res.status(400).json({ error: 'Lista de perguntas inválida' });
|
|
return;
|
|
}
|
|
|
|
const db = loadDb();
|
|
let activity = db.activities.find(a => a.moduleId === moduleId);
|
|
|
|
if (activity) {
|
|
activity.questions = questions;
|
|
} else {
|
|
activity = {
|
|
id: `act_${Math.random().toString(36).substring(2, 9)}`,
|
|
moduleId,
|
|
questions,
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
db.activities.push(activity);
|
|
}
|
|
|
|
saveDb(db);
|
|
res.json({ success: true, activity });
|
|
});
|
|
|
|
// Admin/Student: Get module activity
|
|
app.get('/api/modules/:id/activity', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
|
|
const moduleId = req.params.id;
|
|
const db = loadDb();
|
|
|
|
const activity = db.activities.find(a => a.moduleId === moduleId);
|
|
if (!activity) {
|
|
res.status(404).json({ error: 'Atividade não encontrada' });
|
|
return;
|
|
}
|
|
|
|
// If student, remove correctIndex so they can't cheat easily by inspecting the network request
|
|
if (req.user?.role === 'student') {
|
|
const safeQuestions = activity.questions.map((q: any) => ({
|
|
question: q.question,
|
|
options: q.options
|
|
}));
|
|
res.json({ id: activity.id, moduleId: activity.moduleId, questions: safeQuestions });
|
|
} else {
|
|
res.json(activity);
|
|
}
|
|
});
|
|
|
|
// Admin: Change Course Certificate Mode
|
|
app.post('/api/admin/courses/:id/certificate-mode', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
|
|
const courseId = req.params.id;
|
|
const { mode } = req.body; // 'PER_MODULE' or 'FULL_COURSE'
|
|
|
|
if (mode !== 'PER_MODULE' && mode !== 'FULL_COURSE') {
|
|
res.status(400).json({ error: 'Modo inválido' });
|
|
return;
|
|
}
|
|
|
|
const db = loadDb();
|
|
const course = db.courses.find(c => c.id === courseId);
|
|
if (!course) {
|
|
res.status(404).json({ error: 'Curso não encontrado' });
|
|
return;
|
|
}
|
|
|
|
course.certificateMode = mode;
|
|
saveDb(db);
|
|
res.json({ success: true, course });
|
|
});
|
|
|
|
// Admin: Manually Unlock Certificate
|
|
app.post('/api/admin/students/:userId/unlock-certificate', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
|
|
const { userId } = req.params;
|
|
const { type, targetId, courseName, moduleName } = req.body; // type: 'COURSE' or 'MODULE'
|
|
|
|
const db = loadDb();
|
|
|
|
const certId = `cert_${Math.random().toString(36).substring(2, 9)}`;
|
|
const certificate = {
|
|
id: certId,
|
|
userId,
|
|
courseId: type === 'COURSE' ? targetId : null,
|
|
moduleId: type === 'MODULE' ? targetId : null,
|
|
certificateType: type,
|
|
unlockedAt: new Date().toISOString(),
|
|
finalGrade: 10, // Default manual grade
|
|
courseName,
|
|
moduleName: moduleName || null
|
|
};
|
|
|
|
db.certificates.push(certificate);
|
|
saveDb(db);
|
|
|
|
res.json({ success: true, certificate });
|
|
});
|
|
|
|
// Student: Submit Activity Answers
|
|
app.post('/api/modules/:id/submit-activity', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
|
|
const moduleId = req.params.id;
|
|
const { answers } = req.body; // Array of selected indexes
|
|
const userId = req.user!.id;
|
|
|
|
const db = loadDb();
|
|
const activity = db.activities.find(a => a.moduleId === moduleId);
|
|
|
|
if (!activity) {
|
|
res.status(404).json({ error: 'Atividade não encontrada' });
|
|
return;
|
|
}
|
|
|
|
// Calculate Grade
|
|
let correctCount = 0;
|
|
const totalQuestions = activity.questions.length;
|
|
|
|
activity.questions.forEach((q: any, index: number) => {
|
|
if (answers[index] === q.correctIndex) {
|
|
correctCount++;
|
|
}
|
|
});
|
|
|
|
const score = totalQuestions > 0 ? (correctCount / totalQuestions) * 10 : 0;
|
|
const passed = score >= 6; // Configurable passing score
|
|
|
|
let gradeIdx = db.moduleGrades.findIndex(g => g.userId === userId && g.activityId === activity.id);
|
|
if (gradeIdx >= 0) {
|
|
db.moduleGrades[gradeIdx].score = score;
|
|
db.moduleGrades[gradeIdx].passed = passed;
|
|
} else {
|
|
db.moduleGrades.push({
|
|
id: `grd_${Math.random().toString(36).substring(2, 9)}`,
|
|
userId,
|
|
activityId: activity.id,
|
|
score,
|
|
passed,
|
|
createdAt: new Date().toISOString()
|
|
});
|
|
}
|
|
|
|
// --- Automatic Certificate Generation Logic ---
|
|
const moduleInfo = db.modules.find(m => m.id === moduleId);
|
|
if (passed && moduleInfo) {
|
|
const course = db.courses.find(c => c.id === moduleInfo.courseId);
|
|
if (course) {
|
|
const mode = course.certificateMode || 'FULL_COURSE';
|
|
|
|
if (mode === 'PER_MODULE') {
|
|
// Check if certificate already exists
|
|
const exists = db.certificates.find(c => c.userId === userId && c.moduleId === moduleId && c.certificateType === 'MODULE');
|
|
if (!exists) {
|
|
db.certificates.push({
|
|
id: `cert_${Math.random().toString(36).substring(2, 9)}`,
|
|
userId,
|
|
courseId: course.id,
|
|
moduleId: moduleInfo.id,
|
|
certificateType: 'MODULE',
|
|
unlockedAt: new Date().toISOString(),
|
|
finalGrade: score,
|
|
courseName: course.title,
|
|
moduleName: moduleInfo.title
|
|
});
|
|
}
|
|
} else {
|
|
// FULL_COURSE mode
|
|
// Check if student has passed all modules of this course
|
|
const allCourseModules = db.modules.filter(m => m.courseId === course.id);
|
|
let allPassed = true;
|
|
let totalScore = 0;
|
|
let evaluatedModules = 0;
|
|
|
|
for (const m of allCourseModules) {
|
|
const act = db.activities.find(a => a.moduleId === m.id);
|
|
if (act) {
|
|
const grade = db.moduleGrades.find(g => g.activityId === act.id && g.userId === userId);
|
|
if (!grade || !grade.passed) {
|
|
allPassed = false;
|
|
break;
|
|
}
|
|
totalScore += grade.score;
|
|
evaluatedModules++;
|
|
} else {
|
|
// If a module has no activity, we might consider it "passed" automatically, but let's just ignore it for grades
|
|
// Or require manual progress? Let's assume if it has no activity, we just skip grade check for it
|
|
// Actually, wait, let's require 100% watched percentage for modules without activities.
|
|
const lessons = db.lessons.filter(l => l.moduleId === m.id);
|
|
for(const lsn of lessons) {
|
|
const prog = db.progress.find(p => p.lessonId === lsn.id && p.userId === userId);
|
|
if (!prog || !prog.completed) {
|
|
allPassed = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (allPassed) {
|
|
const exists = db.certificates.find(c => c.userId === userId && c.courseId === course.id && c.certificateType === 'COURSE');
|
|
if (!exists) {
|
|
const finalGrade = evaluatedModules > 0 ? (totalScore / evaluatedModules) : null;
|
|
db.certificates.push({
|
|
id: `cert_${Math.random().toString(36).substring(2, 9)}`,
|
|
userId,
|
|
courseId: course.id,
|
|
moduleId: null,
|
|
certificateType: 'COURSE',
|
|
unlockedAt: new Date().toISOString(),
|
|
finalGrade,
|
|
courseName: course.title,
|
|
moduleName: null
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
saveDb(db);
|
|
res.json({ success: true, score, passed });
|
|
});
|
|
|
|
// Student: Get my certificates
|
|
app.get('/api/certificates', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
|
|
const db = loadDb();
|
|
const myCerts = db.certificates.filter(c => c.userId === req.user!.id);
|
|
res.json(myCerts);
|
|
});
|
|
|
|
// --- NOTIFICATIONS ---
|
|
app.get('/api/notifications', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
|
|
const db = loadDb();
|
|
const myNotifications = db.notifications.filter(n => n.userId === req.user!.id || n.userId === 'ALL');
|
|
res.json(myNotifications.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
|
|
});
|
|
|
|
app.put('/api/notifications/:id/read', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
|
|
const db = loadDb();
|
|
const notification = db.notifications.find(n => n.id === req.params.id);
|
|
if (notification && (notification.userId === req.user!.id || notification.userId === 'ALL')) {
|
|
notification.read = true;
|
|
saveDb(db);
|
|
}
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// --- HELP DESK ---
|
|
app.get('/api/help', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
|
|
const db = loadDb();
|
|
res.json({ helpText: db.settings?.helpText || '' });
|
|
});
|
|
|
|
app.put('/api/admin/help', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
|
|
const db = loadDb();
|
|
if (!db.settings) db.settings = {};
|
|
db.settings.helpText = req.body.helpText;
|
|
saveDb(db);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// --- MODULE ACTIVITIES (ADMIN) ---
|
|
app.get('/api/admin/modules/:moduleId/activity', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
|
|
const db = loadDb();
|
|
const activity = db.activities.find(a => a.moduleId === req.params.moduleId);
|
|
res.json(activity || { moduleId: req.params.moduleId, questions: [] });
|
|
});
|
|
|
|
app.put('/api/admin/modules/:moduleId/activity', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
|
|
const db = loadDb();
|
|
const { moduleId } = req.params;
|
|
const { questions } = req.body;
|
|
|
|
let activity = db.activities.find(a => a.moduleId === moduleId);
|
|
if (!activity) {
|
|
activity = {
|
|
id: `act_${Math.random().toString(36).substring(2, 9)}`,
|
|
moduleId,
|
|
questions: [],
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
db.activities.push(activity);
|
|
}
|
|
|
|
activity.questions = questions;
|
|
saveDb(db);
|
|
res.json(activity);
|
|
});
|
|
|
|
// --- VITE MIDDLEWARE / SPA FALLBACK ---
|
|
async function startServer() {
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
const vite = await createViteServer({
|
|
server: { middlewareMode: true },
|
|
appType: 'spa',
|
|
});
|
|
app.use(vite.middlewares);
|
|
} else {
|
|
const distPath = path.join(process.cwd(), 'dist');
|
|
app.use(express.static(distPath));
|
|
app.get('*', (req: Request, res: Response) => {
|
|
res.sendFile(path.join(distPath, 'index.html'));
|
|
});
|
|
}
|
|
|
|
app.listen(PORT, '0.0.0.0', () => {
|
|
console.log(`DevFlix Server running on port ${PORT}`);
|
|
});
|
|
}
|
|
|
|
startServer();
|