524 lines
No EOL
20 KiB
TypeScript
524 lines
No EOL
20 KiB
TypeScript
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';
|
|
import { auditService } from './audit.service';
|
|
import { AuditAction } from '@prisma/client';
|
|
|
|
export class AnnouncementsService {
|
|
|
|
/**
|
|
* ดึงรายการประกาศของคอร์ส (ใช้ได้ทั้ง instructor, admin, student)
|
|
* List announcements for a course (accessible by instructor, admin, student)
|
|
*/
|
|
async listAnnouncement(input: ListAnnouncementInput): Promise<ListAnnouncementResponse> {
|
|
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}`);
|
|
const decoded = jwt.decode(input.token) as { id: number } | null;
|
|
await auditService.logSync({
|
|
userId: decoded?.id || 0,
|
|
action: AuditAction.ERROR,
|
|
entityType: 'Announcement',
|
|
entityId: 0,
|
|
metadata: {
|
|
error: error instanceof Error ? error.message : String(error)
|
|
}
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* สร้างประกาศใหม่ (พร้อมอัปโหลดไฟล์แนบ)
|
|
* Create a new announcement with optional file attachments
|
|
*/
|
|
async createAnnouncement(input: CreateAnnouncementInput): Promise<CreateAnnouncementResponse> {
|
|
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}`);
|
|
const decoded = jwt.decode(input.token) as { id: number } | null;
|
|
await auditService.logSync({
|
|
userId: decoded?.id || 0,
|
|
action: AuditAction.ERROR,
|
|
entityType: 'Announcement',
|
|
entityId: 0,
|
|
metadata: {
|
|
error: error instanceof Error ? error.message : String(error)
|
|
}
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* อัปเดตประกาศ
|
|
* Update an announcement
|
|
*/
|
|
async updateAnnouncement(input: UpdateAnnouncementInput): Promise<UpdateAnnouncementResponse> {
|
|
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}`);
|
|
const decoded = jwt.decode(input.token) as { id: number } | null;
|
|
await auditService.logSync({
|
|
userId: decoded?.id || 0,
|
|
action: AuditAction.ERROR,
|
|
entityType: 'Announcement',
|
|
entityId: 0,
|
|
metadata: {
|
|
error: error instanceof Error ? error.message : String(error)
|
|
}
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ลบประกาศ
|
|
* Delete an announcement
|
|
*/
|
|
async deleteAnnouncement(input: DeleteAnnouncementInput): Promise<DeleteAnnouncementResponse> {
|
|
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}`);
|
|
const decoded = jwt.decode(input.token) as { id: number } | null;
|
|
await auditService.logSync({
|
|
userId: decoded?.id || 0,
|
|
action: AuditAction.ERROR,
|
|
entityType: 'Announcement',
|
|
entityId: 0,
|
|
metadata: {
|
|
error: error instanceof Error ? error.message : String(error)
|
|
}
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* อัปโหลดไฟล์แนบ
|
|
* Upload attachment to announcement
|
|
*/
|
|
async uploadAttachment(input: UploadAnnouncementAttachmentInput): Promise<UploadAnnouncementAttachmentResponse> {
|
|
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}`);
|
|
const decoded = jwt.decode(input.token) as { id: number } | null;
|
|
await auditService.logSync({
|
|
userId: decoded?.id || 0,
|
|
action: AuditAction.ERROR,
|
|
entityType: 'Announcement',
|
|
entityId: 0,
|
|
metadata: {
|
|
error: error instanceof Error ? error.message : String(error)
|
|
}
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ลบไฟล์แนบ
|
|
* Delete attachment from announcement
|
|
*/
|
|
async deleteAttachment(input: DeleteAnnouncementAttachmentInput): Promise<DeleteAnnouncementAttachmentResponse> {
|
|
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}`);
|
|
const decoded = jwt.decode(input.token) as { id: number } | null;
|
|
await auditService.logSync({
|
|
userId: decoded?.id || 0,
|
|
action: AuditAction.ERROR,
|
|
entityType: 'Announcement',
|
|
entityId: 0,
|
|
metadata: {
|
|
error: error instanceof Error ? error.message : String(error)
|
|
}
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
} |