1545 lines
48 KiB
TypeScript
1545 lines
48 KiB
TypeScript
import express, { Request, Response, NextFunction } from 'express';
|
|
import path from 'path';
|
|
import jwt from 'jsonwebtoken';
|
|
import bcrypt from 'bcryptjs';
|
|
import multer from 'multer';
|
|
import { S3Client, PutObjectCommand, CreateBucketCommand, HeadBucketCommand, PutBucketPolicyCommand } from '@aws-sdk/client-s3';
|
|
import { createServer as createViteServer } from 'vite';
|
|
import { loadDb, saveDb } from './src/db/db';
|
|
import { AsaasService } from './src/services/asaas';
|
|
import { User, Course, Module, Lesson, Note, LessonProgress, WebhookLog, SubscriptionPayment, SubscriptionStatus } from './src/types';
|
|
|
|
// JWT Secret Key
|
|
const JWT_SECRET = process.env.JWT_SECRET || 'devflix_secret_token_key_13579';
|
|
|
|
const app = express();
|
|
const PORT = 3000;
|
|
|
|
app.use(express.json());
|
|
|
|
// --- MINIO / S3 CONFIGURATION ---
|
|
const s3 = new S3Client({
|
|
region: 'us-east-1',
|
|
endpoint: 'http://minio-microtecflix:9000',
|
|
credentials: {
|
|
accessKeyId: process.env.MINIO_ROOT_USER || 'microtecflix_admin',
|
|
secretAccessKey: process.env.MINIO_ROOT_PASSWORD || 'MicrotecFlixS3SecurePass2026!'
|
|
},
|
|
forcePathStyle: true
|
|
});
|
|
|
|
const upload = multer({ storage: multer.memoryStorage() });
|
|
|
|
async function initMinio() {
|
|
const bucketName = 'microtecflix';
|
|
try {
|
|
await s3.send(new HeadBucketCommand({ Bucket: bucketName }));
|
|
console.log('✅ MinIO bucket already exists.');
|
|
} catch (err: any) {
|
|
if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404 || err.message.includes('404')) {
|
|
console.log('⚠️ Creating MinIO bucket...');
|
|
try {
|
|
await s3.send(new CreateBucketCommand({ Bucket: bucketName }));
|
|
|
|
const policy = {
|
|
Version: "2012-10-17",
|
|
Statement: [
|
|
{
|
|
Sid: "PublicReadGetObject",
|
|
Effect: "Allow",
|
|
Principal: "*",
|
|
Action: "s3:GetObject",
|
|
Resource: `arn:aws:s3:::${bucketName}/*`
|
|
}
|
|
]
|
|
};
|
|
|
|
await s3.send(new PutBucketPolicyCommand({
|
|
Bucket: bucketName,
|
|
Policy: JSON.stringify(policy)
|
|
}));
|
|
console.log('✅ MinIO bucket created and set to public read.');
|
|
} catch (createErr) {
|
|
console.error('❌ Failed to create or configure MinIO bucket:', createErr);
|
|
}
|
|
} else {
|
|
console.error('❌ Error checking MinIO bucket:', err);
|
|
}
|
|
}
|
|
}
|
|
// Start initialization async, don't block server start
|
|
initMinio();
|
|
|
|
|
|
// --- JWT AUTH MIDDLEWARE ---
|
|
interface AuthenticatedRequest extends Request {
|
|
user?: {
|
|
id: string;
|
|
email: string;
|
|
role: 'admin' | 'student';
|
|
subscriptionStatus: SubscriptionStatus;
|
|
};
|
|
}
|
|
|
|
function authenticateToken(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
|
|
const authHeader = req.headers['authorization'];
|
|
const token = authHeader && authHeader.split(' ')[1];
|
|
|
|
if (!token) {
|
|
res.status(401).json({ error: 'Token de autenticação não fornecido' });
|
|
return;
|
|
}
|
|
|
|
jwt.verify(token, JWT_SECRET, (err: any, decoded: any) => {
|
|
if (err) {
|
|
res.status(403).json({ error: 'Token inválido ou expirado' });
|
|
return;
|
|
}
|
|
req.user = decoded;
|
|
next();
|
|
});
|
|
}
|
|
|
|
function requireAdmin(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
|
|
if (!req.user || req.user.role !== 'admin') {
|
|
res.status(403).json({ error: 'Acesso restrito para administradores' });
|
|
return;
|
|
}
|
|
next();
|
|
}
|
|
|
|
// --- API ROUTES ---
|
|
|
|
// 1. Auth: Register
|
|
app.post('/api/auth/register', async (req: Request, res: Response) => {
|
|
const { name, email, password, cpf, birthDate, whatsapp } = req.body;
|
|
|
|
if (!name || !email || !password || !cpf || !birthDate || !whatsapp) {
|
|
res.status(400).json({ error: 'Preencha todos os campos obrigatórios' });
|
|
return;
|
|
}
|
|
|
|
const db = loadDb();
|
|
const userExists = db.users.find(u => u.email.toLowerCase() === email.toLowerCase());
|
|
|
|
if (userExists) {
|
|
res.status(400).json({ error: 'E-mail já cadastrado na plataforma' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Generate Asaas Customer ID (Real call if configured, or simulated)
|
|
const asaasCustomerId = await AsaasService.createCustomer(name, email, cpf);
|
|
|
|
const salt = bcrypt.genSaltSync(10);
|
|
const passwordHash = bcrypt.hashSync(password, salt);
|
|
|
|
const createdAt = new Date();
|
|
|
|
// Calculate trial ends at
|
|
const trialDays = db.settings?.trialDurationDays || 7;
|
|
const trialEndsAt = new Date(createdAt);
|
|
trialEndsAt.setDate(trialEndsAt.getDate() + trialDays);
|
|
|
|
const newUser: User = {
|
|
id: `usr_${Math.random().toString(36).substring(2, 9)}`,
|
|
name,
|
|
email: email.toLowerCase(),
|
|
password: passwordHash,
|
|
role: 'student',
|
|
subscriptionStatus: 'TRIAL', // Defaults to trial
|
|
asaasCustomerId,
|
|
cpf,
|
|
birthDate,
|
|
whatsapp,
|
|
trialEndsAt: trialEndsAt.toISOString(),
|
|
createdAt: createdAt.toISOString()
|
|
};
|
|
|
|
db.users.push(newUser);
|
|
saveDb(db);
|
|
|
|
// Generate JWT
|
|
const token = jwt.sign(
|
|
{ id: newUser.id, email: newUser.email, role: newUser.role, subscriptionStatus: newUser.subscriptionStatus },
|
|
JWT_SECRET,
|
|
{ expiresIn: '7d' }
|
|
);
|
|
|
|
res.status(201).json({
|
|
token,
|
|
user: {
|
|
id: newUser.id,
|
|
name: newUser.name,
|
|
email: newUser.email,
|
|
role: newUser.role,
|
|
subscriptionStatus: newUser.subscriptionStatus,
|
|
cpf: newUser.cpf,
|
|
whatsapp: newUser.whatsapp,
|
|
birthDate: newUser.birthDate,
|
|
avatarUrl: newUser.avatarUrl,
|
|
createdAt: newUser.createdAt
|
|
}
|
|
});
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message || 'Erro ao registrar usuário' });
|
|
}
|
|
});
|
|
|
|
// 2. Auth: Login
|
|
app.post('/api/auth/login', (req: Request, res: Response) => {
|
|
const { email, password } = req.body;
|
|
|
|
if (!email || !password) {
|
|
res.status(400).json({ error: 'Informe e-mail e senha' });
|
|
return;
|
|
}
|
|
|
|
const db = loadDb();
|
|
const user = db.users.find(u => u.email.toLowerCase() === email.toLowerCase());
|
|
|
|
if (!user || !user.password) {
|
|
res.status(401).json({ error: 'Credenciais inválidas' });
|
|
return;
|
|
}
|
|
|
|
const passwordValid = bcrypt.compareSync(password, user.password);
|
|
if (!passwordValid) {
|
|
res.status(401).json({ error: 'Credenciais inválidas' });
|
|
return;
|
|
}
|
|
|
|
const token = jwt.sign(
|
|
{ id: user.id, email: user.email, role: user.role, subscriptionStatus: user.subscriptionStatus },
|
|
JWT_SECRET,
|
|
{ expiresIn: '7d' }
|
|
);
|
|
|
|
res.json({
|
|
token,
|
|
user: {
|
|
id: user.id,
|
|
name: user.name,
|
|
email: user.email,
|
|
role: user.role,
|
|
subscriptionStatus: user.subscriptionStatus,
|
|
unlockedCourses: user.unlockedCourses || [],
|
|
cpf: user.cpf,
|
|
whatsapp: user.whatsapp,
|
|
birthDate: user.birthDate,
|
|
avatarUrl: user.avatarUrl,
|
|
createdAt: user.createdAt
|
|
}
|
|
});
|
|
});
|
|
|
|
// 3. Auth: Me
|
|
app.get('/api/auth/me', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
|
|
if (!req.user) {
|
|
res.status(401).json({ error: 'Não autorizado' });
|
|
return;
|
|
}
|
|
|
|
const db = loadDb();
|
|
const user = db.users.find(u => u.id === req.user?.id);
|
|
|
|
if (!user) {
|
|
res.status(404).json({ error: 'Usuário não encontrado' });
|
|
return;
|
|
}
|
|
|
|
res.json({
|
|
id: user.id,
|
|
name: user.name,
|
|
email: user.email,
|
|
role: user.role,
|
|
subscriptionStatus: user.subscriptionStatus,
|
|
unlockedCourses: user.unlockedCourses || [],
|
|
cpf: user.cpf,
|
|
whatsapp: user.whatsapp,
|
|
birthDate: user.birthDate,
|
|
avatarUrl: user.avatarUrl,
|
|
createdAt: user.createdAt
|
|
});
|
|
});
|
|
|
|
// 3.1. Profile: Update Data
|
|
app.put('/api/users/profile', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
|
|
const { name, whatsapp, birthDate } = 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) user.name = name;
|
|
if (whatsapp !== undefined) user.whatsapp = whatsapp;
|
|
if (birthDate !== undefined) user.birthDate = birthDate;
|
|
|
|
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<string, { count: number, resetAt: number }>();
|
|
|
|
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()
|
|
};
|
|
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 } = 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;
|
|
}
|
|
|
|
// 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) {
|
|
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 = 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 });
|
|
});
|
|
|
|
// 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: ${tokenHeader}`);
|
|
res.status(401).json({ error: 'Não autorizado - Token de Webhook do Asaas é inválido ou ausente.' });
|
|
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
|
|
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()
|
|
});
|
|
}
|
|
|
|
// 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);
|
|
});
|
|
|
|
// --- 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();
|