From baf389643b229262d3ecbc84f5af118102683b6a Mon Sep 17 00:00:00 2001 From: JakkrapartXD Date: Tue, 27 Jan 2026 14:00:20 +0700 Subject: [PATCH] feat: implement announcement management service with CRUD operations, attachment handling, and role-based access control for courses. --- .../controllers/announcementsController.ts | 188 +++++++++ Backend/src/services/announcements.service.ts | 379 ++++++++++++++++++ Backend/src/types/announcements.types.ts | 81 +++- 3 files changed, 640 insertions(+), 8 deletions(-) create mode 100644 Backend/src/controllers/announcementsController.ts diff --git a/Backend/src/controllers/announcementsController.ts b/Backend/src/controllers/announcementsController.ts new file mode 100644 index 00000000..e20ecc42 --- /dev/null +++ b/Backend/src/controllers/announcementsController.ts @@ -0,0 +1,188 @@ +import { Body, Delete, Get, Path, Post, Put, Query, Request, Response, Route, Security, SuccessResponse, Tags, UploadedFile } from 'tsoa'; +import { ValidationError } from '../middleware/errorHandler'; +import { AnnouncementsService } from '../services/announcements.service'; +import { + ListAnnouncementResponse, + CreateAnnouncementResponse, + UpdateAnnouncementResponse, + DeleteAnnouncementResponse, + UploadAnnouncementAttachmentResponse, + DeleteAnnouncementAttachmentResponse, + CreateAnnouncementBody, + UpdateAnnouncementBody, +} from '../types/announcements.types'; + +const announcementsService = new AnnouncementsService(); + +@Route('api/instructors/courses/{courseId}/announcements') +@Tags('Announcements - Instructor') +export class AnnouncementsController { + + /** + * ดึงรายการประกาศของคอร์ส + * List announcements for a course + * @param courseId - รหัสคอร์ส / Course ID + * @param page - หน้าที่ต้องการ / Page number + * @param limit - จำนวนต่อหน้า / Items per page + */ + @Get() + @SuccessResponse('200', 'Announcements retrieved successfully') + @Response('401', 'Unauthorized') + @Response('403', 'Forbidden') + public async listAnnouncements( + @Request() request: any, + @Path() courseId: number, + @Query() page?: number, + @Query() limit?: number + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) throw new ValidationError('No token provided'); + return await announcementsService.listAnnouncement({ + token, + course_id: courseId, + page, + limit, + }); + } + + /** + * สร้างประกาศใหม่ + * Create a new announcement + * @param courseId - รหัสคอร์ส / Course ID + */ + @Post() + @Security('jwt', ['instructor']) + @SuccessResponse('201', 'Announcement created successfully') + @Response('401', 'Unauthorized') + @Response('403', 'Forbidden') + public async createAnnouncement( + @Request() request: any, + @Path() courseId: number, + @Body() body: CreateAnnouncementBody + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) throw new ValidationError('No token provided'); + return await announcementsService.createAnnouncement({ + token, + course_id: courseId, + title: body.title, + content: body.content, + status: body.status, + is_pinned: body.is_pinned, + }); + } + + /** + * อัปเดตประกาศ + * Update an announcement + * @param courseId - รหัสคอร์ส / Course ID + * @param announcementId - รหัสประกาศ / Announcement ID + */ + @Put('{announcementId}') + @Security('jwt', ['instructor']) + @SuccessResponse('200', 'Announcement updated successfully') + @Response('401', 'Unauthorized') + @Response('403', 'Forbidden') + @Response('404', 'Announcement not found') + public async updateAnnouncement( + @Request() request: any, + @Path() courseId: number, + @Path() announcementId: number, + @Body() body: UpdateAnnouncementBody + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) throw new ValidationError('No token provided'); + return await announcementsService.updateAnnouncement({ + token, + course_id: courseId, + announcement_id: announcementId, + title: body.title, + content: body.content, + status: body.status, + is_pinned: body.is_pinned, + }); + } + + /** + * ลบประกาศ + * Delete an announcement + * @param courseId - รหัสคอร์ส / Course ID + * @param announcementId - รหัสประกาศ / Announcement ID + */ + @Delete('{announcementId}') + @Security('jwt', ['instructor']) + @SuccessResponse('200', 'Announcement deleted successfully') + @Response('401', 'Unauthorized') + @Response('403', 'Forbidden') + @Response('404', 'Announcement not found') + public async deleteAnnouncement( + @Request() request: any, + @Path() courseId: number, + @Path() announcementId: number + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) throw new ValidationError('No token provided'); + return await announcementsService.deleteAnnouncement({ + token, + course_id: courseId, + announcement_id: announcementId, + }); + } + + /** + * อัปโหลดไฟล์แนบ + * Upload attachment to announcement + * @param courseId - รหัสคอร์ส / Course ID + * @param announcementId - รหัสประกาศ / Announcement ID + */ + @Post('{announcementId}/attachments') + @Security('jwt', ['instructor']) + @SuccessResponse('201', 'Attachment uploaded successfully') + @Response('401', 'Unauthorized') + @Response('403', 'Forbidden') + @Response('404', 'Announcement not found') + public async uploadAttachment( + @Request() request: any, + @Path() courseId: number, + @Path() announcementId: number, + @UploadedFile() file: Express.Multer.File + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) throw new ValidationError('No token provided'); + return await announcementsService.uploadAttachment({ + token, + course_id: courseId, + announcement_id: announcementId, + file: file as any, + }); + } + + /** + * ลบไฟล์แนบ + * Delete attachment from announcement + * @param courseId - รหัสคอร์ส / Course ID + * @param announcementId - รหัสประกาศ / Announcement ID + * @param attachmentId - รหัสไฟล์แนบ / Attachment ID + */ + @Delete('{announcementId}/attachments/{attachmentId}') + @Security('jwt', ['instructor']) + @SuccessResponse('200', 'Attachment deleted successfully') + @Response('401', 'Unauthorized') + @Response('403', 'Forbidden') + @Response('404', 'Attachment not found') + public async deleteAttachment( + @Request() request: any, + @Path() courseId: number, + @Path() announcementId: number, + @Path() attachmentId: number + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) throw new ValidationError('No token provided'); + return await announcementsService.deleteAttachment({ + token, + course_id: courseId, + announcement_id: announcementId, + attachment_id: attachmentId, + }); + } +} diff --git a/Backend/src/services/announcements.service.ts b/Backend/src/services/announcements.service.ts index e69de29b..070d0262 100644 --- a/Backend/src/services/announcements.service.ts +++ b/Backend/src/services/announcements.service.ts @@ -0,0 +1,379 @@ +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 } 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 + const statusFilter = (isAdmin || isInstructor) ? {} : { status: 'PUBLISHED' as const }; + + const [announcements, total] = await Promise.all([ + prisma.announcement.findMany({ + where: { course_id, ...statusFilter }, + include: { + attachments: true, + }, + orderBy: [ + { is_pinned: 'desc' }, + { created_at: 'desc' }, + ], + skip, + take: limit, + }), + prisma.announcement.count({ where: { course_id } }), + ]); + + return { + code: 200, + message: 'Announcements retrieved successfully', + data: announcements.map(a => ({ + 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, + created_at: a.created_at, + updated_at: a.updated_at, + attachments: a.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, + })), + })), + total, + page, + limit, + }; + } catch (error) { + logger.error(`Error listing announcements: ${error}`); + throw error; + } + } + + /** + * สร้างประกาศใหม่ + * Create a new announcement + */ + async createAnnouncement(input: CreateAnnouncementInput): Promise { + try { + const { token, course_id, title, content, status, is_pinned } = input; + const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; + + // Validate instructor access + await CoursesInstructorService.validateCourseInstructor(token, course_id); + + const announcement = await prisma.announcement.create({ + data: { + course_id, + title, + content, + status: status as any, + is_pinned, + published_at: status === 'PUBLISHED' ? new Date() : null, + created_by: decoded.id, + }, + include: { + attachments: true, + }, + }); + + 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, + 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 } = 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'); + } + + const announcement = await prisma.announcement.update({ + where: { id: announcement_id }, + data: { + title, + content, + status: status as any, + is_pinned, + published_at: status === 'PUBLISHED' && existing.status !== 'PUBLISHED' ? new Date() : existing.published_at, + 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, + 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; + } + } +} \ No newline at end of file diff --git a/Backend/src/types/announcements.types.ts b/Backend/src/types/announcements.types.ts index 05ff928f..c526906f 100644 --- a/Backend/src/types/announcements.types.ts +++ b/Backend/src/types/announcements.types.ts @@ -1,16 +1,13 @@ import { MultiLanguageText } from './index'; -// Use MultiLanguageText from index.ts for consistency -export type MultiLangText = MultiLanguageText; - export interface Announcement { id: number; - title: MultiLangText; - content: MultiLangText; + title: MultiLanguageText; + content: MultiLanguageText; status: string; is_pinned: boolean; created_at: Date; - updated_at: Date; + updated_at: Date | null; attachments: AnnouncementAttachment[]; } @@ -42,15 +39,83 @@ export interface ListAnnouncementInput{ export interface CreateAnnouncementInput{ token: string; course_id: number; - title: MultiLangText; - content: MultiLangText; + title: MultiLanguageText; + content: MultiLanguageText; status: string; is_pinned: boolean; attachments?: AnnouncementAttachment[]; } +export interface UploadAnnouncementAttachmentInput{ + token: string; + course_id: number; + announcement_id: number; + file: File; +} + +export interface UploadAnnouncementAttachmentResponse{ + code: number; + message: string; + data: AnnouncementAttachment; +} + +export interface DeleteAnnouncementAttachmentInput{ + token: string; + course_id: number; + announcement_id: number; + attachment_id: number; +} + +export interface DeleteAnnouncementAttachmentResponse{ + code: number; + message: string; +} + export interface CreateAnnouncementResponse{ code: number; message: string; data: Announcement; +} + +export interface UpdateAnnouncementInput{ + token: string; + course_id: number; + announcement_id: number; + title: MultiLanguageText; + content: MultiLanguageText; + status: string; + is_pinned: boolean; + attachments?: AnnouncementAttachment[]; +} + +export interface UpdateAnnouncementResponse{ + code: number; + message: string; + data: Announcement; +} + +export interface DeleteAnnouncementInput{ + token: string; + course_id: number; + announcement_id: number; +} + +export interface DeleteAnnouncementResponse{ + code: number; + message: string; +} + +// Body types for TSOA controller +export interface CreateAnnouncementBody { + title: MultiLanguageText; + content: MultiLanguageText; + status: string; + is_pinned: boolean; +} + +export interface UpdateAnnouncementBody { + title: MultiLanguageText; + content: MultiLanguageText; + status: string; + is_pinned: boolean; } \ No newline at end of file