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
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m14s
Details
This commit is contained in:
parent
2288eb2784
commit
d79792898d
44
server.ts
44
server.ts
|
|
@ -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) }))
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue