2413 lines
78 KiB
TypeScript
2413 lines
78 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 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: `
|
|
<div style="background-color: #141414; color: #fff; padding: 40px; font-family: sans-serif; text-align: center;">
|
|
<h1 style="color: #E50914;">${settings?.brandName || 'MICROTEC'}FLIX</h1>
|
|
<h2>Recuperação de Senha</h2>
|
|
<p>Você solicitou a recuperação da sua senha. Clique no botão abaixo para redefini-la (válido por 1 hora).</p>
|
|
<a href="${resetUrl}" style="display: inline-block; padding: 15px 30px; margin: 20px 0; background-color: #E50914; color: #fff; text-decoration: none; font-weight: bold; border-radius: 4px;">Redefinir Senha</a>
|
|
<p style="color: #666; font-size: 12px;">Se você não solicitou, apenas ignore este e-mail.</p>
|
|
</div>
|
|
`
|
|
});
|
|
}
|
|
|
|
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<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, 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<string, number> = {};
|
|
const categoryRevenue: Record<string, number> = {};
|
|
|
|
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', async (req: Request, 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 || '',
|
|
termsOfUse: settings?.termsOfUse || '',
|
|
privacyPolicy: settings?.privacyPolicy || ''
|
|
});
|
|
});
|
|
|
|
// 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,
|
|
termsOfUse: settings?.termsOfUse || '',
|
|
privacyPolicy: settings?.privacyPolicy || ''
|
|
});
|
|
});
|
|
|
|
app.post('/api/admin/settings', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
|
|
const newSettings = req.body;
|
|
|
|
const allowedKeys = [
|
|
'trialDurationDays', 'asaasEnvironment', 'asaasApiKey', 'asaasWebhookUrl',
|
|
'asaasWebhookSecret', 'helpText', 'enableBoleto', 'enableInstallmentBoleto',
|
|
'maxBoletoInstallments', 'evolutionApiUrl', 'evolutionApiKey', 'evolutionInstance',
|
|
'whatsappConnected', 'whatsappPhoneNumber', 'brandName', 'brandSlogan', 'cnpj',
|
|
'address', 'smtpHost', 'smtpPort', 'smtpUser', 'smtpPass', 'smtpFrom', 'smtpSecure',
|
|
'termsOfUse', 'privacyPolicy'
|
|
];
|
|
const cleanedSettings: any = {};
|
|
for (const key of allowedKeys) {
|
|
if (newSettings[key] !== undefined) {
|
|
cleanedSettings[key] = newSettings[key];
|
|
}
|
|
}
|
|
|
|
const settings = await prisma.systemSettings.findFirst();
|
|
if (settings) {
|
|
await prisma.systemSettings.update({
|
|
where: { id: settings.id },
|
|
data: cleanedSettings
|
|
});
|
|
} else {
|
|
await prisma.systemSettings.create({
|
|
data: cleanedSettings
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// --- Suporte / Mensagens ---
|
|
|
|
// Aluno envia um chamado de suporte/ajuda
|
|
app.post('/api/support-tickets', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
|
try {
|
|
const { content } = req.body;
|
|
if (!content) return res.status(400).json({ error: 'Conteúdo é obrigatório' });
|
|
|
|
const ticket = await prisma.supportTicket.create({
|
|
data: {
|
|
userId: req.user!.id,
|
|
content,
|
|
}
|
|
});
|
|
|
|
res.status(201).json(ticket);
|
|
} catch (error) {
|
|
console.error('Error creating support ticket:', error);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
// Admin puxa todos os chamados e anotações
|
|
app.get('/api/admin/messages', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
|
|
try {
|
|
const tickets = await prisma.supportTicket.findMany({
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true, avatarUrl: true, whatsapp: true } }
|
|
},
|
|
orderBy: { createdAt: 'desc' }
|
|
});
|
|
|
|
const notes = await prisma.note.findMany({
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true, avatarUrl: true, whatsapp: true } },
|
|
lesson: { select: { title: true, module: { select: { course: { select: { title: true } } } } } }
|
|
},
|
|
orderBy: { createdAt: 'desc' }
|
|
});
|
|
|
|
res.json({ tickets, notes });
|
|
} catch (error) {
|
|
console.error('Error fetching admin messages:', error);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
// Admin responde um chamado ou anotação
|
|
app.post('/api/admin/reply-message', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
|
|
try {
|
|
const { userId, message, ticketId } = req.body;
|
|
if (!userId || !message) return res.status(400).json({ error: 'userId e message são obrigatórios' });
|
|
|
|
// 1. Criar notificação no portal (Sininho)
|
|
await prisma.notification.create({
|
|
data: {
|
|
userId,
|
|
title: 'Nova mensagem de Suporte',
|
|
message,
|
|
type: 'info'
|
|
}
|
|
});
|
|
|
|
if (ticketId) {
|
|
await prisma.supportTicket.update({
|
|
where: { id: ticketId },
|
|
data: { status: 'REPLIED' }
|
|
});
|
|
}
|
|
|
|
// 2. Enviar WhatsApp via Evolution API se configurado
|
|
const settings = await prisma.systemSettings.findUnique({ where: { id: 'default' } });
|
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
|
|
|
if (settings?.whatsappConnected && settings.evolutionApiUrl && settings.evolutionApiKey && user?.whatsapp) {
|
|
try {
|
|
const cleanPhone = user.whatsapp.replace(/\D/g, '');
|
|
if (cleanPhone.length >= 10) {
|
|
const numberId = `${cleanPhone}@s.whatsapp.net`;
|
|
const url = `${settings.evolutionApiUrl}/message/sendText/${settings.evolutionInstance}`;
|
|
await axios.post(url, {
|
|
number: numberId,
|
|
text: message,
|
|
delay: 1200,
|
|
linkPreview: true
|
|
}, {
|
|
headers: {
|
|
'apikey': settings.evolutionApiKey,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
console.log(`WhatsApp reply sent to ${cleanPhone}`);
|
|
}
|
|
} catch (waError: any) {
|
|
console.error('Failed to send WhatsApp reply:', waError?.response?.data || waError.message);
|
|
}
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Error replying to message:', error);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
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));
|
|
|
|
// Fallback para SPA - Isso DEVE estar no final
|
|
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();
|