import express, { Request, Response, NextFunction } from 'express';
import path from 'path';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import multer from 'multer';
import axios from 'axios';
import { execSync } from 'child_process';
import { S3Client, PutObjectCommand, CreateBucketCommand, HeadBucketCommand, PutBucketPolicyCommand } from '@aws-sdk/client-s3';
import crypto from 'crypto';
import nodemailer from 'nodemailer';
import { prisma } from './src/db/prisma';
import { Prisma } from '@prisma/client';
import { AsaasService } from './src/services/asaas';
import { User, Course, Module, Lesson, Note, LessonProgress, WebhookLog, SubscriptionPayment, SubscriptionStatus } from './src/types';
// Override default serialization of Decimal fields to return a normal JavaScript number
(Prisma.Decimal.prototype as any).toJSON = function () {
return this.toNumber();
};
// 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
// Start initialization async, don't block server start
initMinio();
// --- SMTP EMAILS CONFIGURATION ---
async function getTransporter() {
const settings = await prisma.systemSettings.findFirst();
if (!settings || !settings.smtpHost) {
throw new Error('As credenciais SMTP não estão configuradas.');
}
return nodemailer.createTransport({
host: settings.smtpHost,
port: settings.smtpPort || 587,
secure: settings.smtpSecure,
auth: {
user: settings.smtpUser,
pass: settings.smtpPass,
},
});
}
// --- JWT AUTH MIDDLEWARE ---
interface AuthenticatedRequest extends Request {
user?: {
id: string;
email: string;
role: 'admin' | 'student';
subscriptionStatus: SubscriptionStatus;
};
}
function authenticateToken(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
res.status(401).json({ error: 'Token de autenticação não fornecido' });
return;
}
jwt.verify(token, JWT_SECRET, (err: any, decoded: any) => {
if (err) {
res.status(403).json({ error: 'Token inválido ou expirado' });
return;
}
req.user = decoded;
next();
});
}
function requireAdmin(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
if (!req.user || req.user.role !== 'admin') {
res.status(403).json({ error: 'Acesso restrito para administradores' });
return;
}
next();
}
// --- API ROUTES ---
// 1. Auth: Register
app.post('/api/auth/register', async (req: Request, res: Response) => {
const { name, email, password, cpf, birthDate, whatsapp } = req.body;
if (!name || !email || !password || !cpf || !birthDate || !whatsapp) {
res.status(400).json({ error: 'Preencha todos os campos obrigatórios' });
return;
}
try {
const userExists = await prisma.user.findUnique({
where: { email: email.toLowerCase() }
});
if (userExists) {
res.status(400).json({ error: 'E-mail já cadastrado na plataforma' });
return;
}
// Generate Asaas Customer ID (Real call if configured, or simulated)
const asaasCustomerId = await AsaasService.createCustomer(name, email, cpf);
const salt = bcrypt.genSaltSync(10);
const passwordHash = bcrypt.hashSync(password, salt);
const createdAt = new Date();
// Calculate trial ends at
const settings = await prisma.systemSettings.findUnique({ where: { id: 'default' } });
const trialDays = settings?.trialDurationDays || 7;
const trialEndsAt = new Date(createdAt);
trialEndsAt.setDate(trialEndsAt.getDate() + trialDays);
const newUser = await prisma.user.create({
data: {
name,
email: email.toLowerCase(),
password: passwordHash,
role: 'student',
subscriptionStatus: 'TRIAL',
asaasCustomerId,
// @ts-ignore
cpf, whatsapp, birthDate, trialEndsAt: trialEndsAt.toISOString(), // Assuming these are added to schema later or stored differently if not in schema. Wait, these aren't in prisma schema! I need to add them.
}
});
// Generate JWT
const token = jwt.sign(
{ id: newUser.id, email: newUser.email, role: newUser.role, subscriptionStatus: newUser.subscriptionStatus },
JWT_SECRET,
{ expiresIn: '7d' }
);
res.status(201).json({
token,
user: newUser
});
} catch (err: any) {
res.status(500).json({ error: err.message || 'Erro ao registrar usuário' });
}
});
// 2. Auth: Login
app.post('/api/auth/login', async (req: Request, res: Response) => {
const { email, password } = req.body; // email field represents identifier (email or whatsapp)
if (!email || !password) {
res.status(400).json({ error: 'E-mail ou WhatsApp e senha são obrigatórios' });
return;
}
try {
let user;
if (email.includes('@')) {
user = await prisma.user.findFirst({
where: { email: email.toLowerCase() }
});
} else {
const cleanPhone = email.replace(/\D/g, '');
const withoutDDI = cleanPhone.replace(/^55/, '');
const withDDI = '55' + withoutDDI;
user = await prisma.user.findFirst({
where: {
OR: [
{ whatsapp: cleanPhone },
{ whatsapp: withoutDDI },
{ whatsapp: withDDI }
]
}
});
}
if (!user || !user.password) {
res.status(401).json({ error: 'Credenciais inválidas' });
return;
}
const passwordValid = bcrypt.compareSync(password, user.password);
if (!passwordValid) {
res.status(401).json({ error: 'Credenciais inválidas' });
return;
}
const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role, subscriptionStatus: user.subscriptionStatus },
JWT_SECRET,
{ expiresIn: '7d' }
);
res.json({
token,
user
});
} catch (err: any) {
res.status(500).json({ error: 'Erro no servidor' });
}
});
// 2.5 Auth: Forgot Password
app.post('/api/auth/forgot-password', async (req: Request, res: Response) => {
const { identifier, channel } = req.body;
if (!identifier || !channel) {
res.status(400).json({ error: 'Informe o identificador e o canal (email ou whatsapp).' });
return;
}
try {
let user;
if (channel === 'whatsapp') {
const cleanIdentifier = identifier.replace(/\D/g, '');
const numberWithoutDDI = cleanIdentifier.replace(/^55/, '');
const numberWithDDI = '55' + numberWithoutDDI;
user = await prisma.user.findFirst({
where: {
OR: [
{ whatsapp: cleanIdentifier },
{ whatsapp: numberWithoutDDI },
{ whatsapp: numberWithDDI }
]
}
});
} else {
user = await prisma.user.findFirst({
where: { email: identifier.toLowerCase() }
});
}
if (!user) {
res.json({ success: true, message: 'Se o usuário existir, o link foi enviado.' });
return;
}
const token = crypto.randomBytes(32).toString('hex');
const expires = new Date(Date.now() + 3600000); // 1 hour
await prisma.user.update({
where: { id: user.id },
data: { resetPasswordToken: token, resetPasswordExpires: expires }
});
const resetUrl = `https://estudo.microtecinformaticacurso.com.br/reset-password?token=${token}`;
if (channel === 'whatsapp') {
const text = `*Recuperação de Senha - MicrotecFlix*\n\nVocê solicitou a recuperação da sua senha.\nClique no link abaixo para criar uma nova senha:\n\n${resetUrl}\n\n_Se você não solicitou isso, ignore esta mensagem._`;
await sendWhatsappNotification(user.whatsapp || identifier, text);
} else {
const transporter = await getTransporter();
const settings = await prisma.systemSettings.findFirst();
await transporter.sendMail({
from: `"${settings?.brandName || 'Microtec'}Flix" <${settings?.smtpFrom || 'no-reply@microtecinformaticacurso.com.br'}>`,
to: user.email,
subject: 'Recuperação de Senha',
html: `
${settings?.brandName || 'MICROTEC'}FLIX
Recuperação de Senha
Você solicitou a recuperação da sua senha. Clique no botão abaixo para redefini-la (válido por 1 hora).
Redefinir Senha
Se você não solicitou, apenas ignore este e-mail.
`
});
}
res.json({ success: true, message: 'Link de recuperação enviado com sucesso.' });
} catch (error: any) {
console.error('Forgot password error:', error);
res.status(500).json({ error: error.message || 'Erro ao enviar link de recuperação.' });
}
});
// 2.6 Auth: Reset Password
app.post('/api/auth/reset-password', async (req: Request, res: Response) => {
const { token, newPassword } = req.body;
if (!token || !newPassword) {
res.status(400).json({ error: 'Token e nova senha são obrigatórios.' });
return;
}
try {
const user = await prisma.user.findFirst({
where: {
resetPasswordToken: token,
resetPasswordExpires: { gte: new Date() }
}
});
if (!user) {
res.status(400).json({ error: 'Token inválido ou expirado.' });
return;
}
const hashedPassword = bcrypt.hashSync(newPassword, 10);
await prisma.user.update({
where: { id: user.id },
data: {
password: hashedPassword,
resetPasswordToken: null,
resetPasswordExpires: null
}
});
res.json({ success: true, message: 'Senha redefinida com sucesso!' });
} catch (error) {
console.error('Reset password error:', error);
res.status(500).json({ error: 'Erro ao redefinir senha.' });
}
});
// 3. Auth: Me
app.get('/api/auth/me', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
if (!req.user) {
res.status(401).json({ error: 'Não autorizado' });
return;
}
try {
const user = await prisma.user.findUnique({
where: { id: req.user.id }
});
if (!user) {
res.status(404).json({ error: 'Usuário não encontrado' });
return;
}
res.json(user);
} catch(err) {
res.status(500).json({ error: 'Erro no servidor' });
}
});
// 3.1. Profile: Update Data
app.put('/api/users/profile', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const { name, whatsapp, birthDate, cpf } = req.body;
const userId = req.user!.id;
try {
const user = await prisma.user.update({
where: { id: userId },
data: {
...(name !== undefined && { name }),
...(whatsapp !== undefined && { whatsapp }),
...(birthDate !== undefined && { birthDate }),
...(cpf !== undefined && { cpf })
}
});
res.json({ success: true, user });
} catch (error) {
res.status(404).json({ error: 'Usuário não encontrado' });
}
});
// 3.2. Profile: Avatar Upload
app.post('/api/users/avatar', authenticateToken, upload.single('avatar'), async (req: AuthenticatedRequest, res: Response) => {
const userId = req.user!.id;
if (!req.file) {
res.status(400).json({ error: 'Nenhum arquivo enviado' });
return;
}
const ext = req.file.originalname.split('.').pop();
const filename = `avatars/${userId}_${Date.now()}.${ext}`;
try {
await s3.send(new PutObjectCommand({
Bucket: 'microtecflix',
Key: filename,
Body: req.file.buffer,
ContentType: req.file.mimetype
}));
// URL Pública configurada via Traefik
const avatarUrl = `https://s3-estudo.microtecinformaticacurso.com.br/microtecflix/${filename}`;
await prisma.user.update({
where: { id: userId },
data: { avatarUrl }
});
res.json({ success: true, avatarUrl });
} catch (err: any) {
console.error('Erro no upload da foto:', err);
res.status(500).json({ error: 'Erro ao salvar foto no MinIO' });
}
});
// Admin Image/File Upload directly to MinIO S3
app.post('/api/admin/upload', authenticateToken, upload.single('file'), async (req: AuthenticatedRequest, res: Response) => {
if (!req.file) {
res.status(400).json({ error: 'Nenhum arquivo enviado' });
return;
}
const ext = req.file.originalname.split('.').pop();
const filename = `uploads/${Date.now()}_${Math.random().toString(36).substring(2, 8)}.${ext}`;
try {
await s3.send(new PutObjectCommand({
Bucket: 'microtecflix',
Key: filename,
Body: req.file.buffer,
ContentType: req.file.mimetype
}));
const url = `https://s3-estudo.microtecinformaticacurso.com.br/microtecflix/${filename}`;
res.json({ success: true, url });
} catch (err: any) {
console.error('Erro no upload para S3:', err);
res.status(500).json({ error: 'Erro ao salvar arquivo no S3' });
}
});
// 4. Courses List (With Progress)
app.get('/api/courses', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const userId = req.user!.id;
const user = await prisma.user.findUnique({
where: { id: userId }
});
const unlockedCourses = user?.unlockedCourses || [];
const isAdmin = user?.role === 'admin';
const courses = await prisma.course.findMany({
include: {
modules: {
include: {
lessons: true
}
}
}
});
const userProgress = await prisma.progress.findMany({
where: { userId, completed: true }
});
const userProgressLessonIds = userProgress.map(p => p.lessonId);
const coursesWithProgress = courses.map(course => {
const sortedModules = [...course.modules].sort((a, b) => a.order - b.order);
const courseLessons = sortedModules.flatMap(m => {
const sortedLessons = [...m.lessons].sort((a, b) => a.order - b.order);
return sortedLessons.map(l => ({
id: l.id,
title: l.title,
thumbnailUrl: l.thumbnailUrl,
order: l.order,
moduleId: m.id
}));
});
const totalLessons = courseLessons.length;
let completedLessons = 0;
if (totalLessons > 0) {
completedLessons = courseLessons.filter(l => userProgressLessonIds.includes(l.id)).length;
}
const progressPercentage = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0;
return {
id: course.id,
title: course.title,
description: course.description,
thumbnail: course.thumbnail,
isLocked: course.isLocked,
price: course.price,
category: course.category,
totalLessons,
completedLessons,
progress: progressPercentage,
isUnlocked: !course.isLocked || unlockedCourses.includes(course.id) || isAdmin,
lessons: courseLessons
};
});
res.json(coursesWithProgress);
});
// 5. Course Details (with Modules, Lessons, Progress & Notes)
app.get('/api/courses/:id', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const userId = req.user!.id;
const courseId = req.params.id;
const course = await prisma.course.findUnique({
where: { id: courseId },
include: {
modules: {
orderBy: { order: 'asc' },
include: {
lessons: {
orderBy: { order: 'asc' }
}
}
}
}
});
if (!course) {
res.status(404).json({ error: 'Curso não encontrado' });
return;
}
const user = await prisma.user.findUnique({
where: { id: userId }
});
const unlockedCourses = user?.unlockedCourses || [];
const isAdmin = user?.role === 'admin';
const isUnlocked = !course.isLocked || unlockedCourses.includes(course.id) || isAdmin;
const progressRecords = await prisma.progress.findMany({
where: { userId, lesson: { moduleId: { in: course.modules.map(m => m.id) } } }
});
const noteRecords = await prisma.note.findMany({
where: { userId, lesson: { moduleId: { in: course.modules.map(m => m.id) } } }
});
const modulesWithLessons = course.modules.map(mod => {
const lessons = mod.lessons.map(lesson => {
const prog = progressRecords.find(p => p.lessonId === lesson.id);
const note = noteRecords.find(n => n.lessonId === lesson.id);
return {
...lesson,
progress: prog ? {
watchedPercentage: prog.watchedPercentage,
completed: prog.completed
} : { watchedPercentage: 0, completed: false },
note: note ? note.content : ''
};
});
return {
...mod,
lessons
};
});
const { modules, ...courseData } = course;
res.json({
course: {
...courseData,
isUnlocked
},
modules: modulesWithLessons
});
});
// Rate limiting protection for payment checkout endpoints
const checkoutRateLimitMap = new Map();
const checkoutRateLimiter = (req: Request, res: Response, next: Function) => {
const ip = req.ip || req.socket.remoteAddress || 'unknown';
const now = Date.now();
const record = checkoutRateLimitMap.get(ip) || { count: 0, resetAt: now + 60000 };
if (now > record.resetAt) {
record.count = 1;
record.resetAt = now + 60000;
} else {
record.count += 1;
}
checkoutRateLimitMap.set(ip, record);
if (record.count > 15) {
res.status(429).json({ error: 'Muitas tentativas de pagamento num curto período. Aguarde um minuto e tente novamente.' });
return;
}
next();
};
// 5.5 Unlock Course
app.post('/api/courses/:id/unlock', authenticateToken, checkoutRateLimiter, async (req: AuthenticatedRequest, res: Response) => {
const courseId = req.params.id;
const userId = req.user!.id;
const { billingType, creditCardInfo, installmentCount, cpf } = req.body;
const user = await prisma.user.findUnique({ where: { id: userId } });
const course = await prisma.course.findUnique({ where: { id: courseId } });
if (!user) {
res.status(404).json({ error: 'Usuário não encontrado' });
return;
}
if (!course) {
res.status(404).json({ error: 'Curso não encontrado' });
return;
}
try {
const activeCpf = cpf || user.cpf;
if (!activeCpf || activeCpf === '000.000.000-00') {
res.status(400).json({ error: 'Para realizar o pagamento é obrigatório fornecer um CPF ou CNPJ válido.' });
return;
}
if (activeCpf && activeCpf !== user.cpf) {
await prisma.user.update({
where: { id: userId },
data: { cpf: activeCpf }
});
user.cpf = activeCpf;
}
let customerId = user.asaasCustomerId;
const apiKey = await 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, activeCpf);
await prisma.user.update({
where: { id: userId },
data: { asaasCustomerId: customerId }
});
user.asaasCustomerId = customerId;
} else {
await AsaasService.updateCustomer(customerId, { cpfCnpj: activeCpf });
}
let processedCardInfo = creditCardInfo;
// Tokenize credit card if raw info is provided (PCI-DSS Compliance)
if (billingType === 'CREDIT_CARD' && creditCardInfo?.creditCard?.number) {
try {
const tokenResult = await AsaasService.tokenizeCreditCard(
customerId,
creditCardInfo.creditCard,
creditCardInfo.creditCardHolderInfo
);
processedCardInfo = {
creditCardToken: tokenResult.creditCardToken,
creditCardNumberLast4: tokenResult.creditCardNumber
};
} catch (tokErr) {
console.warn('Tokenization fallback to direct API transmission:', tokErr);
}
}
const installments = installmentCount ? Number(installmentCount) : 1;
const asaasPayment = await AsaasService.createPayment(
customerId,
course.price || 49.90,
billingType || 'PIX',
`unlock_course_${courseId}`,
processedCardInfo,
installments
);
let pixData = null;
if ((billingType || 'PIX') === 'PIX') {
pixData = await AsaasService.getPixQrCode(asaasPayment.id);
}
const newPayment = await prisma.payment.create({
data: {
id: asaasPayment.id,
userId: userId,
value: course.price || 49.90,
status: 'PENDING',
billingType: billingType || 'PIX',
invoiceUrl: asaasPayment.invoiceUrl,
dueDate: asaasPayment.dueDate ? new Date(asaasPayment.dueDate) : new Date(),
courseId: courseId
}
});
res.json({ success: true, payment: newPayment, pixData });
} catch (err: any) {
console.error('Course unlock error:', err);
res.status(400).json({ error: err.message || 'Erro ao processar pagamento do curso' });
}
});
// Route to cancel a pending payment
app.post('/api/payments/:id/cancel', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const paymentId = req.params.id;
try {
const payment = await prisma.payment.findUnique({ where: { id: paymentId } });
if (!payment) {
res.status(404).json({ error: 'Pagamento não encontrado.' });
return;
}
if (payment.status === 'PENDING') {
const deleted = await AsaasService.deletePayment(paymentId);
if (deleted) {
await prisma.payment.update({
where: { id: paymentId },
data: { status: 'CANCELED' }
});
res.json({ success: true, message: 'Pagamento cancelado com sucesso.' });
return;
}
}
res.status(400).json({ error: 'Não foi possível cancelar este pagamento.' });
} catch (err: any) {
console.error('Erro ao cancelar pagamento:', err.message);
res.status(500).json({ error: 'Erro no servidor.' });
}
});
// 6. Lesson Progress tracking
app.post('/api/lessons/:id/progress', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const { watchedPercentage } = req.body;
const lessonId = req.params.id;
const userId = req.user!.id;
if (watchedPercentage === undefined) {
res.status(400).json({ error: 'watchedPercentage é obrigatório' });
return;
}
const lesson = await prisma.lesson.findUnique({ where: { id: lessonId } });
if (!lesson) {
res.status(404).json({ error: 'Aula não encontrada' });
return;
}
const isCompleted = watchedPercentage >= 90;
try {
const existingProgress = await prisma.progress.findFirst({
where: { userId, lessonId }
});
if (existingProgress) {
await prisma.progress.update({
where: { id: existingProgress.id },
data: {
watchedPercentage: Math.max(existingProgress.watchedPercentage, watchedPercentage),
completed: isCompleted ? true : existingProgress.completed
}
});
} else {
await prisma.progress.create({
data: {
userId,
lessonId,
watchedPercentage,
completed: isCompleted
}
});
}
res.json({ success: true, completed: isCompleted });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao salvar progresso' });
}
});
// 7. Lesson Notes and Doubts
app.get('/api/lessons/:id/notes', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const lessonId = req.params.id;
const userId = req.user!.id;
const note = await prisma.note.findFirst({
where: { userId, lessonId }
});
res.json({ content: note ? note.content : '' });
});
app.post('/api/lessons/:id/notes', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const { content } = req.body;
const lessonId = req.params.id;
const userId = req.user!.id;
try {
const existingNote = await prisma.note.findFirst({
where: { userId, lessonId }
});
if (existingNote) {
await prisma.note.update({
where: { id: existingNote.id },
data: { content }
});
} else {
await prisma.note.create({
data: {
userId,
lessonId,
content
}
});
}
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao salvar anotação' });
}
});
// 8. Subscription status & Invoice history
app.get('/api/subscription/status', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const userId = req.user!.id;
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
res.status(404).json({ error: 'Usuário não encontrado' });
return;
}
const payments = await prisma.payment.findMany({
where: { userId },
orderBy: { createdAt: 'desc' }
});
res.json({
status: user.subscriptionStatus,
asaasCustomerId: user.asaasCustomerId,
payments
});
});
// 9. Subscribe to plan
app.post('/api/subscription/subscribe', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const { billingType, creditCardInfo, planId, cpf } = req.body;
const userId = req.user!.id;
if (!billingType) {
res.status(400).json({ error: 'Tipo de cobrança é obrigatório (PIX, CREDIT_CARD ou BOLETO)' });
return;
}
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
res.status(404).json({ error: 'Usuário não encontrado' });
return;
}
try {
const activeCpf = cpf || user.cpf;
if (!activeCpf || activeCpf === '000.000.000-00') {
res.status(400).json({ error: 'Para realizar a assinatura é obrigatório fornecer um CPF ou CNPJ válido.' });
return;
}
if (activeCpf && activeCpf !== user.cpf) {
await prisma.user.update({
where: { id: userId },
data: { cpf: activeCpf }
});
user.cpf = activeCpf;
}
let customerId = user.asaasCustomerId;
const apiKey = await 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, activeCpf);
await prisma.user.update({
where: { id: userId },
data: { asaasCustomerId: customerId }
});
user.asaasCustomerId = customerId;
} else {
await AsaasService.updateCustomer(customerId, { cpfCnpj: activeCpf });
}
let plan = planId ? await prisma.plan.findUnique({ where: { id: planId } }) : null;
let price = plan ? plan.price : 49.90;
let cycle = plan ? plan.cycle : 'MONTHLY';
let description = plan ? `Plano: ${plan.title}` : 'Assinatura Mensal DevFlix';
let asaasPaymentId = '';
let invoiceUrlStr = '';
let statusAsaas = billingType === 'CREDIT_CARD' ? 'CONFIRMED' : 'PENDING';
let asaasDueDate = new Date(Date.now() + 3 * 24 * 3600 * 1000).toISOString().split('T')[0];
let pixData = null;
if (cycle === 'LIFETIME') {
let processedCardInfo = creditCardInfo;
if (billingType === 'CREDIT_CARD' && creditCardInfo?.creditCard?.number) {
try {
const tokenResult = await AsaasService.tokenizeCreditCard(
customerId,
creditCardInfo.creditCard,
creditCardInfo.creditCardHolderInfo
);
processedCardInfo = {
creditCardToken: tokenResult.creditCardToken,
creditCardNumberLast4: tokenResult.creditCardNumber
};
} catch (tokErr) {
console.warn('Tokenization fallback to direct API transmission:', tokErr);
}
}
const asaasPayment = await AsaasService.createPayment(
customerId,
price,
billingType,
`plan_${planId}`,
processedCardInfo,
1
);
asaasPaymentId = asaasPayment.id;
invoiceUrlStr = asaasPayment.invoiceUrl;
asaasDueDate = asaasPayment.dueDate || asaasDueDate;
if (billingType === 'PIX') {
pixData = await AsaasService.getPixQrCode(asaasPayment.id);
}
} else {
const { subscriptionId, payment } = await AsaasService.createSubscription(
customerId,
price,
billingType,
creditCardInfo,
cycle as any,
description
);
asaasPaymentId = payment.id || `pay_${Math.random().toString(36).substring(2, 9)}`;
invoiceUrlStr = payment.invoiceUrl || '';
asaasDueDate = payment.dueDate || asaasDueDate;
}
const newPayment = await prisma.payment.create({
data: {
id: asaasPaymentId,
userId: userId,
value: price,
status: statusAsaas as any,
billingType: billingType,
invoiceUrl: invoiceUrlStr,
dueDate: new Date(asaasDueDate),
paidAt: statusAsaas === 'CONFIRMED' ? new Date() : null,
planId: planId
}
});
if (statusAsaas === 'CONFIRMED') {
if (cycle === 'LIFETIME' && plan) {
if (!plan.includedCourses || plan.includedCourses.length === 0) {
await prisma.user.update({
where: { id: userId },
data: { subscriptionStatus: 'ACTIVE' }
});
} else {
await prisma.user.update({
where: { id: userId },
data: {
unlockedCourses: {
set: [...new Set([...(user.unlockedCourses || []), ...plan.includedCourses])]
}
}
});
}
} else {
await prisma.user.update({
where: { id: userId },
data: { subscriptionStatus: 'ACTIVE' }
});
}
}
res.json({ success: true, payment: newPayment, pixData });
} catch (err: any) {
console.error('Subscription error:', err);
res.status(400).json({ error: err.message || 'Erro ao processar assinatura' });
}
});
// --- ADMIN API ENDPOINTS ---
// 1. Dashboard statistics
// 1. Dashboard statistics
app.get('/api/admin/dashboard', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const users = await prisma.user.findMany({ where: { role: 'student' } });
const totalActive = users.filter(u => u.subscriptionStatus === 'ACTIVE').length;
const totalOverdue = users.filter(u => u.subscriptionStatus === 'OVERDUE').length;
const totalCanceled = users.filter(u => u.subscriptionStatus === 'CANCELED').length;
const totalTrial = users.filter(u => u.subscriptionStatus === 'TRIAL').length;
const activeUsersIds = users.filter(u => u.subscriptionStatus === 'ACTIVE').map(u => u.id);
const allPayments = await prisma.payment.findMany({
include: {
user: true,
course: true,
plan: true
},
orderBy: { createdAt: 'desc' }
});
// MRR Calculation
let mrr = 0;
for (const userId of activeUsersIds) {
const studentPayments = allPayments.filter(p => p.userId === userId && (p.status === 'CONFIRMED' || p.status === 'RECEIVED'));
// Find the latest payment that is a subscription (either a plan or no courseId)
const latestSubscription = studentPayments.find(p => p.planId || (!p.courseId && !p.planId));
if (latestSubscription) {
if (latestSubscription.planId && latestSubscription.plan) {
if (latestSubscription.plan.cycle === 'MONTHLY') mrr += latestSubscription.plan.price;
else if (latestSubscription.plan.cycle === 'YEARLY') mrr += (latestSubscription.plan.price / 12);
} else if (!latestSubscription.courseId) {
mrr += Number(latestSubscription.value);
}
}
}
// Total Revenue Calculation
const totalRevenue = allPayments
.filter(p => p.status === 'CONFIRMED' || p.status === 'RECEIVED')
.reduce((sum, p) => sum + Number(p.value), 0);
// Generate Revenue Data for Charts (Last 30 Days)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const recentConfirmedPayments = allPayments.filter(
p => (p.status === 'CONFIRMED' || p.status === 'RECEIVED') && p.createdAt >= thirtyDaysAgo
);
const revenueByDay: Record = {};
const categoryRevenue: Record = {};
for (const pay of recentConfirmedPayments) {
const dateStr = pay.createdAt.toISOString().split('T')[0];
revenueByDay[dateStr] = (revenueByDay[dateStr] || 0) + Number(pay.value);
let category = 'Desconhecido';
if (pay.courseId && pay.course) {
category = `Curso: ${pay.course.title}`;
} else if (pay.planId && pay.plan) {
category = `Plano: ${pay.plan.title}`;
} else {
category = 'Assinatura Legado';
}
categoryRevenue[category] = (categoryRevenue[category] || 0) + Number(pay.value);
}
const revenueData = Object.keys(revenueByDay).sort().map(date => ({
date,
value: revenueByDay[date]
}));
const categoryData = Object.keys(categoryRevenue).map(name => ({
name,
value: categoryRevenue[name]
}));
const recentPaymentsFormatted = allPayments
.slice(0, 15)
.map(p => {
let desc = '';
if (p.course) desc = `Curso: ${p.course.title}`;
else if (p.plan) desc = `Plano: ${p.plan.title}`;
return {
id: p.id,
userId: p.userId,
value: p.value,
status: p.status,
billingType: p.billingType,
invoiceUrl: p.invoiceUrl,
dueDate: p.dueDate,
paidAt: p.paidAt,
createdAt: p.createdAt,
courseId: p.courseId,
planId: p.planId,
studentName: p.user?.name || 'Aluno Removido',
studentEmail: p.user?.email || '',
description: desc
};
});
const webhookLogs = await prisma.webhookLog.findMany({
orderBy: { receivedAt: 'desc' },
take: 15
});
res.json({
stats: {
mrr,
totalRevenue,
activeSubscribers: totalActive,
trialSubscribers: totalTrial,
overdueSubscribers: totalOverdue,
canceledSubscribers: totalCanceled
},
revenueData,
categoryData,
recentPayments: recentPaymentsFormatted,
webhookLogs: webhookLogs.map(w => ({
id: w.id,
event: w.event,
payload: w.payload,
receivedAt: w.receivedAt
}))
});
});
// 2. Students & status management
app.get('/api/admin/students', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const students = await prisma.user.findMany({
where: { role: 'student' },
include: {
payments: true,
moduleGrades: true,
certificates: true
}
});
const studentsFormatted = students.map(u => {
return {
id: u.id,
name: u.name,
email: u.email,
subscriptionStatus: u.subscriptionStatus,
asaasCustomerId: u.asaasCustomerId,
createdAt: u.createdAt,
birthDate: u.birthDate,
whatsapp: u.whatsapp,
trialEndsAt: u.trialEndsAt,
totalPaid: u.payments
.filter(p => p.status === 'CONFIRMED' || p.status === 'RECEIVED')
.reduce((acc, curr) => acc + Number(curr.value), 0),
unlockedCourses: u.unlockedCourses || [],
moduleGrades: u.moduleGrades,
certificates: u.certificates,
payments: u.payments.map(p => ({ ...p, value: Number(p.value) }))
};
});
res.json(studentsFormatted);
});
// Admin change student status manually
app.post('/api/admin/students/:id/status', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const { status } = req.body;
const studentId = req.params.id;
const allowedStatuses = ['ACTIVE', 'OVERDUE', 'CANCELED', 'TRIAL'];
if (!status || !allowedStatuses.includes(status as any)) {
res.status(400).json({ error: 'Status de assinatura inválido' });
return;
}
try {
const student = await prisma.user.update({
where: { id: studentId, role: 'student' },
data: { subscriptionStatus: status as any }
});
res.json({ success: true, status: student.subscriptionStatus });
} catch (error) {
res.status(404).json({ error: 'Estudante não encontrado' });
}
});
// Student / Public System Settings
app.get('/api/settings', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const settings = await prisma.systemSettings.findFirst();
res.json({
trialDurationDays: settings?.trialDurationDays || 7,
helpText: settings?.helpText || '',
enableBoleto: settings?.enableBoleto !== false,
enableInstallmentBoleto: settings?.enableInstallmentBoleto === true,
maxBoletoInstallments: settings?.maxBoletoInstallments || 3,
brandName: settings?.brandName || 'MICROTEC',
brandSlogan: settings?.brandSlogan || 'Acelere seus estudos em informática e pacote Office no estilo Netflix.',
cnpj: settings?.cnpj || '',
address: settings?.address || ''
});
});
// Admin System Settings
app.get('/api/admin/settings', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const settings = await prisma.systemSettings.findFirst();
res.json({
trialDurationDays: settings?.trialDurationDays || 7,
asaasEnvironment: settings?.asaasEnvironment || 'sandbox',
asaasApiKey: settings?.asaasApiKey || '',
asaasWebhookUrl: settings?.asaasWebhookUrl || 'https://admin-estudo.microtecinformaticacurso.com.br/api/webhooks/asaas',
asaasWebhookSecret: settings?.asaasWebhookSecret || 'devflix_webhook_secret_key',
helpText: settings?.helpText || '',
evolutionApiUrl: settings?.evolutionApiUrl || 'https://evolution.microtecinformaticacurso.com.br',
evolutionApiKey: settings?.evolutionApiKey || '',
evolutionInstance: settings?.evolutionInstance || 'microtecflix',
whatsappConnected: settings?.whatsappConnected || false,
whatsappPhoneNumber: settings?.whatsappPhoneNumber || '',
brandName: settings?.brandName || 'MICROTEC',
brandSlogan: settings?.brandSlogan || 'Acelere seus estudos em informática e pacote Office no estilo Netflix.',
cnpj: settings?.cnpj || '',
address: settings?.address || '',
smtpHost: settings?.smtpHost || '',
smtpPort: settings?.smtpPort || 587,
smtpUser: settings?.smtpUser || '',
smtpPass: settings?.smtpPass || '',
smtpFrom: settings?.smtpFrom || 'no-reply@microtecinformaticacurso.com.br',
smtpSecure: settings?.smtpSecure || false
});
});
app.post('/api/admin/settings', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const newSettings = req.body;
const settings = await prisma.systemSettings.findFirst();
if (settings) {
await prisma.systemSettings.update({
where: { id: settings.id },
data: newSettings
});
} else {
await prisma.systemSettings.create({
data: newSettings
});
}
const updatedSettings = await prisma.systemSettings.findFirst();
res.json({ success: true, settings: updatedSettings });
});
app.post('/api/admin/test-asaas', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
try {
const { asaasApiKey, asaasEnvironment } = req.body || {};
const result = await AsaasService.testConnection(asaasApiKey, asaasEnvironment);
res.json(result);
} catch (err: any) {
res.status(500).json({ success: false, message: err.message || 'Erro ao testar conexão Asaas' });
}
});
app.post('/api/admin/test-smtp', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
try {
const { smtpHost, smtpPort, smtpSecure, smtpUser, smtpPass, smtpFrom } = req.body;
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: {
user: smtpUser,
pass: smtpPass
}
});
await transporter.verify();
// Envia um email de teste para o próprio remetente (ou admin)
await transporter.sendMail({
from: smtpFrom,
to: req.user!.email,
subject: 'Teste de Conexão SMTP - MicrotecFlix',
text: 'Se você está recebendo este e-mail, sua configuração SMTP na MicrotecFlix foi realizada com sucesso!'
});
res.json({ success: true, message: 'Conexão estabelecida e e-mail de teste enviado com sucesso!' });
} catch (err: any) {
res.status(500).json({ success: false, message: err.message || 'Erro ao testar conexão SMTP' });
}
});
// --- EVOLUTION API WHATSAPP ---
app.get('/api/admin/whatsapp/qrcode', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
try {
const settings = await prisma.systemSettings.findFirst();
if (!settings || !settings.evolutionApiUrl || !settings.evolutionApiKey) {
return res.status(400).json({ error: 'Credenciais da Evolution API não configuradas.' });
}
const apiUrl = settings.evolutionApiUrl.replace(/\/$/, '');
const instance = settings.evolutionInstance || 'microtecflix';
const apiKey = settings.evolutionApiKey;
const headers = { apikey: apiKey };
// Check connection state
try {
const stateRes = await axios.get(`${apiUrl}/instance/connectionState/${instance}`, { headers });
if (stateRes.data && stateRes.data.instance && stateRes.data.instance.state === 'open') {
if (!settings.whatsappConnected) {
await prisma.systemSettings.update({
where: { id: settings.id },
data: { whatsappConnected: true }
});
}
return res.json({ connected: true, status: 'open' });
}
} catch (err: any) {
if (err.response?.status === 404) {
// Instance does not exist, create it
await axios.post(`${apiUrl}/instance/create`, {
instanceName: instance,
qrcode: true,
integration: 'WHATSAPP-BAILEYS'
}, { headers });
} else {
throw err;
}
}
// Get QR Code
const qrRes = await axios.get(`${apiUrl}/instance/connect/${instance}`, { headers });
// Some versions of evolution return { base64: "...", pairingCode: "..." }
res.json({
connected: false,
qrCode: qrRes.data.base64 || qrRes.data.qrcode || null,
pairingCode: qrRes.data.pairingCode || null
});
} catch (err: any) {
console.error('Erro na Evolution API (QR Code):', err.message);
res.status(500).json({ error: 'Falha ao comunicar com Evolution API.' });
}
});
app.post('/api/admin/whatsapp/logout', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
try {
const settings = await prisma.systemSettings.findFirst();
if (!settings || !settings.evolutionApiUrl || !settings.evolutionApiKey) {
return res.status(400).json({ error: 'Credenciais não configuradas.' });
}
const apiUrl = settings.evolutionApiUrl.replace(/\/$/, '');
const instance = settings.evolutionInstance || 'microtecflix';
const apiKey = settings.evolutionApiKey;
try {
await axios.delete(`${apiUrl}/instance/delete/${instance}`, { headers: { apikey: apiKey } });
} catch (err: any) {
// ignore errors during delete
}
await prisma.systemSettings.update({
where: { id: settings.id },
data: { whatsappConnected: false }
});
res.json({ success: true });
} catch (err: any) {
console.error('Erro na Evolution API (Logout):', err.message);
res.status(500).json({ error: 'Falha ao desconectar Evolution API.' });
}
});
export async function sendWhatsappNotification(phone: string, text: string) {
try {
const settings = await prisma.systemSettings.findFirst();
if (!settings?.whatsappConnected || !settings.evolutionApiUrl || !settings.evolutionApiKey) {
return false; // Not configured or not connected
}
// Clean phone number (leave only digits)
let cleanPhone = phone.replace(/\D/g, '');
if (!cleanPhone) return false;
// Add DDI 55 for Brazil if not present
if (cleanPhone.length === 10 || cleanPhone.length === 11) {
cleanPhone = '55' + cleanPhone;
}
const apiUrl = settings.evolutionApiUrl.replace(/\/$/, '');
const instance = settings.evolutionInstance || 'microtecflix';
await axios.post(`${apiUrl}/message/sendText/${instance}`, {
number: cleanPhone,
text: text,
delay: 1200
}, {
headers: { apikey: settings.evolutionApiKey }
});
return true;
} catch (err) {
console.error('Falha ao enviar WhatsApp:', err);
return false;
}
}
// Admin manually unlock or lock a course for a student
app.post('/api/admin/students/:id/unlock-course', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const { courseId, unlocked } = req.body;
const studentId = req.params.id;
if (!courseId || unlocked === undefined) {
res.status(400).json({ error: 'Parâmetros courseId e unlocked são obrigatórios' });
return;
}
const student = await prisma.user.findUnique({ where: { id: studentId, role: 'student' } });
if (!student) {
res.status(404).json({ error: 'Estudante não encontrado' });
return;
}
let newUnlocked = student.unlockedCourses || [];
if (unlocked) {
if (!newUnlocked.includes(courseId)) {
newUnlocked.push(courseId);
}
} else {
newUnlocked = newUnlocked.filter(id => id !== courseId);
}
await prisma.user.update({
where: { id: studentId },
data: { unlockedCourses: newUnlocked }
});
res.json({ success: true, unlockedCourses: newUnlocked });
});
// 3. CRUD Courses
app.post('/api/admin/courses', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const { title, description, thumbnail, category, isLocked, price } = req.body;
if (!title || !description || !thumbnail || !category) {
res.status(400).json({ error: 'Campos obrigatórios faltando' });
return;
}
const newCourse = await prisma.course.create({
data: {
title,
description,
thumbnail: thumbnail,
category,
isLocked: isLocked === true || isLocked === 'true',
price: price ? Number(price) : 0
}
});
res.status(201).json(newCourse);
});
app.put('/api/admin/courses/:id', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const { title, description, thumbnail, category, isLocked, price } = req.body;
const courseId = req.params.id;
try {
const course = await prisma.course.update({
where: { id: courseId },
data: {
...(title && { title }),
...(description && { description }),
...(thumbnail && { thumbnailUrl: thumbnail }),
...(category && { category }),
...(isLocked !== undefined && { isLocked: isLocked === true || isLocked === 'true' }),
...(price !== undefined && { price: Number(price) })
}
});
res.json(course);
} catch (error) {
res.status(404).json({ error: 'Curso não encontrado' });
}
});
app.delete('/api/admin/courses/:id', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const courseId = req.params.id;
try {
await prisma.course.delete({ where: { id: courseId } });
res.json({ success: true });
} catch (error) {
res.status(404).json({ error: 'Curso não encontrado' });
}
});
// 4. CRUD Modules
app.post('/api/admin/modules', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const { courseId, title, order } = req.body;
if (!courseId || !title) {
res.status(400).json({ error: 'Curso e título são obrigatórios' });
return;
}
const count = await prisma.module.count({ where: { courseId } });
const newModule = await prisma.module.create({
data: {
courseId,
title,
order: order !== undefined ? Number(order) : count + 1
}
});
res.status(201).json(newModule);
});
app.put('/api/admin/modules/:id', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const { title, order } = req.body;
const moduleId = req.params.id;
try {
const updatedModule = await prisma.module.update({
where: { id: moduleId },
data: {
...(title && { title }),
...(order !== undefined && { order: Number(order) })
}
});
res.json(updatedModule);
} catch (error) {
res.status(404).json({ error: 'Módulo não encontrado' });
}
});
app.delete('/api/admin/modules/:id', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const moduleId = req.params.id;
try {
await prisma.module.delete({ where: { id: moduleId } });
res.json({ success: true });
} catch (error) {
res.status(404).json({ error: 'Módulo não encontrado' });
}
});
// 5. CRUD Lessons
app.post('/api/admin/lessons', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const { moduleId, title, videoUrl, videoType, order, content, thumbnailUrl } = req.body;
if (!moduleId || !title || !videoUrl || !videoType) {
res.status(400).json({ error: 'Campos obrigatórios faltando' });
return;
}
const count = await prisma.lesson.count({ where: { moduleId } });
const newLesson = await prisma.lesson.create({
data: {
moduleId,
title,
videoUrl,
videoType,
content: content || '',
thumbnailUrl: thumbnailUrl || '',
order: order !== undefined ? Number(order) : count + 1
}
});
res.status(201).json(newLesson);
});
app.put('/api/admin/lessons/:id', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const { title, videoUrl, videoType, order, content, thumbnailUrl } = req.body;
const lessonId = req.params.id;
try {
const updatedLesson = await prisma.lesson.update({
where: { id: lessonId },
data: {
...(title && { title }),
...(videoUrl && { videoUrl }),
...(videoType && { videoType }),
...(content !== undefined && { content }),
...(thumbnailUrl !== undefined && { thumbnailUrl }),
...(order !== undefined && { order: Number(order) })
}
});
res.json(updatedLesson);
} catch (error) {
res.status(404).json({ error: 'Aula não encontrada' });
}
});
app.delete('/api/admin/lessons/:id', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const lessonId = req.params.id;
try {
await prisma.lesson.delete({ where: { id: lessonId } });
res.json({ success: true });
} catch (error) {
res.status(404).json({ error: 'Aula não encontrada' });
}
});
// Reorder modules or lessons (Drag-and-drop or batch reorder helper)
app.post('/api/admin/reorder', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const { type, items } = req.body;
if (!type || !items || !Array.isArray(items)) {
res.status(400).json({ error: 'Parâmetros inválidos' });
return;
}
try {
if (type === 'modules') {
for (const item of items) {
await prisma.module.update({
where: { id: item.id },
data: { order: Number(item.order) }
});
}
} else if (type === 'lessons') {
for (const item of items) {
await prisma.lesson.update({
where: { id: item.id },
data: { order: Number(item.order) }
});
}
}
res.json({ success: true });
} catch (error) {
console.error('Reorder error:', error);
res.status(500).json({ error: 'Erro ao reordenar' });
}
});
// --- ASAAS WEBHOOK HANDLER ---
app.post('/api/webhooks/asaas', async (req: Request, res: Response) => {
const { event, payment } = req.body;
if (event) {
await prisma.webhookLog.create({
data: {
event: event,
payload: JSON.stringify(req.body)
}
});
}
const settings = await prisma.systemSettings.findFirst();
const configuredSecret = settings?.asaasWebhookSecret || process.env.ASAAS_WEBHOOK_TOKEN || 'devflix_webhook_secret_key';
const tokenHeader = req.headers['asaas-access-token'] || req.headers['asaas-token'] || req.headers['authorization'];
if (configuredSecret && tokenHeader !== configuredSecret) {
console.warn(`[WEBHOOK SEGURANÇA] Token inválido bloqueado: Recebido ${tokenHeader}`);
res.status(401).json({
error: 'Não autorizado - Token de Webhook do Asaas é inválido ou ausente.',
dica_para_o_admin: `O Asaas enviou o token: '${tokenHeader || 'NENHUM'}', mas o servidor esperava o token: '${configuredSecret}'. Por favor, vá no painel do Asaas Sandbox > Integração > Webhooks e coloque exatamente o token esperado no campo 'Token de Acesso (Access Token)'.`
});
return;
}
if (!event || !payment) {
res.status(400).json({ error: 'Formato do Webhook inválido' });
return;
}
const customerId = payment.customer;
const user = await prisma.user.findFirst({ where: { asaasCustomerId: customerId } });
if (user) {
const isPaid = event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED';
const isOverdue = event === 'PAYMENT_OVERDUE';
const isRefunded = event === 'PAYMENT_REFUNDED' || event === 'PAYMENT_DELETED';
const newStatus = isPaid ? 'CONFIRMED' :
isOverdue ? 'OVERDUE' :
isRefunded ? 'REFUNDED' :
event === 'PAYMENT_RESTORED' ? 'PENDING' : null;
let existingPayment = await prisma.payment.findUnique({ where: { id: payment.id } });
if (existingPayment) {
if (newStatus) {
await prisma.payment.update({
where: { id: payment.id },
data: {
status: newStatus as any,
...(isPaid && { paidAt: new Date() })
}
});
}
} else if (!isRefunded) {
const lastPayment = await prisma.payment.findFirst({
where: { userId: user.id },
orderBy: { createdAt: 'desc' }
});
await prisma.payment.create({
data: {
id: payment.id,
userId: user.id,
value: payment.value || 49.90,
status: (newStatus || 'PENDING') as any,
billingType: payment.billingType || 'PIX',
invoiceUrl: payment.invoiceUrl || `https://sandbox.asaas.com/i/${payment.id}`,
dueDate: payment.dueDate ? new Date(payment.dueDate) : new Date(),
paidAt: isPaid ? new Date() : null,
planId: lastPayment?.planId,
courseId: lastPayment?.courseId
}
});
}
const description = payment.description || '';
const isCoursePayment = description.startsWith('unlock_course_');
if (isPaid) {
if (isCoursePayment) {
const courseId = description.replace('unlock_course_', '');
let newUnlocked = user.unlockedCourses || [];
if (!newUnlocked.includes(courseId)) {
newUnlocked.push(courseId);
}
await prisma.user.update({
where: { id: user.id },
data: { unlockedCourses: newUnlocked }
});
console.log(`Webhook: Curso ${courseId} LIBERADO para usuário ${user.email}`);
} else {
await prisma.user.update({
where: { id: user.id },
data: { subscriptionStatus: 'ACTIVE' }
});
console.log(`Webhook: Assinatura LIBERADA para usuário ${user.email}`);
}
} else if (isOverdue || isRefunded || event === 'SUBSCRIPTION_DELETED') {
if (!isCoursePayment) {
await prisma.user.update({
where: { id: user.id },
data: { subscriptionStatus: isOverdue ? 'OVERDUE' : 'CANCELED' }
});
console.log(`Webhook: Assinatura BLOQUEADA para usuário ${user.email} - Motivo: ${event}`);
} else if (isRefunded) {
console.log(`Webhook: Pagamento de Curso REEMBOLSADO/DELETADO para usuário ${user.email}`);
}
}
} else {
console.warn(`Webhook: Cliente Asaas ${customerId} não encontrado no banco local.`);
}
res.status(200).json({ received: true });
});
// --- ACTIVITIES & CERTIFICATES API ENDPOINTS ---
// Admin: Save or update module activity
app.post('/api/admin/modules/:id/activity', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const moduleId = req.params.id;
const { questions } = req.body;
if (!questions || !Array.isArray(questions)) {
res.status(400).json({ error: 'Lista de perguntas inválida' });
return;
}
const existingActivity = await prisma.moduleActivity.findUnique({ where: { moduleId } });
if (existingActivity) {
const updated = await prisma.moduleActivity.update({
where: { moduleId },
data: { questions: questions as any }
});
res.json({ success: true, activity: updated });
} else {
const newActivity = await prisma.moduleActivity.create({
data: {
moduleId,
questions: questions as any
}
});
res.json({ success: true, activity: newActivity });
}
});
// Admin/Student: Get module activity
app.get('/api/modules/:id/activity', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const moduleId = req.params.id;
const activity = await prisma.moduleActivity.findUnique({ where: { moduleId } });
if (!activity) {
res.status(404).json({ error: 'Atividade não encontrada' });
return;
}
if (req.user?.role === 'student') {
const safeQuestions = (activity.questions as any[]).map((q: any) => ({
question: q.question,
options: q.options
}));
res.json({ id: activity.id, moduleId: activity.moduleId, questions: safeQuestions });
} else {
res.json(activity);
}
});
// Admin: Change Course Certificate Mode
app.post('/api/admin/courses/:id/certificate-mode', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const courseId = req.params.id;
const { mode } = req.body;
if (mode !== 'PER_MODULE' && mode !== 'FULL_COURSE') {
res.status(400).json({ error: 'Modo inválido' });
return;
}
try {
const course = await prisma.course.update({
where: { id: courseId },
data: { certificateMode: mode }
});
res.json({ success: true, course });
} catch (error) {
res.status(404).json({ error: 'Curso não encontrado' });
}
});
// Admin: Manually Unlock Certificate
app.post('/api/admin/students/:userId/unlock-certificate', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const { userId } = req.params;
const { type, targetId, courseName, moduleName } = req.body;
const certificate = await prisma.certificate.create({
data: {
userId,
courseId: type === 'COURSE' ? targetId : null,
moduleId: type === 'MODULE' ? targetId : null,
certificateType: type,
finalGrade: 10,
courseName,
moduleName: moduleName || null
}
});
res.json({ success: true, certificate });
});
// Student: Submit Activity Answers
app.post('/api/modules/:id/submit-activity', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const moduleId = req.params.id;
const { answers } = req.body;
const userId = req.user!.id;
const activity = await prisma.moduleActivity.findUnique({ where: { moduleId } });
if (!activity) {
res.status(404).json({ error: 'Atividade não encontrada' });
return;
}
let correctCount = 0;
const questions = activity.questions as any[];
const totalQuestions = questions.length;
questions.forEach((q: any, index: number) => {
if (answers[index] === q.correctIndex) {
correctCount++;
}
});
const score = totalQuestions > 0 ? (correctCount / totalQuestions) * 10 : 0;
const passed = score >= 6;
await prisma.moduleGrade.upsert({
where: {
userId_activityId: { userId, activityId: activity.id }
},
update: { score, passed },
create: { userId, activityId: activity.id, score, passed }
});
const moduleInfo = await prisma.module.findUnique({ where: { id: moduleId }, include: { course: true } });
if (passed && moduleInfo && moduleInfo.course) {
const course = moduleInfo.course;
const mode = course.certificateMode || 'FULL_COURSE';
if (mode === 'PER_MODULE') {
const exists = await prisma.certificate.findFirst({
where: { userId, moduleId, certificateType: 'MODULE' }
});
if (!exists) {
await prisma.certificate.create({
data: {
userId,
courseId: course.id,
moduleId: moduleInfo.id,
certificateType: 'MODULE',
finalGrade: score,
courseName: course.title,
moduleName: moduleInfo.title
}
});
}
} else {
const allCourseModules = await prisma.module.findMany({ where: { courseId: course.id } });
let allPassed = true;
let totalScore = 0;
let evaluatedModules = 0;
for (const m of allCourseModules) {
const act = await prisma.moduleActivity.findUnique({ where: { moduleId: m.id } });
if (act) {
const grade = await prisma.moduleGrade.findUnique({
where: { userId_activityId: { userId, activityId: act.id } }
});
if (!grade || !grade.passed) {
allPassed = false;
break;
}
totalScore += grade.score;
evaluatedModules++;
} else {
const lessons = await prisma.lesson.findMany({ where: { moduleId: m.id } });
for (const lsn of lessons) {
const prog = await prisma.progress.findUnique({
where: { userId_lessonId: { userId, lessonId: lsn.id } }
});
if (!prog || !prog.completed) {
allPassed = false;
break;
}
}
}
}
if (allPassed) {
const exists = await prisma.certificate.findFirst({
where: { userId, courseId: course.id, certificateType: 'COURSE' }
});
if (!exists) {
const finalGrade = evaluatedModules > 0 ? (totalScore / evaluatedModules) : null;
await prisma.certificate.create({
data: {
userId,
courseId: course.id,
certificateType: 'COURSE',
finalGrade,
courseName: course.title
}
});
}
}
}
}
res.json({ success: true, score, passed });
});
// Student: Get my certificates
app.get('/api/certificates', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const myCerts = await prisma.certificate.findMany({ where: { userId: req.user!.id } });
res.json(myCerts);
});
// --- NOTIFICATIONS ---
app.get('/api/notifications', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const myNotifications = await prisma.notification.findMany({
where: { OR: [{ userId: req.user!.id }, { userId: 'ALL' }] },
orderBy: { createdAt: 'desc' }
});
res.json(myNotifications);
});
app.put('/api/notifications/:id/read', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const notification = await prisma.notification.findUnique({ where: { id: req.params.id } });
if (notification && (notification.userId === req.user!.id || notification.userId === 'ALL')) {
await prisma.notification.update({
where: { id: notification.id },
data: { read: true }
});
}
res.json({ success: true });
});
// --- HELP DESK ---
app.get('/api/help', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const settings = await prisma.systemSettings.findFirst();
res.json({ helpText: settings?.helpText || '' });
});
app.put('/api/admin/help', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const settings = await prisma.systemSettings.findFirst();
if (settings) {
await prisma.systemSettings.update({
where: { id: settings.id },
data: { helpText: req.body.helpText }
});
} else {
await prisma.systemSettings.create({ data: { helpText: req.body.helpText } });
}
res.json({ success: true });
});
// --- MODULE ACTIVITIES (ADMIN) ---
app.get('/api/admin/modules/:moduleId/activity', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const activity = await prisma.moduleActivity.findUnique({ where: { moduleId: req.params.moduleId } });
res.json(activity || { moduleId: req.params.moduleId, questions: [] });
});
app.put('/api/admin/modules/:moduleId/activity', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const { moduleId } = req.params;
const { questions } = req.body;
const updated = await prisma.moduleActivity.upsert({
where: { moduleId },
update: { questions: questions as any },
create: { moduleId, questions: questions as any }
});
res.json(updated);
});
// --- PUBLIC/STUDENT PLANS API ---
app.get('/api/plans', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const activePlans = await prisma.plan.findMany({ where: { active: true } });
res.json(activePlans);
});
// --- ADMIN PLANS API ENDPOINTS ---
app.get('/api/admin/plans', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const plans = await prisma.plan.findMany();
res.json(plans);
});
app.post('/api/admin/plans', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
const { title, description, price, cycle, includedCourses, active } = req.body;
const newPlan = await prisma.plan.create({
data: {
title: title || 'Novo Plano',
description: description || '',
price: price || 0,
cycle: cycle || 'LIFETIME',
includedCourses: includedCourses || [],
active: active !== undefined ? active : true
}
});
res.json({ success: true, plan: newPlan });
});
app.put('/api/admin/plans/:id', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
try {
const plan = await prisma.plan.update({
where: { id: req.params.id },
data: req.body
});
res.json({ success: true, plan });
} catch (error) {
res.status(404).json({ error: 'Plano não encontrado' });
}
});
app.delete('/api/admin/plans/:id', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
try {
await prisma.plan.delete({ where: { id: req.params.id } });
res.json({ success: true });
} catch (error) {
res.status(404).json({ error: 'Plano não encontrado' });
}
});
// Update single course price and lock status directly
app.put('/api/admin/courses/:id/price', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
try {
const course = await prisma.course.update({
where: { id: req.params.id },
data: {
price: req.body.price !== undefined ? Number(req.body.price) : undefined,
isLocked: req.body.isLocked
}
});
res.json({ success: true, course });
} catch (error) {
res.status(404).json({ error: 'Curso não encontrado' });
}
});
// --- VITE MIDDLEWARE / SPA FALLBACK ---
async function startServer() {
try {
console.log('🔄 Sincronizando banco de dados com Prisma...');
let retries = 5;
while (retries > 0) {
try {
execSync('npx prisma db push --accept-data-loss', { stdio: 'inherit' });
console.log('✅ Banco de dados sincronizado!');
break;
} catch (err) {
console.error(`⚠️ Erro ao sincronizar banco (tentativas restantes: ${retries - 1})... Aguardando Postgres...`);
retries -= 1;
if (retries === 0) {
console.error('❌ Falha definitiva ao inicializar banco de dados:', err);
// Don't throw, let it attempt anyway (might just be a timeout issue, though count() will probably fail)
} else {
// Wait 3 seconds synchronously using a busy wait or just use a helper
await new Promise(res => setTimeout(res, 3000));
}
}
}
// Seed test users if DB is empty
const adminCount = await prisma.user.count({ where: { email: 'sidney.gomes1989@gmail.com' } });
if (adminCount === 0) {
console.log('🌱 Semeando usuários de teste...');
const defaultPassword = bcrypt.hashSync('admin123', 10);
const studentPassword = bcrypt.hashSync('aluno123', 10);
await prisma.user.createMany({
data: [
{
email: 'sidney.gomes1989@gmail.com',
password: defaultPassword,
name: 'Administrador Sidney',
role: 'admin',
subscriptionStatus: 'ACTIVE',
},
{
email: 'aluno@microtecflix.com',
password: studentPassword,
name: 'Aluno Teste',
role: 'student',
subscriptionStatus: 'ACTIVE',
},
{
email: 'joao@microtecflix.com',
password: studentPassword,
name: 'João Silva (Excel)',
role: 'student',
subscriptionStatus: 'ACTIVE',
},
{
email: 'maria@microtecflix.com',
password: studentPassword,
name: 'Maria Ativa',
role: 'student',
subscriptionStatus: 'ACTIVE',
},
{
email: 'pedro@microtecflix.com',
password: studentPassword,
name: 'Pedro Atrasado',
role: 'student',
subscriptionStatus: 'OVERDUE',
},
{
email: 'ana@microtecflix.com',
password: studentPassword,
name: 'Ana Cancelada',
role: 'student',
subscriptionStatus: 'CANCELED',
}
],
skipDuplicates: true
});
console.log('✅ Usuários de teste semeados com sucesso!');
}
} catch (dbError) {
console.error('❌ Falha ao inicializar banco de dados:', dbError);
}
if (process.env.NODE_ENV !== 'production') {
const { createServer: createViteServer } = await import('vite');
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}`);
});
// Background job to clean up expired PIX payments (older than 5 minutes) and Boleto payments (older than 2 days)
setInterval(async () => {
try {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
// Clean PIX
const expiredPix = await prisma.payment.findMany({
where: {
billingType: 'PIX',
status: 'PENDING',
createdAt: { lt: fiveMinutesAgo }
}
});
for (const payment of expiredPix) {
console.log(`[PIX Expire Job] Expirando e deletando PIX ${payment.id}...`);
const deletedInAsaas = await AsaasService.deletePayment(payment.id);
if (deletedInAsaas) {
await prisma.payment.update({
where: { id: payment.id },
data: { status: 'CANCELED' }
});
console.log(`[PIX Expire Job] PIX ${payment.id} cancelado com sucesso.`);
}
}
// Clean BOLETO
const expiredBoleto = await prisma.payment.findMany({
where: {
billingType: 'BOLETO',
status: 'PENDING',
createdAt: { lt: twoDaysAgo }
}
});
for (const payment of expiredBoleto) {
console.log(`[Boleto Expire Job] Expirando e deletando Boleto ${payment.id}...`);
const deletedInAsaas = await AsaasService.deletePayment(payment.id);
if (deletedInAsaas) {
await prisma.payment.update({
where: { id: payment.id },
data: { status: 'CANCELED' }
});
console.log(`[Boleto Expire Job] Boleto ${payment.id} cancelado com sucesso.`);
}
}
} catch (err: any) {
console.error('[Expire Job] Erro ao limpar pagamentos expirados:', err.message);
}
}, 60 * 1000); // Check every 60 seconds (1 minute)
}
startServer();