feat: Introduce announcements service, integrate MinIO for lesson media with presigned URLs, and restrict editing of pending courses.
This commit is contained in:
parent
5f8e0a3687
commit
bbfb62093e
5 changed files with 145 additions and 4 deletions
|
|
@ -2,7 +2,7 @@ import { prisma } from '../config/database';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { config } from '../config';
|
import { config } from '../config';
|
||||||
import { logger } from '../config/logger';
|
import { logger } from '../config/logger';
|
||||||
import { deleteFile, generateFilePath, uploadFile } from '../config/minio';
|
import { deleteFile, generateFilePath, uploadFile, getPresignedUrl, listObjects, getVideoFolder, getAttachmentsFolder } from '../config/minio';
|
||||||
import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler';
|
import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import {
|
import {
|
||||||
|
|
@ -333,7 +333,90 @@ export class ChaptersLessonService {
|
||||||
throw new ForbiddenError('This lesson is not available yet');
|
throw new ForbiddenError('This lesson is not available yet');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { code: 200, message: 'Lesson fetched successfully', data: lesson as LessonData };
|
// Get video URL from MinIO
|
||||||
|
let video_url: string | null = null;
|
||||||
|
try {
|
||||||
|
const videoPrefix = getVideoFolder(course_id, lesson_id);
|
||||||
|
const videoFiles = await listObjects(videoPrefix);
|
||||||
|
if (videoFiles.length > 0) {
|
||||||
|
video_url = await getPresignedUrl(videoFiles[0].name, 3600);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Failed to get video from MinIO: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get attachments with presigned URLs from MinIO
|
||||||
|
const attachmentsWithUrls: {
|
||||||
|
id: number;
|
||||||
|
lesson_id: number;
|
||||||
|
file_name: string;
|
||||||
|
file_path: string;
|
||||||
|
file_size: number;
|
||||||
|
mime_type: string;
|
||||||
|
sort_order: number;
|
||||||
|
created_at: Date;
|
||||||
|
presigned_url: string | null;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const attachmentsPrefix = getAttachmentsFolder(course_id, lesson_id);
|
||||||
|
const attachmentFiles = await listObjects(attachmentsPrefix);
|
||||||
|
|
||||||
|
for (const file of attachmentFiles) {
|
||||||
|
let presigned_url: string | null = null;
|
||||||
|
try {
|
||||||
|
presigned_url = await getPresignedUrl(file.name, 3600);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Failed to generate presigned URL for ${file.name}: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract filename from path
|
||||||
|
const fileName = file.name.split('/').pop() || file.name;
|
||||||
|
// Guess mime type from extension
|
||||||
|
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||||
|
const mimeTypes: { [key: string]: string } = {
|
||||||
|
'pdf': 'application/pdf',
|
||||||
|
'doc': 'application/msword',
|
||||||
|
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'ppt': 'application/vnd.ms-powerpoint',
|
||||||
|
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
'xls': 'application/vnd.ms-excel',
|
||||||
|
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'png': 'image/png',
|
||||||
|
'jpg': 'image/jpeg',
|
||||||
|
'jpeg': 'image/jpeg',
|
||||||
|
'gif': 'image/gif',
|
||||||
|
'mp4': 'video/mp4',
|
||||||
|
'zip': 'application/zip',
|
||||||
|
};
|
||||||
|
const mime_type = mimeTypes[ext] || 'application/octet-stream';
|
||||||
|
|
||||||
|
// Find matching attachment from database or create entry
|
||||||
|
const dbAttachment = lesson.attachments?.find(a => a.file_path === file.name);
|
||||||
|
attachmentsWithUrls.push({
|
||||||
|
id: dbAttachment?.id || 0,
|
||||||
|
lesson_id: lesson_id,
|
||||||
|
file_name: fileName,
|
||||||
|
file_path: file.name,
|
||||||
|
file_size: file.size,
|
||||||
|
mime_type: dbAttachment?.mime_type || mime_type,
|
||||||
|
sort_order: dbAttachment?.sort_order || attachmentsWithUrls.length,
|
||||||
|
created_at: dbAttachment?.created_at || file.lastModified,
|
||||||
|
presigned_url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Failed to list attachments from MinIO: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response with MinIO URLs
|
||||||
|
const lessonData = {
|
||||||
|
...lesson,
|
||||||
|
video_url,
|
||||||
|
attachments: attachmentsWithUrls.length > 0 ? attachmentsWithUrls : lesson.attachments,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { code: 200, message: 'Lesson fetched successfully', data: lessonData as LessonData };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error fetching lesson: ${error}`);
|
logger.error(`Error fetching lesson: ${error}`);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
|
||||||
|
|
@ -352,7 +352,7 @@ export class CoursesInstructorService {
|
||||||
if (!course) {
|
if (!course) {
|
||||||
throw new NotFoundError('Course not found');
|
throw new NotFoundError('Course not found');
|
||||||
}
|
}
|
||||||
if (course.status === 'APPROVED') {
|
if (course.status === 'APPROVED' || course.status === 'PENDING') {
|
||||||
throw new ForbiddenError('Course is already approved Cannot Edit');
|
throw new ForbiddenError('Course is already approved Cannot Edit');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
0
Backend/src/services/announcements.service.ts
Normal file
0
Backend/src/services/announcements.service.ts
Normal file
|
|
@ -19,9 +19,10 @@ export interface LessonAttachmentData {
|
||||||
file_path: string;
|
file_path: string;
|
||||||
file_size: number;
|
file_size: number;
|
||||||
mime_type: string;
|
mime_type: string;
|
||||||
description: MultiLanguageText | null;
|
description?: MultiLanguageText | null;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
|
presigned_url?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuizData {
|
export interface QuizData {
|
||||||
|
|
@ -68,6 +69,7 @@ export interface LessonData {
|
||||||
is_published: boolean;
|
is_published: boolean;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
updated_at: Date | null;
|
updated_at: Date | null;
|
||||||
|
video_url?: string | null;
|
||||||
attachments?: LessonAttachmentData[];
|
attachments?: LessonAttachmentData[];
|
||||||
quiz?: QuizData | null;
|
quiz?: QuizData | null;
|
||||||
progress?: LessonProgressData[];
|
progress?: LessonProgressData[];
|
||||||
|
|
|
||||||
56
Backend/src/types/announcements.types.ts
Normal file
56
Backend/src/types/announcements.types.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { MultiLanguageText } from './index';
|
||||||
|
|
||||||
|
// Use MultiLanguageText from index.ts for consistency
|
||||||
|
export type MultiLangText = MultiLanguageText;
|
||||||
|
|
||||||
|
export interface Announcement {
|
||||||
|
id: number;
|
||||||
|
title: MultiLangText;
|
||||||
|
content: MultiLangText;
|
||||||
|
status: string;
|
||||||
|
is_pinned: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
attachments: AnnouncementAttachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnouncementAttachment {
|
||||||
|
id: number;
|
||||||
|
announcement_id: number;
|
||||||
|
file_name: string;
|
||||||
|
file_path: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListAnnouncementResponse{
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: Announcement[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListAnnouncementInput{
|
||||||
|
token: string;
|
||||||
|
course_id: number;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAnnouncementInput{
|
||||||
|
token: string;
|
||||||
|
course_id: number;
|
||||||
|
title: MultiLangText;
|
||||||
|
content: MultiLangText;
|
||||||
|
status: string;
|
||||||
|
is_pinned: boolean;
|
||||||
|
attachments?: AnnouncementAttachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAnnouncementResponse{
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: Announcement;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue