import { prisma } from '../config/database'; import { config } from '../config'; import { logger } from '../config/logger'; import { UnauthorizedError, ForbiddenError, NotFoundError } from '../middleware/errorHandler'; import jwt from 'jsonwebtoken'; import { ListAnnouncementResponse, CreateAnnouncementInput, CreateAnnouncementResponse, ListAnnouncementInput, UpdateAnnouncementInput, UpdateAnnouncementResponse, DeleteAnnouncementAttachmentInput, DeleteAnnouncementAttachmentResponse, UploadAnnouncementAttachmentInput, UploadAnnouncementAttachmentResponse, DeleteAnnouncementInput, DeleteAnnouncementResponse, Announcement, } from '../types/announcements.types'; import { CoursesInstructorService } from './CoursesInstructor.service'; import { uploadFile, deleteFile, getPresignedUrl } from '../config/minio'; export class AnnouncementsService { /** * ดึงรายการประกาศของคอร์ส (ใช้ได้ทั้ง instructor, admin, student) * List announcements for a course (accessible by instructor, admin, student) */ async listAnnouncement(input: ListAnnouncementInput): Promise { try { const { token, course_id, page = 1, limit = 10 } = input; const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; // Check user access - instructor, admin, or enrolled student const user = await prisma.user.findUnique({ where: { id: decoded.id }, include: { role: true }, }); if (!user) { throw new UnauthorizedError('Invalid token'); } // Admin can access all courses const isAdmin = user.role.code === 'ADMIN'; // Check if instructor of this course const isInstructor = await prisma.courseInstructor.findFirst({ where: { course_id, user_id: decoded.id }, }); // Check if enrolled student const isEnrolled = await prisma.enrollment.findFirst({ where: { course_id, user_id: decoded.id }, }); if (!isAdmin && !isInstructor && !isEnrolled) throw new ForbiddenError('You do not have access to this course announcements'); const skip = (page - 1) * limit; // Students only see PUBLISHED announcements with published_at <= now const now = new Date(); const whereClause: any = { course_id }; if (!(isAdmin || isInstructor)) { // Students: only show PUBLISHED and published_at <= now whereClause.status = 'PUBLISHED'; whereClause.published_at = { lte: now }; } const [announcements, total] = await Promise.all([ prisma.announcement.findMany({ where: whereClause, include: { attachments: true, }, orderBy: [ { is_pinned: 'desc' }, { created_at: 'desc' }, ], skip, take: limit, }), prisma.announcement.count({ where: whereClause }), ]); // Generate presigned URLs for attachments const announcementsWithUrls = await Promise.all( announcements.map(async (a) => { const attachmentsWithUrls = await Promise.all( a.attachments.map(async (att) => { let presigned_url: string | null = null; try { presigned_url = await getPresignedUrl(att.file_path, 3600); } catch (err) { logger.warn(`Failed to generate presigned URL for ${att.file_path}: ${err}`); } return { id: att.id, announcement_id: att.announcement_id, file_name: att.file_name, file_path: att.file_path, presigned_url, created_at: att.created_at, updated_at: att.created_at, }; }) ); return { id: a.id, title: a.title as { th: string; en: string }, content: a.content as { th: string; en: string }, status: a.status, is_pinned: a.is_pinned, published_at: a.published_at, created_at: a.created_at, updated_at: a.updated_at, attachments: attachmentsWithUrls, }; }) ); return { code: 200, message: 'Announcements retrieved successfully', data: announcementsWithUrls, total, page, limit, }; } catch (error) { logger.error(`Error listing announcements: ${error}`); throw error; } } /** * สร้างประกาศใหม่ (พร้อมอัปโหลดไฟล์แนบ) * Create a new announcement with optional file attachments */ async createAnnouncement(input: CreateAnnouncementInput): Promise { try { const { token, course_id, title, content, status, is_pinned, published_at, files } = input; const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; // Validate instructor access await CoursesInstructorService.validateCourseInstructor(token, course_id); // Determine published_at: use provided value or default to now if status is PUBLISHED let finalPublishedAt: Date | null = null; if (status === 'PUBLISHED') { finalPublishedAt = published_at ? new Date(published_at) : new Date(); } // Create announcement const announcement = await prisma.announcement.create({ data: { course_id, title, content, status: status as any, is_pinned, published_at: finalPublishedAt, created_by: decoded.id, }, }); // Upload attachments if provided const attachments: { id: number; announcement_id: number; file_name: string; file_path: string; created_at: Date; updated_at: Date; }[] = []; if (files && files.length > 0) { for (const file of files) { const timestamp = Date.now(); const uniqueId = Math.random().toString(36).substring(2, 15); const fileName = file.originalname || 'file'; const extension = fileName.split('.').pop() || ''; const safeFilename = `${timestamp}-${uniqueId}.${extension}`; const filePath = `courses/${course_id}/announcements/${announcement.id}/${safeFilename}`; // Upload to MinIO await uploadFile(filePath, file.buffer, file.mimetype || 'application/octet-stream'); // Create attachment record const attachment = await prisma.announcementAttachment.create({ data: { announcement_id: announcement.id, file_name: fileName, file_path: filePath, file_size: file.size, mime_type: file.mimetype || 'application/octet-stream', }, }); attachments.push({ id: attachment.id, announcement_id: attachment.announcement_id, file_name: attachment.file_name, file_path: attachment.file_path, created_at: attachment.created_at, updated_at: attachment.created_at, }); } } return { code: 201, message: 'Announcement created successfully', data: { id: announcement.id, title: announcement.title as { th: string; en: string }, content: announcement.content as { th: string; en: string }, status: announcement.status, is_pinned: announcement.is_pinned, published_at: announcement.published_at, created_at: announcement.created_at, updated_at: announcement.updated_at, attachments, }, }; } catch (error) { logger.error(`Error creating announcement: ${error}`); throw error; } } /** * อัปเดตประกาศ * Update an announcement */ async updateAnnouncement(input: UpdateAnnouncementInput): Promise { try { const { token, course_id, announcement_id, title, content, status, is_pinned, published_at } = input; const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; // Validate instructor access await CoursesInstructorService.validateCourseInstructor(token, course_id); // Check announcement exists and belongs to course const existing = await prisma.announcement.findFirst({ where: { id: announcement_id, course_id }, }); if (!existing) { throw new NotFoundError('Announcement not found'); } // Determine published_at let finalPublishedAt: Date | null = existing.published_at; if (status === 'PUBLISHED') { if (published_at) { finalPublishedAt = new Date(published_at); } else if (existing.status !== 'PUBLISHED') { finalPublishedAt = new Date(); } } const announcement = await prisma.announcement.update({ where: { id: announcement_id }, data: { title, content, status: status as any, is_pinned, published_at: finalPublishedAt, updated_by: decoded.id, }, include: { attachments: true, }, }); return { code: 200, message: 'Announcement updated successfully', data: { id: announcement.id, title: announcement.title as { th: string; en: string }, content: announcement.content as { th: string; en: string }, status: announcement.status, is_pinned: announcement.is_pinned, published_at: announcement.published_at, created_at: announcement.created_at, updated_at: announcement.updated_at, attachments: announcement.attachments.map(att => ({ id: att.id, announcement_id: att.announcement_id, file_name: att.file_name, file_path: att.file_path, created_at: att.created_at, updated_at: att.created_at, })), }, }; } catch (error) { logger.error(`Error updating announcement: ${error}`); throw error; } } /** * ลบประกาศ * Delete an announcement */ async deleteAnnouncement(input: DeleteAnnouncementInput): Promise { try { const { token, course_id, announcement_id } = input; jwt.verify(token, config.jwt.secret) as { id: number }; // Validate instructor access await CoursesInstructorService.validateCourseInstructor(token, course_id); // Check announcement exists and belongs to course const existing = await prisma.announcement.findFirst({ where: { id: announcement_id, course_id }, include: { attachments: true }, }); if (!existing) { throw new NotFoundError('Announcement not found'); } // Delete attachments from storage for (const attachment of existing.attachments) { try { await deleteFile(attachment.file_path); } catch (err) { logger.warn(`Failed to delete attachment file: ${attachment.file_path}`); } } // Delete announcement (attachments cascade deleted) await prisma.announcement.delete({ where: { id: announcement_id }, }); return { code: 200, message: 'Announcement deleted successfully', }; } catch (error) { logger.error(`Error deleting announcement: ${error}`); throw error; } } /** * อัปโหลดไฟล์แนบ * Upload attachment to announcement */ async uploadAttachment(input: UploadAnnouncementAttachmentInput): Promise { try { const { token, course_id, announcement_id, file } = input; jwt.verify(token, config.jwt.secret) as { id: number }; // Validate instructor access await CoursesInstructorService.validateCourseInstructor(token, course_id); // Check announcement exists and belongs to course const existing = await prisma.announcement.findFirst({ where: { id: announcement_id, course_id }, }); if (!existing) { throw new NotFoundError('Announcement not found'); } // Generate file path const timestamp = Date.now(); const uniqueId = Math.random().toString(36).substring(2, 15); const fileName = (file as any).originalname || 'file'; const extension = fileName.split('.').pop() || ''; const safeFilename = `${timestamp}-${uniqueId}.${extension}`; const filePath = `courses/${course_id}/announcements/${announcement_id}/${safeFilename}`; // Upload to MinIO const fileBuffer = (file as any).buffer; const mimeType = (file as any).mimetype || 'application/octet-stream'; const fileSize = fileBuffer.length; await uploadFile(filePath, fileBuffer, mimeType); // Create attachment record const attachment = await prisma.announcementAttachment.create({ data: { announcement_id, file_name: fileName, file_path: filePath, file_size: fileSize, mime_type: mimeType, }, }); return { code: 201, message: 'Attachment uploaded successfully', data: { id: attachment.id, announcement_id: attachment.announcement_id, file_name: attachment.file_name, file_path: attachment.file_path, created_at: attachment.created_at, updated_at: attachment.created_at, }, }; } catch (error) { logger.error(`Error uploading attachment: ${error}`); throw error; } } /** * ลบไฟล์แนบ * Delete attachment from announcement */ async deleteAttachment(input: DeleteAnnouncementAttachmentInput): Promise { try { const { token, course_id, announcement_id, attachment_id } = input; jwt.verify(token, config.jwt.secret) as { id: number }; // Validate instructor access await CoursesInstructorService.validateCourseInstructor(token, course_id); // Check attachment exists and belongs to announcement in this course const attachment = await prisma.announcementAttachment.findFirst({ where: { id: attachment_id, announcement_id, announcement: { course_id }, }, }); if (!attachment) { throw new NotFoundError('Attachment not found'); } // Delete from storage try { await deleteFile(attachment.file_path); } catch (err) { logger.warn(`Failed to delete attachment file: ${attachment.file_path}`); } // Delete record await prisma.announcementAttachment.delete({ where: { id: attachment_id }, }); return { code: 200, message: 'Attachment deleted successfully', }; } catch (error) { logger.error(`Error deleting attachment: ${error}`); throw error; } } }