From 66671f65c39f13d82329a6ee901cb38ccfe7d233 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Fri, 4 Sep 2026 15:18:10 -0300 Subject: [PATCH] feat: delete lesson videos from Bunny --- pages/ManageCourses.tsx | 31 ++++++++++++++++++++++++++----- server/src/providers/bunny.ts | 4 ++++ server/src/routes/media.ts | 12 ++++++++++++ services/api.ts | 3 +++ 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/pages/ManageCourses.tsx b/pages/ManageCourses.tsx index db28756..cd0fb0a 100644 --- a/pages/ManageCourses.tsx +++ b/pages/ManageCourses.tsx @@ -93,6 +93,7 @@ const CourseModal: React.FC<{ const [bunnyConfigured, setBunnyConfigured] = useState(null); const [bunnyUploadState, setBunnyUploadState] = useState<'idle' | 'uploading' | 'uploaded' | 'error'>('idle'); const [bunnyUploadMessage, setBunnyUploadMessage] = useState(''); + const [deletingVideoId, setDeletingVideoId] = useState(null); // Tips & Global Attachments const [tips, setTips] = useState([]); @@ -340,8 +341,26 @@ const CourseModal: React.FC<{ setBunnyUploadMessage(''); }; - const removeLesson = (id: string) => { - setLessons(lessons.filter(l => l.id !== id)); + const removeLesson = async (lesson: Lesson) => { + const video = lesson.media?.find((media) => media.provider === 'bunny'); + const message = video + ? 'Excluir esta aula e o vídeo enviado? O vídeo será removido permanentemente e esta ação não poderá ser desfeita.' + : 'Excluir esta aula? Esta ação não poderá ser desfeita após salvar o curso.'; + if (!window.confirm(message)) return; + + if (video) { + setDeletingVideoId(video.externalId); + try { + await instructorApi.deleteBunnyVideo(video.externalId); + } catch (error) { + alert(error instanceof Error ? error.message : 'Não foi possível excluir o vídeo. Tente novamente.'); + return; + } finally { + setDeletingVideoId(null); + } + } + + setLessons((current) => current.filter((item) => item.id !== lesson.id)); }; const toggleLessonAccess = (id: string) => { @@ -778,10 +797,12 @@ const CourseModal: React.FC<{ diff --git a/server/src/providers/bunny.ts b/server/src/providers/bunny.ts index 2a90173..5f5e6fd 100644 --- a/server/src/providers/bunny.ts +++ b/server/src/providers/bunny.ts @@ -114,6 +114,10 @@ export async function getBunnyVideo(videoId: string) { return toVideo(await response.json() as BunnyApiVideo); } +export async function deleteBunnyVideo(videoId: string) { + await bunnyRequest(`/videos/${encodeURIComponent(videoId)}`, { method: 'DELETE' }); +} + export function signedBunnyEmbedUrl(videoId: string, validitySeconds = config.BUNNY_EMBED_TOKEN_TTL_SECONDS) { const { libraryId, embedTokenKey } = getBunnyConfiguration(); const expires = Math.floor(Date.now() / 1000) + validitySeconds; diff --git a/server/src/routes/media.ts b/server/src/routes/media.ts index 2f99864..630e65e 100644 --- a/server/src/routes/media.ts +++ b/server/src/routes/media.ts @@ -6,6 +6,7 @@ import { BunnyConfigurationError, BunnyRequestError, createBunnyVideo, + deleteBunnyVideo, getBunnyVideo, isBunnyConfigured, uploadBunnyVideo, @@ -123,4 +124,15 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return providerError(reply, error); } }); + + app.delete('/bunny/videos/:videoId', manageAccess, async (request, reply) => { + const { videoId } = videoParamsSchema.parse(request.params); + try { + await deleteBunnyVideo(videoId); + await recordAudit({ actorId: request.user.id, action: 'media.video.deleted', subjectType: 'video', subjectId: videoId, ipAddress: request.ip }); + return reply.code(204).send(); + } catch (error) { + return providerError(reply, error); + } + }); }; diff --git a/services/api.ts b/services/api.ts index 973011c..cb08b10 100644 --- a/services/api.ts +++ b/services/api.ts @@ -150,6 +150,9 @@ export const instructorApi = { async bunnyVideoStatus(videoId: string) { return apiRequest<{ data: BunnyVideo }>(`/manage/media/bunny/videos/${videoId}`); }, + async deleteBunnyVideo(videoId: string) { + return apiRequest(`/manage/media/bunny/videos/${videoId}`, { method: 'DELETE' }); + }, async coverConfiguration() { return apiRequest<{ data: { configured: boolean; maxUploadBytes: number } }>('/manage/media/covers/config'); }, async uploadCover(file: File) { return uploadImageRequest<{ data: { key: string; coverImageUrl: string } }>('/manage/media/covers', file); }, };