Compare commits

...

6 Commits

11 changed files with 2915 additions and 807 deletions

View File

@ -21,6 +21,7 @@ O sistema realiza o roteamento automático do frontend via `src/Root.tsx`:
Para evitar conflitos com outros bancos de dados ativos na VPS (como o PostgreSQL do AgendaPRO na porta `5432`), o MicrotecFlix utiliza uma instância isolada e dedicada de PostgreSQL:
* **Tecnologia ORM**: Prisma ORM (`prisma/schema.prisma`), que governa 100% da persistência de dados. O backend (`server-prisma.ts`) está inteiramente refatorado, sem utilizar mais arquivos `db.json` e nem funções locais (como `loadDb()`). Todas as alterações agora salvam em tabelas.
* **Container**: `postgres-microtecflix`
* **Imagem**: `postgres:16-alpine`
* **Porta Host**: `5435:5432` (evita colisão com a porta 5432 original)

View File

@ -27,14 +27,21 @@ O **MicrotecFlix** é uma plataforma EAD / Streaming de cursos de informática e
## 2. Esquema de Entidades (`prisma/schema.prisma` / PostgreSQL)
Todo o sistema foi **100% migrado para o PostgreSQL** utilizando o **Prisma ORM**. O antigo arquivo `db.json` e a função genérica `loadDb()` foram completamente descartados e removidos da base de código backend (`server-prisma.ts`).
1. **`User`**: Usuários da plataforma (`admin` ou `student`) com status de assinatura (`ACTIVE`, `OVERDUE`, `CANCELED`, `TRIAL`).
2. **`Course`**: Cursos cadastrados com título, descrição, thumbnail, categoria e preço.
2. **`Course`**: Cursos cadastrados com título, descrição, thumbnail, categoria e preço avulso/bloqueio.
3. **`Module`**: Módulos organizados sequencialmente por curso.
4. **`Lesson`**: Aulas vinculadas ao módulo com URL de vídeo (`youtube` ou `direct`) e conteúdo complementar.
5. **`Note`**: Anotações pessoais feitas pelos alunos por aula.
6. **`Progress`**: Histórico de progresso do aluno por aula (% assistida e `completed`).
7. **`Payment`**: Histórico de faturas e cobranças registradas no gateway de pagamentos.
7. **`Payment` (SubscriptionPayment)**: Histórico de faturas e cobranças registradas no gateway de pagamentos, linkado a usuários, cursos e planos.
8. **`WebhookLog`**: Registro auditável de webhooks recebidos do Asaas.
9. **`ModuleActivity` / `ModuleGrade`**: Questionários/Múltipla escolha e pontuações dos alunos, substituindo a antiga matriz JSON de atividades.
10. **`Certificate`**: Certificados ganhos pelos alunos (por Módulo ou por Curso) gerados automaticamente.
11. **`Plan`**: Planos de assinatura (Combos vitálcios, Assinaturas Mensais, Anuais).
12. **`Notification`**: Alertas e avisos aos usuários, gerenciados em tabela relacional em tempo real.
13. **`SystemSettings`**: Configurações dinâmicas do sistema, chaves do Asaas e texto de ajuda.
---

View File

@ -13,6 +13,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@google/genai": "^2.4.0",
"@prisma/client": "^6.4.1",
"multer": "^1.4.5-lts.1",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
@ -35,6 +36,7 @@
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"prisma": "^6.4.1",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",

View File

@ -53,6 +53,11 @@ model User {
role Role @default(student)
subscriptionStatus SubscriptionStatus @default(TRIAL) @map("subscription_status")
asaasCustomerId String? @map("asaas_customer_id")
cpf String?
whatsapp String?
birthDate String? @map("birth_date")
avatarUrl String? @map("avatar_url") @db.Text
trialEndsAt DateTime? @map("trial_ends_at")
createdAt DateTime @default(now()) @map("created_at")
notes Note[]
@ -60,6 +65,7 @@ model User {
payments Payment[]
moduleGrades ModuleGrade[]
certificates Certificate[]
unlockedCourses String[] @default([]) @map("unlocked_courses")
@@map("users")
}
@ -71,6 +77,8 @@ model Course {
thumbnail String
category String
certificateMode CertificateMode @default(FULL_COURSE) @map("certificate_mode")
isLocked Boolean @default(false) @map("is_locked")
price Float?
createdAt DateTime @default(now()) @map("created_at")
modules Module[]
@ -147,6 +155,9 @@ model Payment {
invoiceUrl String @map("invoice_url") @db.Text
dueDate DateTime @map("due_date") @db.Date
paidAt DateTime? @map("paid_at")
planId String? @map("plan_id")
courseId String? @map("course_id")
description String? @db.Text
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@ -205,3 +216,29 @@ model Certificate {
@@map("certificates")
}
model Plan {
id String @id @default(uuid())
title String
description String @db.Text
price Float
cycle String // 'MONTHLY', 'YEARLY', 'LIFETIME'
active Boolean @default(true)
includedCourses String[] @default([]) @map("included_courses")
createdAt DateTime @default(now()) @map("created_at")
@@map("plans")
}
model SystemSettings {
id String @id @default("default")
trialDurationDays Int @default(7) @map("trial_duration_days")
asaasEnvironment String @default("sandbox") @map("asaas_environment")
asaasApiKey String @default("") @map("asaas_api_key")
asaasWebhookUrl String @default("") @map("asaas_webhook_url")
asaasWebhookSecret String @default("") @map("asaas_webhook_secret")
helpText String @db.Text @default("# Central de Ajuda") @map("help_text")
@@map("system_settings")
}

1760
server-json.ts Normal file

File diff suppressed because it is too large Load Diff

1561
server.ts

File diff suppressed because it is too large Load Diff

View File

@ -32,7 +32,7 @@ function CoursePricingCard({ course, saveLoading, onSave }: { course: Course, sa
localLocked ? 'bg-amber-500/20 text-amber-400 border border-amber-500/30' : 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30'
}`}
>
{localLocked ? '💰 Pago (Bloqueado)' : '🔓 Gratuito (Livre)'}
{localLocked ? <span>💰 Pago (Bloqueado)</span> : <span>🔓 Gratuito (Livre)</span>}
</button>
</div>
@ -67,7 +67,7 @@ function CoursePricingCard({ course, saveLoading, onSave }: { course: Course, sa
) : (
<Save className="w-4 h-4" />
)}
Salvar Alteração
<span>Salvar Alteração</span>
</button>
</div>
);
@ -82,7 +82,7 @@ export const AdminPricingManager: React.FC<AdminPricingManagerProps> = ({ course
const [showPlanModal, setShowPlanModal] = useState(false);
const [planForm, setPlanForm] = useState<Partial<Plan>>({ cycle: 'LIFETIME', active: true, includedCourses: [] });
const token = localStorage.getItem('devflix_token');
const token = localStorage.getItem('devflix_admin_token');
const fetchPlans = async () => {
setLoading(true);

View File

@ -220,6 +220,13 @@ export default function Navbar({ user, activeView, setView, onLogout }: NavbarPr
<User className="w-4 h-4" />
<span>Meus dados</span>
</button>
<button
onClick={() => { setView('certificates'); setShowProfileMenu(false); }}
className="w-full px-4 py-2 text-left flex items-center space-x-2 text-sm text-zinc-300 hover:bg-zinc-800 hover:text-white transition-colors"
>
<GraduationCap className="w-4 h-4" />
<span>Meu Certificado</span>
</button>
<button
onClick={() => { setView('help'); setShowProfileMenu(false); }}
className="w-full px-4 py-2 text-left flex items-center space-x-2 text-sm text-zinc-300 hover:bg-zinc-800 hover:text-white transition-colors"

View File

@ -0,0 +1,242 @@
import fs from 'fs';
import path from 'path';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const DATA_DIR = process.env.DATA_DIR || (fs.existsSync('/app/data') ? '/app/data' : process.cwd());
const DB_FILE = path.join(DATA_DIR, 'database.json');
async function main() {
console.log('🔄 Iniciando migração de JSON para PostgreSQL...');
if (!fs.existsSync(DB_FILE)) {
console.error('❌ Arquivo database.json não encontrado!');
return;
}
const data = JSON.parse(fs.readFileSync(DB_FILE, 'utf-8'));
// 1. Settings
console.log('Migrando Settings...');
if (data.settings) {
await prisma.systemSettings.upsert({
where: { id: 'default' },
update: {
trialDurationDays: data.settings.trialDurationDays || 7,
asaasEnvironment: data.settings.asaasEnvironment || 'sandbox',
asaasApiKey: data.settings.asaasApiKey || '',
asaasWebhookUrl: data.settings.asaasWebhookUrl || '',
asaasWebhookSecret: data.settings.asaasWebhookSecret || '',
helpText: data.settings.helpText || '# Central de Ajuda'
},
create: {
id: 'default',
trialDurationDays: data.settings.trialDurationDays || 7,
asaasEnvironment: data.settings.asaasEnvironment || 'sandbox',
asaasApiKey: data.settings.asaasApiKey || '',
asaasWebhookUrl: data.settings.asaasWebhookUrl || '',
asaasWebhookSecret: data.settings.asaasWebhookSecret || '',
helpText: data.settings.helpText || '# Central de Ajuda'
}
});
}
// 2. Users
console.log(`Migrando ${data.users?.length || 0} Users...`);
for (const user of data.users || []) {
await prisma.user.upsert({
where: { id: user.id },
update: {
name: user.name,
email: user.email,
password: user.password,
role: user.role === 'admin' ? 'admin' : 'student',
subscriptionStatus: user.subscriptionStatus,
asaasCustomerId: user.asaasCustomerId,
cpf: user.cpf,
whatsapp: user.whatsapp,
birthDate: user.birthDate,
avatarUrl: user.avatarUrl,
trialEndsAt: user.trialEndsAt ? new Date(user.trialEndsAt) : null,
unlockedCourses: user.unlockedCourses || [],
createdAt: new Date(user.createdAt)
},
create: {
id: user.id,
name: user.name,
email: user.email,
password: user.password,
role: user.role === 'admin' ? 'admin' : 'student',
subscriptionStatus: user.subscriptionStatus,
asaasCustomerId: user.asaasCustomerId,
cpf: user.cpf,
whatsapp: user.whatsapp,
birthDate: user.birthDate,
avatarUrl: user.avatarUrl,
trialEndsAt: user.trialEndsAt ? new Date(user.trialEndsAt) : null,
unlockedCourses: user.unlockedCourses || [],
createdAt: new Date(user.createdAt)
}
});
}
// 3. Courses
console.log(`Migrando ${data.courses?.length || 0} Courses...`);
for (const course of data.courses || []) {
await prisma.course.upsert({
where: { id: course.id },
update: {
title: course.title,
description: course.description,
thumbnail: course.thumbnail,
category: course.category,
certificateMode: course.certificateMode || 'FULL_COURSE',
isLocked: course.isLocked || false,
price: course.price || null,
createdAt: new Date(course.createdAt)
},
create: {
id: course.id,
title: course.title,
description: course.description,
thumbnail: course.thumbnail,
category: course.category,
certificateMode: course.certificateMode || 'FULL_COURSE',
isLocked: course.isLocked || false,
price: course.price || null,
createdAt: new Date(course.createdAt)
}
});
}
// 4. Modules
console.log(`Migrando ${data.modules?.length || 0} Modules...`);
for (const mod of data.modules || []) {
await prisma.module.upsert({
where: { id: mod.id },
update: {
courseId: mod.courseId,
title: mod.title,
order: mod.order,
createdAt: new Date(mod.createdAt)
},
create: {
id: mod.id,
courseId: mod.courseId,
title: mod.title,
order: mod.order,
createdAt: new Date(mod.createdAt)
}
});
}
// 5. Lessons
console.log(`Migrando ${data.lessons?.length || 0} Lessons...`);
for (const lesson of data.lessons || []) {
await prisma.lesson.upsert({
where: { id: lesson.id },
update: {
moduleId: lesson.moduleId,
title: lesson.title,
videoUrl: lesson.videoUrl,
videoType: lesson.videoType === 'youtube' ? 'youtube' : 'direct',
order: lesson.order,
content: lesson.content,
createdAt: new Date(lesson.createdAt)
},
create: {
id: lesson.id,
moduleId: lesson.moduleId,
title: lesson.title,
videoUrl: lesson.videoUrl,
videoType: lesson.videoType === 'youtube' ? 'youtube' : 'direct',
order: lesson.order,
content: lesson.content,
createdAt: new Date(lesson.createdAt)
}
});
}
// 6. Plans
console.log(`Migrando ${data.plans?.length || 0} Plans...`);
for (const plan of data.plans || []) {
await prisma.plan.upsert({
where: { id: plan.id },
update: {
title: plan.title,
description: plan.description || '',
price: plan.price,
cycle: plan.cycle,
active: plan.active,
includedCourses: plan.includedCourses || [],
createdAt: plan.createdAt ? new Date(plan.createdAt) : new Date()
},
create: {
id: plan.id,
title: plan.title,
description: plan.description || '',
price: plan.price,
cycle: plan.cycle,
active: plan.active,
includedCourses: plan.includedCourses || [],
createdAt: plan.createdAt ? new Date(plan.createdAt) : new Date()
}
});
}
// 7. Payments
console.log(`Migrando ${data.payments?.length || 0} Payments...`);
for (const p of data.payments || []) {
let billingType = 'PIX';
if (p.billingType === 'BOLETO') billingType = 'BOLETO';
if (p.billingType === 'CREDIT_CARD') billingType = 'CREDIT_CARD';
let status = 'PENDING';
if (p.status === 'RECEIVED') status = 'RECEIVED';
if (p.status === 'CONFIRMED') status = 'CONFIRMED';
if (p.status === 'OVERDUE') status = 'OVERDUE';
if (p.status === 'REFUNDED') status = 'REFUNDED';
await prisma.payment.upsert({
where: { id: p.id },
update: {
userId: p.userId,
value: p.value,
status: status as any,
billingType: billingType as any,
invoiceUrl: p.invoiceUrl || '',
dueDate: p.dueDate ? new Date(p.dueDate) : new Date(),
paidAt: p.paidAt ? new Date(p.paidAt) : null,
planId: p.planId || null,
courseId: p.courseId || null,
description: p.description || null,
createdAt: p.createdAt ? new Date(p.createdAt) : new Date()
},
create: {
id: p.id,
userId: p.userId,
value: p.value,
status: status as any,
billingType: billingType as any,
invoiceUrl: p.invoiceUrl || '',
dueDate: p.dueDate ? new Date(p.dueDate) : new Date(),
paidAt: p.paidAt ? new Date(p.paidAt) : null,
planId: p.planId || null,
courseId: p.courseId || null,
description: p.description || null,
createdAt: p.createdAt ? new Date(p.createdAt) : new Date()
}
});
}
console.log('✅ Migração finalizada com sucesso!');
}
main()
.catch(e => {
console.error('❌ Erro na migração:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

3
src/db/prisma.ts Normal file
View File

@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();

82
src/routes/userRoutes.ts Normal file
View File

@ -0,0 +1,82 @@
import { Router, Request, Response } from 'express';
import { PrismaClient } from '@prisma/client';
import multer from 'multer';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
// Mock types since we will split these out
interface AuthenticatedRequest extends Request {
user?: any;
}
const prisma = new PrismaClient();
const router = Router();
const upload = multer({ storage: multer.memoryStorage() });
const s3 = new S3Client({
region: 'us-east-1',
endpoint: 'https://s3-estudo.microtecinformaticacurso.com.br',
credentials: {
accessKeyId: 'microtecflix_admin',
secretAccessKey: 'MicrotecFlixS3SecurePass2026!'
},
forcePathStyle: true
});
const authenticateToken = (req: any, res: any, next: any) => {
// Logic will be in server.ts or a middleware file, assuming it's passed down
next();
};
router.put('/profile', 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' });
}
});
router.post('/avatar', 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
}));
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' });
}
});
export default router;