feat: add published_at field support to announcements with scheduled publishing and student visibility filtering

Add published_at field to announcement creation and updates, allowing instructors to schedule announcement publishing. Update student announcement filtering to only show PUBLISHED announcements where published_at <= now. Modify announcement creation to set published_at to provided value or current time for PUBLISHED status. Update announcement updates to handle published_at changes while
This commit is contained in:
JakkrapartXD 2026-02-04 16:15:38 +07:00
parent 67f10c4287
commit 05755992a7
3 changed files with 41 additions and 8 deletions

View file

@ -75,6 +75,7 @@ export class AnnouncementsController {
content: parsed.content, content: parsed.content,
status: parsed.status, status: parsed.status,
is_pinned: parsed.is_pinned, is_pinned: parsed.is_pinned,
published_at: parsed.published_at ? new Date(parsed.published_at) : undefined,
files, files,
}); });
} }
@ -107,6 +108,7 @@ export class AnnouncementsController {
content: body.content, content: body.content,
status: body.status, status: body.status,
is_pinned: body.is_pinned, is_pinned: body.is_pinned,
published_at: body.published_at ? new Date(body.published_at) : undefined,
}); });
} }

View file

@ -58,12 +58,19 @@ export class AnnouncementsService {
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
// Students only see PUBLISHED announcements // Students only see PUBLISHED announcements with published_at <= now
const statusFilter = (isAdmin || isInstructor) ? {} : { status: 'PUBLISHED' as const }; 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([ const [announcements, total] = await Promise.all([
prisma.announcement.findMany({ prisma.announcement.findMany({
where: { course_id, ...statusFilter }, where: whereClause,
include: { include: {
attachments: true, attachments: true,
}, },
@ -74,7 +81,7 @@ export class AnnouncementsService {
skip, skip,
take: limit, take: limit,
}), }),
prisma.announcement.count({ where: { course_id } }), prisma.announcement.count({ where: whereClause }),
]); ]);
// Generate presigned URLs for attachments // Generate presigned URLs for attachments
@ -105,6 +112,7 @@ export class AnnouncementsService {
content: a.content as { th: string; en: string }, content: a.content as { th: string; en: string },
status: a.status, status: a.status,
is_pinned: a.is_pinned, is_pinned: a.is_pinned,
published_at: a.published_at,
created_at: a.created_at, created_at: a.created_at,
updated_at: a.updated_at, updated_at: a.updated_at,
attachments: attachmentsWithUrls, attachments: attachmentsWithUrls,
@ -132,12 +140,18 @@ export class AnnouncementsService {
*/ */
async createAnnouncement(input: CreateAnnouncementInput): Promise<CreateAnnouncementResponse> { async createAnnouncement(input: CreateAnnouncementInput): Promise<CreateAnnouncementResponse> {
try { try {
const { token, course_id, title, content, status, is_pinned, files } = input; const { token, course_id, title, content, status, is_pinned, published_at, files } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor access // Validate instructor access
await CoursesInstructorService.validateCourseInstructor(token, course_id); 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 // Create announcement
const announcement = await prisma.announcement.create({ const announcement = await prisma.announcement.create({
data: { data: {
@ -146,7 +160,7 @@ export class AnnouncementsService {
content, content,
status: status as any, status: status as any,
is_pinned, is_pinned,
published_at: status === 'PUBLISHED' ? new Date() : null, published_at: finalPublishedAt,
created_by: decoded.id, created_by: decoded.id,
}, },
}); });
@ -204,6 +218,7 @@ export class AnnouncementsService {
content: announcement.content as { th: string; en: string }, content: announcement.content as { th: string; en: string },
status: announcement.status, status: announcement.status,
is_pinned: announcement.is_pinned, is_pinned: announcement.is_pinned,
published_at: announcement.published_at,
created_at: announcement.created_at, created_at: announcement.created_at,
updated_at: announcement.updated_at, updated_at: announcement.updated_at,
attachments, attachments,
@ -221,7 +236,7 @@ export class AnnouncementsService {
*/ */
async updateAnnouncement(input: UpdateAnnouncementInput): Promise<UpdateAnnouncementResponse> { async updateAnnouncement(input: UpdateAnnouncementInput): Promise<UpdateAnnouncementResponse> {
try { try {
const { token, course_id, announcement_id, title, content, status, is_pinned } = input; 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 }; const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor access // Validate instructor access
@ -236,6 +251,16 @@ export class AnnouncementsService {
throw new NotFoundError('Announcement not found'); 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({ const announcement = await prisma.announcement.update({
where: { id: announcement_id }, where: { id: announcement_id },
data: { data: {
@ -243,7 +268,7 @@ export class AnnouncementsService {
content, content,
status: status as any, status: status as any,
is_pinned, is_pinned,
published_at: status === 'PUBLISHED' && existing.status !== 'PUBLISHED' ? new Date() : existing.published_at, published_at: finalPublishedAt,
updated_by: decoded.id, updated_by: decoded.id,
}, },
include: { include: {
@ -260,6 +285,7 @@ export class AnnouncementsService {
content: announcement.content as { th: string; en: string }, content: announcement.content as { th: string; en: string },
status: announcement.status, status: announcement.status,
is_pinned: announcement.is_pinned, is_pinned: announcement.is_pinned,
published_at: announcement.published_at,
created_at: announcement.created_at, created_at: announcement.created_at,
updated_at: announcement.updated_at, updated_at: announcement.updated_at,
attachments: announcement.attachments.map(att => ({ attachments: announcement.attachments.map(att => ({

View file

@ -6,6 +6,7 @@ export interface Announcement {
content: MultiLanguageText; content: MultiLanguageText;
status: string; status: string;
is_pinned: boolean; is_pinned: boolean;
published_at: Date | null;
created_at: Date; created_at: Date;
updated_at: Date | null; updated_at: Date | null;
attachments: AnnouncementAttachment[]; attachments: AnnouncementAttachment[];
@ -44,6 +45,7 @@ export interface CreateAnnouncementInput{
content: MultiLanguageText; content: MultiLanguageText;
status: string; status: string;
is_pinned: boolean; is_pinned: boolean;
published_at?: Date;
files?: Express.Multer.File[]; files?: Express.Multer.File[];
} }
@ -86,6 +88,7 @@ export interface UpdateAnnouncementInput{
content: MultiLanguageText; content: MultiLanguageText;
status: string; status: string;
is_pinned: boolean; is_pinned: boolean;
published_at?: Date;
attachments?: AnnouncementAttachment[]; attachments?: AnnouncementAttachment[];
} }
@ -112,6 +115,7 @@ export interface CreateAnnouncementBody {
content: MultiLanguageText; content: MultiLanguageText;
status: string; status: string;
is_pinned: boolean; is_pinned: boolean;
published_at?: string;
} }
export interface UpdateAnnouncementBody { export interface UpdateAnnouncementBody {
@ -119,4 +123,5 @@ export interface UpdateAnnouncementBody {
content: MultiLanguageText; content: MultiLanguageText;
status: string; status: string;
is_pinned: boolean; is_pinned: boolean;
published_at?: string;
} }