diff --git a/server.ts b/server.ts index 42645d2..51ebe0c 100644 --- a/server.ts +++ b/server.ts @@ -7,9 +7,15 @@ import axios from 'axios'; import { execSync } from 'child_process'; import { S3Client, PutObjectCommand, CreateBucketCommand, HeadBucketCommand, PutBucketPolicyCommand } from '@aws-sdk/client-s3'; 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'; @@ -289,6 +295,32 @@ app.post('/api/users/avatar', authenticateToken, upload.single('avatar'), async } }); +// 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; @@ -812,7 +844,7 @@ app.get('/api/admin/dashboard', authenticateToken, requireAdmin, async (req: Aut 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 += latestSubscription.value; + mrr += Number(latestSubscription.value); } } } @@ -820,7 +852,7 @@ app.get('/api/admin/dashboard', authenticateToken, requireAdmin, async (req: Aut // Total Revenue Calculation const totalRevenue = allPayments .filter(p => p.status === 'CONFIRMED' || p.status === 'RECEIVED') - .reduce((sum, p) => sum + p.value, 0); + .reduce((sum, p) => sum + Number(p.value), 0); // Generate Revenue Data for Charts (Last 30 Days) const thirtyDaysAgo = new Date(); @@ -835,7 +867,7 @@ app.get('/api/admin/dashboard', authenticateToken, requireAdmin, async (req: Aut for (const pay of recentConfirmedPayments) { const dateStr = pay.createdAt.toISOString().split('T')[0]; - revenueByDay[dateStr] = (revenueByDay[dateStr] || 0) + pay.value; + revenueByDay[dateStr] = (revenueByDay[dateStr] || 0) + Number(pay.value); let category = 'Desconhecido'; if (pay.courseId && pay.course) { @@ -845,7 +877,7 @@ app.get('/api/admin/dashboard', authenticateToken, requireAdmin, async (req: Aut } else { category = 'Assinatura Legado'; } - categoryRevenue[category] = (categoryRevenue[category] || 0) + pay.value; + categoryRevenue[category] = (categoryRevenue[category] || 0) + Number(pay.value); } const revenueData = Object.keys(revenueByDay).sort().map(date => ({ @@ -933,11 +965,11 @@ app.get('/api/admin/students', authenticateToken, requireAdmin, async (req: Auth trialEndsAt: u.trialEndsAt, totalPaid: u.payments .filter(p => p.status === 'CONFIRMED' || p.status === 'RECEIVED') - .reduce((acc, curr) => acc + curr.value, 0), + .reduce((acc, curr) => acc + Number(curr.value), 0), unlockedCourses: u.unlockedCourses || [], moduleGrades: u.moduleGrades, certificates: u.certificates, - payments: u.payments + payments: u.payments.map(p => ({ ...p, value: Number(p.value) })) }; }); diff --git a/src/components/AdminContentManager.tsx b/src/components/AdminContentManager.tsx index 5642c29..fe134a9 100644 --- a/src/components/AdminContentManager.tsx +++ b/src/components/AdminContentManager.tsx @@ -68,6 +68,38 @@ export default function AdminContentManager({ token }: AdminContentManagerProps) const [activeModuleForActivity, setActiveModuleForActivity] = useState(null); const [activityQuestions, setActivityQuestions] = useState<{question: string, options: string[], correctIndex: number}[]>([]); + const [uploadingImage, setUploadingImage] = useState(false); + + const handleImageUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const formData = new FormData(); + formData.append('file', file); + + setUploadingImage(true); + try { + const res = await fetch('/api/admin/upload', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }); + const data = await res.json(); + if (res.ok && data.url) { + setCourseForm(prev => ({ ...prev, thumbnail: data.url })); + } else { + alert(data.error || 'Erro ao fazer upload da imagem'); + } + } catch (err) { + console.error(err); + alert('Erro de conexão ao fazer upload'); + } finally { + setUploadingImage(false); + } + }; + useEffect(() => { fetchCourses(); }, []); @@ -449,14 +481,26 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
- setCourseForm({ ...courseForm, thumbnail: e.target.value })} - placeholder="https://images.unsplash.com/... ou link de imagem direta" - className="w-full glass-input rounded-lg p-3 text-sm text-white focus:outline-none" - /> +
+ setCourseForm({ ...courseForm, thumbnail: e.target.value })} + placeholder="https://images.unsplash.com/... ou link de imagem direta" + className="flex-1 glass-input rounded-lg p-3 text-sm text-white focus:outline-none" + /> + +