fix: decimal conversion and add image upload button for courses directly to MinIO S3
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m14s Details

This commit is contained in:
Sidney Gomes 2026-07-23 10:41:30 -03:00
parent 2288eb2784
commit d79792898d
2 changed files with 90 additions and 14 deletions

View File

@ -7,9 +7,15 @@ import axios from 'axios';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import { S3Client, PutObjectCommand, CreateBucketCommand, HeadBucketCommand, PutBucketPolicyCommand } from '@aws-sdk/client-s3'; import { S3Client, PutObjectCommand, CreateBucketCommand, HeadBucketCommand, PutBucketPolicyCommand } from '@aws-sdk/client-s3';
import { prisma } from './src/db/prisma'; import { prisma } from './src/db/prisma';
import { Prisma } from '@prisma/client';
import { AsaasService } from './src/services/asaas'; import { AsaasService } from './src/services/asaas';
import { User, Course, Module, Lesson, Note, LessonProgress, WebhookLog, SubscriptionPayment, SubscriptionStatus } from './src/types'; 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 // JWT Secret Key
const JWT_SECRET = process.env.JWT_SECRET || 'devflix_secret_token_key_13579'; 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) // 4. Courses List (With Progress)
app.get('/api/courses', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { app.get('/api/courses', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const userId = req.user!.id; 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; if (latestSubscription.plan.cycle === 'MONTHLY') mrr += latestSubscription.plan.price;
else if (latestSubscription.plan.cycle === 'YEARLY') mrr += (latestSubscription.plan.price / 12); else if (latestSubscription.plan.cycle === 'YEARLY') mrr += (latestSubscription.plan.price / 12);
} else if (!latestSubscription.courseId) { } 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 // Total Revenue Calculation
const totalRevenue = allPayments const totalRevenue = allPayments
.filter(p => p.status === 'CONFIRMED' || p.status === 'RECEIVED') .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) // Generate Revenue Data for Charts (Last 30 Days)
const thirtyDaysAgo = new Date(); const thirtyDaysAgo = new Date();
@ -835,7 +867,7 @@ app.get('/api/admin/dashboard', authenticateToken, requireAdmin, async (req: Aut
for (const pay of recentConfirmedPayments) { for (const pay of recentConfirmedPayments) {
const dateStr = pay.createdAt.toISOString().split('T')[0]; 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'; let category = 'Desconhecido';
if (pay.courseId && pay.course) { if (pay.courseId && pay.course) {
@ -845,7 +877,7 @@ app.get('/api/admin/dashboard', authenticateToken, requireAdmin, async (req: Aut
} else { } else {
category = 'Assinatura Legado'; 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 => ({ 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, trialEndsAt: u.trialEndsAt,
totalPaid: u.payments totalPaid: u.payments
.filter(p => p.status === 'CONFIRMED' || p.status === 'RECEIVED') .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 || [], unlockedCourses: u.unlockedCourses || [],
moduleGrades: u.moduleGrades, moduleGrades: u.moduleGrades,
certificates: u.certificates, certificates: u.certificates,
payments: u.payments payments: u.payments.map(p => ({ ...p, value: Number(p.value) }))
}; };
}); });

View File

@ -68,6 +68,38 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
const [activeModuleForActivity, setActiveModuleForActivity] = useState<string | null>(null); const [activeModuleForActivity, setActiveModuleForActivity] = useState<string | null>(null);
const [activityQuestions, setActivityQuestions] = useState<{question: string, options: string[], correctIndex: number}[]>([]); const [activityQuestions, setActivityQuestions] = useState<{question: string, options: string[], correctIndex: number}[]>([]);
const [uploadingImage, setUploadingImage] = useState(false);
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
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(() => { useEffect(() => {
fetchCourses(); fetchCourses();
}, []); }, []);
@ -449,14 +481,26 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
<div className="md:col-span-2 space-y-2"> <div className="md:col-span-2 space-y-2">
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider">Imagem de Capa (Thumbnail URL)</label> <label className="text-xs text-zinc-400 font-bold uppercase tracking-wider">Imagem de Capa (Thumbnail URL)</label>
<input <div className="flex gap-2">
type="url" <input
required type="url"
value={courseForm.thumbnail} required
onChange={(e) => setCourseForm({ ...courseForm, thumbnail: e.target.value })} value={courseForm.thumbnail}
placeholder="https://images.unsplash.com/... ou link de imagem direta" onChange={(e) => setCourseForm({ ...courseForm, thumbnail: e.target.value })}
className="w-full glass-input rounded-lg p-3 text-sm text-white focus:outline-none" 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"
/>
<label className="bg-zinc-800 hover:bg-zinc-700 text-white text-xs font-bold py-3 px-4 rounded-lg border border-white/10 transition-all flex items-center justify-center space-x-1.5 cursor-pointer select-none">
{uploadingImage ? 'Enviando...' : 'Upload S3'}
<input
type="file"
accept="image/*"
onChange={handleImageUpload}
disabled={uploadingImage}
className="hidden"
/>
</label>
</div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">