From d0cefc17a3665d55f39c101f45288a27330a06de Mon Sep 17 00:00:00 2001 From: ribhavsharma Date: Thu, 2 Oct 2025 23:20:07 -0700 Subject: [PATCH 1/8] initial changes, add deliveryMode to alerts --- packages/common/index.ts | 25 +++++++ .../1738540000000-alert-delivery-mode.ts | 49 ++++++++++++++ .../server/src/alerts/alerts.controller.ts | 67 +++++++++++++------ packages/server/src/alerts/alerts.entity.ts | 12 +++- packages/server/src/alerts/alerts.service.ts | 44 ++++++++---- .../server/src/calendar/calendar.service.ts | 3 +- .../queue/queue-clean/queue-clean.service.ts | 8 +++ 7 files changed, 175 insertions(+), 33 deletions(-) create mode 100644 packages/server/migration/1738540000000-alert-delivery-mode.ts diff --git a/packages/common/index.ts b/packages/common/index.ts index f39b4a0ee..da0212556 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -2230,6 +2230,12 @@ export enum AlertType { REPHRASE_QUESTION = 'rephraseQuestion', EVENT_ENDED_CHECKOUT_STAFF = 'eventEndedCheckoutStaff', PROMPT_STUDENT_TO_LEAVE_QUEUE = 'promptStudentToLeaveQueue', + DOCUMENT_PROCESSED = 'documentProcessed', +} + +export enum AlertDeliveryMode { + MODAL = 'modal', + FEED = 'feed', } export class AlertPayload {} @@ -2238,12 +2244,19 @@ export class Alert { @IsEnum(AlertType) alertType!: AlertType + @IsEnum(AlertDeliveryMode) + deliveryMode!: AlertDeliveryMode + @IsDate() sent!: Date @Type(() => AlertPayload) payload!: AlertPayload + @IsOptional() + @IsDate() + readAt?: Date + @IsInt() id!: number } @@ -2266,6 +2279,14 @@ export class PromptStudentToLeaveQueuePayload extends AlertPayload { queueQuestionId?: number } +export class DocumentProcessedPayload extends AlertPayload { + @IsInt() + documentId!: number + + @IsString() + documentName!: string +} + export class OrganizationCourseResponse { @IsInt() id?: number @@ -2296,6 +2317,10 @@ export class CreateAlertParams { @IsEnum(AlertType) alertType!: AlertType + @IsOptional() + @IsEnum(AlertDeliveryMode) + deliveryMode?: AlertDeliveryMode + @IsInt() courseId!: number diff --git a/packages/server/migration/1738540000000-alert-delivery-mode.ts b/packages/server/migration/1738540000000-alert-delivery-mode.ts new file mode 100644 index 000000000..90102a61f --- /dev/null +++ b/packages/server/migration/1738540000000-alert-delivery-mode.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AlertDeliveryMode1738540000000 implements MigrationInterface { + name = 'AlertDeliveryMode1738540000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE "public"."alert_model_alerttype_enum" RENAME TO "alert_model_alerttype_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."alert_model_alerttype_enum" AS ENUM('rephraseQuestion', 'eventEndedCheckoutStaff', 'promptStudentToLeaveQueue', 'documentProcessed')`, + ); + await queryRunner.query( + `ALTER TABLE "alert_model" ALTER COLUMN "alertType" TYPE "public"."alert_model_alerttype_enum" USING "alertType"::text::"public"."alert_model_alerttype_enum"`, + ); + await queryRunner.query( + `DROP TYPE "public"."alert_model_alerttype_enum_old"`, + ); + + await queryRunner.query( + `CREATE TYPE "public"."alert_model_deliverymode_enum" AS ENUM('modal', 'feed')`, + ); + await queryRunner.query( + `ALTER TABLE "alert_model" ADD "deliveryMode" "public"."alert_model_deliverymode_enum" NOT NULL DEFAULT 'modal'`, + ); + await queryRunner.query(`ALTER TABLE "alert_model" ADD "readAt" TIMESTAMP`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "alert_model" DROP COLUMN "readAt"`); + await queryRunner.query( + `ALTER TABLE "alert_model" DROP COLUMN "deliveryMode"`, + ); + await queryRunner.query( + `DROP TYPE "public"."alert_model_deliverymode_enum"`, + ); + + await queryRunner.query( + `CREATE TYPE "public"."alert_model_alerttype_enum_old" AS ENUM('rephraseQuestion', 'eventEndedCheckoutStaff', 'promptStudentToLeaveQueue')`, + ); + await queryRunner.query( + `ALTER TABLE "alert_model" ALTER COLUMN "alertType" TYPE "public"."alert_model_alerttype_enum_old" USING "alertType"::text::"public"."alert_model_alerttype_enum_old"`, + ); + await queryRunner.query(`DROP TYPE "public"."alert_model_alerttype_enum"`); + await queryRunner.query( + `ALTER TYPE "public"."alert_model_alerttype_enum_old" RENAME TO "alert_model_alerttype_enum"`, + ); + } +} diff --git a/packages/server/src/alerts/alerts.controller.ts b/packages/server/src/alerts/alerts.controller.ts index 5e2ccbcea..be6c41d8d 100644 --- a/packages/server/src/alerts/alerts.controller.ts +++ b/packages/server/src/alerts/alerts.controller.ts @@ -1,4 +1,5 @@ import { + AlertDeliveryMode, CreateAlertParams, CreateAlertResponse, ERROR_MESSAGES, @@ -14,6 +15,7 @@ import { ParseIntPipe, Patch, Post, + Query, UseGuards, } from '@nestjs/common'; import { JwtAuthGuard } from 'guards/jwt-auth.guard'; @@ -33,13 +35,32 @@ export class AlertsController { async getAlerts( @Param('courseId', ParseIntPipe) courseId: number, @UserId() userId: number, + @Query('mode') mode?: string, + @Query('includeRead') includeRead?: string, ): Promise { + const parsedMode = Object.values(AlertDeliveryMode).includes( + (mode as AlertDeliveryMode) ?? AlertDeliveryMode.MODAL, + ) + ? (mode as AlertDeliveryMode) || AlertDeliveryMode.MODAL + : AlertDeliveryMode.MODAL; + + const includeReadFlag = includeRead === 'true'; + + const where: Record = { + courseId, + userId, + deliveryMode: parsedMode, + }; + + if (parsedMode === AlertDeliveryMode.MODAL) { + where.resolved = IsNull(); + } else if (!includeReadFlag) { + where.readAt = IsNull(); + } + const alerts = await AlertModel.find({ - where: { - courseId, - userId, - resolved: IsNull(), - }, + where, + order: { sent: 'DESC' }, }); return { alerts: await this.alertsService.removeStaleAlerts(alerts) }; } @@ -49,7 +70,8 @@ export class AlertsController { async createAlert( @Body() body: CreateAlertParams, ): Promise { - const { alertType, courseId, payload, targetUserId } = body; + const { alertType, courseId, payload, targetUserId, deliveryMode } = body; + const parsedMode = deliveryMode ?? AlertDeliveryMode.MODAL; if (!this.alertsService.assertPayloadType(alertType, payload)) { throw new BadRequestException( @@ -57,23 +79,26 @@ export class AlertsController { ); } - const anotherAlert = await AlertModel.findOne({ - where: { - alertType, - userId: targetUserId, - resolved: IsNull(), - }, - }); + if (parsedMode === AlertDeliveryMode.MODAL) { + const anotherAlert = await AlertModel.findOne({ + where: { + alertType, + deliveryMode: parsedMode, + userId: targetUserId, + resolved: IsNull(), + }, + }); - // If the same user already has an alert for this then don't create a new one - if (anotherAlert) { - throw new BadRequestException( - ERROR_MESSAGES.alertController.duplicateAlert, - ); + if (anotherAlert) { + throw new BadRequestException( + ERROR_MESSAGES.alertController.duplicateAlert, + ); + } } const alert = await AlertModel.create({ alertType, + deliveryMode: parsedMode, sent: new Date(), userId: targetUserId, courseId, @@ -100,7 +125,11 @@ export class AlertsController { ); } - alert.resolved = new Date(); + if (alert.deliveryMode === AlertDeliveryMode.FEED) { + alert.readAt = new Date(); + } else { + alert.resolved = new Date(); + } await alert.save(); } } diff --git a/packages/server/src/alerts/alerts.entity.ts b/packages/server/src/alerts/alerts.entity.ts index fe96dfc43..808afa5c5 100644 --- a/packages/server/src/alerts/alerts.entity.ts +++ b/packages/server/src/alerts/alerts.entity.ts @@ -1,4 +1,4 @@ -import { AlertPayload, AlertType } from '@koh/common'; +import { AlertDeliveryMode, AlertPayload, AlertType } from '@koh/common'; import { Exclude } from 'class-transformer'; import { BaseEntity, @@ -19,12 +19,22 @@ export class AlertModel extends BaseEntity { @Column({ type: 'enum', enum: AlertType }) alertType: AlertType; + @Column({ + type: 'enum', + enum: AlertDeliveryMode, + default: AlertDeliveryMode.MODAL, + }) + deliveryMode: AlertDeliveryMode; + @Column() sent: Date; @Column({ nullable: true }) resolved: Date; + @Column({ type: 'timestamp', nullable: true }) + readAt: Date; + @ManyToOne((type) => UserModel, (user) => user.alerts) @JoinColumn({ name: 'userId' }) user: UserModel; diff --git a/packages/server/src/alerts/alerts.service.ts b/packages/server/src/alerts/alerts.service.ts index 860786784..4d56d0b23 100644 --- a/packages/server/src/alerts/alerts.service.ts +++ b/packages/server/src/alerts/alerts.service.ts @@ -1,9 +1,11 @@ import { Alert, + AlertDeliveryMode, AlertPayload, AlertType, RephraseQuestionPayload, PromptStudentToLeaveQueuePayload, + DocumentProcessedPayload, } from '@koh/common'; import { pick } from 'lodash'; import { Injectable } from '@nestjs/common'; @@ -17,10 +19,8 @@ export class AlertsService { const nonStaleAlerts = []; for (const alert of alerts) { - // Might be one of the few usecases for ReasonML - switch (alert.alertType) { - case AlertType.REPHRASE_QUESTION: + case AlertType.REPHRASE_QUESTION: { const payload = alert.payload as RephraseQuestionPayload; const question = await QuestionModel.findOne({ where: { id: payload.questionId }, @@ -33,31 +33,43 @@ export class AlertsService { }, }); - const isQueueOpen = queue.staffList.length > 0 && !queue.isDisabled; - if (question.closedAt || !isQueueOpen) { + const isQueueOpen = queue?.staffList.length > 0 && !queue?.isDisabled; + if (question?.closedAt || !queue || !isQueueOpen) { alert.resolved = new Date(); await alert.save(); } else { nonStaleAlerts.push( - pick(alert, ['sent', 'alertType', 'payload', 'id']), + pick(alert, [ + 'sent', + 'alertType', + 'payload', + 'id', + 'deliveryMode', + 'readAt', + ]), ); } break; + } case AlertType.EVENT_ENDED_CHECKOUT_STAFF: - nonStaleAlerts.push( - pick(alert, ['sent', 'alertType', 'payload', 'id']), - ); - break; case AlertType.PROMPT_STUDENT_TO_LEAVE_QUEUE: + case AlertType.DOCUMENT_PROCESSED: nonStaleAlerts.push( - pick(alert, ['sent', 'alertType', 'payload', 'id']), + pick(alert, [ + 'sent', + 'alertType', + 'payload', + 'id', + 'deliveryMode', + 'readAt', + ]), ); + break; } } return nonStaleAlerts; } - assertPayloadType(alertType: AlertType, payload: AlertPayload): boolean { switch (alertType) { case AlertType.REPHRASE_QUESTION: @@ -81,6 +93,14 @@ export class AlertsService { typeof promptPayload.queueQuestionId === 'number') ); + case AlertType.DOCUMENT_PROCESSED: + const docPayload = payload as DocumentProcessedPayload; + return ( + typeof docPayload.documentId === 'number' && + typeof docPayload.documentName === 'string' && + docPayload.documentName.trim().length > 0 + ); + default: return true; } diff --git a/packages/server/src/calendar/calendar.service.ts b/packages/server/src/calendar/calendar.service.ts index a7a7e315a..c29e02f2b 100644 --- a/packages/server/src/calendar/calendar.service.ts +++ b/packages/server/src/calendar/calendar.service.ts @@ -10,7 +10,7 @@ import { EntityManager } from 'typeorm'; import { UserModel } from '../profile/user.entity'; import { CalendarModel } from './calendar.entity'; import { AlertModel } from '../alerts/alerts.entity'; -import { AlertType, ERROR_MESSAGES } from '@koh/common'; +import { AlertDeliveryMode, AlertType, ERROR_MESSAGES } from '@koh/common'; import { QueueModel } from '../queue/queue.entity'; import { EventModel, EventType } from '../profile/event-model.entity'; import { CronJob } from 'cron'; @@ -235,6 +235,7 @@ export class CalendarService implements OnModuleInit { try { alert = await AlertModel.create({ alertType: AlertType.EVENT_ENDED_CHECKOUT_STAFF, + deliveryMode: AlertDeliveryMode.MODAL, sent: now, userId: userId, courseId: courseId, diff --git a/packages/server/src/queue/queue-clean/queue-clean.service.ts b/packages/server/src/queue/queue-clean/queue-clean.service.ts index ef9c5f943..8db7a130d 100644 --- a/packages/server/src/queue/queue-clean/queue-clean.service.ts +++ b/packages/server/src/queue/queue-clean/queue-clean.service.ts @@ -1,4 +1,5 @@ import { + AlertDeliveryMode, AlertType, ClosedQuestionStatus, LimboQuestionStatus, @@ -104,6 +105,9 @@ export class QueueCleanService { .andWhere("(alert.payload ->> 'queueId')::INTEGER = :queueId ", { queueId, }) + .andWhere('alert."deliveryMode" = :deliveryMode', { + deliveryMode: AlertDeliveryMode.MODAL, + }) .getMany(); questions.forEach((q: QuestionModel) => { @@ -201,12 +205,16 @@ export class QueueCleanService { .andWhere('alert.payload::jsonb @> :payload', { payload: JSON.stringify({ queueId }), }) + .andWhere('alert."deliveryMode" = :deliveryMode', { + deliveryMode: AlertDeliveryMode.MODAL, + }) .getOne(); if (existingAlert) { return; } const alert = await AlertModel.create({ alertType: AlertType.PROMPT_STUDENT_TO_LEAVE_QUEUE, + deliveryMode: AlertDeliveryMode.MODAL, sent: new Date(), userId: student.studentId, courseId: student.courseId, From a5955f270c773b4ed0d813fcd4f8cc92a682d050 Mon Sep 17 00:00:00 2001 From: ribhavsharma Date: Sat, 11 Oct 2025 11:53:19 -0700 Subject: [PATCH 2/8] add anytime question updates + rephrase question to notif feed --- packages/common/index.ts | 31 +- .../components/TAQuestionCardButtons.tsx | 2 + packages/frontend/app/api/index.ts | 9 + .../frontend/app/components/HeaderBar.tsx | 9 + .../app/components/NotificationBell.tsx | 412 ++++++++++++++++++ ...000000000-add-async-question-alert-type.ts | 35 ++ .../server/src/alerts/alerts.controller.ts | 33 ++ packages/server/src/alerts/alerts.service.ts | 22 +- .../asyncQuestion/asyncQuestion.service.ts | 79 +++- .../queue/queue-clean/queue-clean.service.ts | 2 +- 10 files changed, 623 insertions(+), 11 deletions(-) create mode 100644 packages/frontend/app/components/NotificationBell.tsx create mode 100644 packages/server/migration/1755000000000-add-async-question-alert-type.ts diff --git a/packages/common/index.ts b/packages/common/index.ts index da0212556..528103252 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -2231,6 +2231,7 @@ export enum AlertType { EVENT_ENDED_CHECKOUT_STAFF = 'eventEndedCheckoutStaff', PROMPT_STUDENT_TO_LEAVE_QUEUE = 'promptStudentToLeaveQueue', DOCUMENT_PROCESSED = 'documentProcessed', + ASYNC_QUESTION_UPDATE = 'asyncQuestionUpdate', } export enum AlertDeliveryMode { @@ -2238,7 +2239,11 @@ export enum AlertDeliveryMode { FEED = 'feed', } -export class AlertPayload {} +export class AlertPayload { + @IsOptional() + @IsInt() + courseId?: number +} export class Alert { @IsEnum(AlertType) @@ -2267,9 +2272,6 @@ export class RephraseQuestionPayload extends AlertPayload { @IsInt() queueId!: number - - @IsInt() - courseId!: number } export class PromptStudentToLeaveQueuePayload extends AlertPayload { @@ -2287,6 +2289,27 @@ export class DocumentProcessedPayload extends AlertPayload { documentName!: string } +export class AsyncQuestionUpdatePayload extends AlertPayload { + @IsInt() + questionId!: number + + @IsInt() + courseId!: number + + @IsString() + @IsOptional() + subtype?: + | 'commentOnMyPost' + | 'commentOnOthersPost' + | 'humanAnswered' + | 'statusChanged' + | 'upvoted' + + @IsString() + @IsOptional() + summary?: string +} + export class OrganizationCourseResponse { @IsInt() id?: number diff --git a/packages/frontend/app/(dashboard)/course/[cid]/queue/[qid]/components/TAQuestionCardButtons.tsx b/packages/frontend/app/(dashboard)/course/[cid]/queue/[qid]/components/TAQuestionCardButtons.tsx index 661fec367..5180dff44 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/queue/[qid]/components/TAQuestionCardButtons.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/queue/[qid]/components/TAQuestionCardButtons.tsx @@ -7,6 +7,7 @@ import { UndoOutlined, } from '@ant-design/icons' import { + AlertDeliveryMode, AlertType, ClosedQuestionStatus, ERROR_MESSAGES, @@ -159,6 +160,7 @@ const TAQuestionCardButtons: React.FC = ({ courseId, payload, targetUserId: question.creatorId, + deliveryMode: AlertDeliveryMode.FEED, }) // await mutateQuestions() message.success('Successfully asked student to rephrase their question.') diff --git a/packages/frontend/app/api/index.ts b/packages/frontend/app/api/index.ts index 28031576f..a4b67c3c7 100644 --- a/packages/frontend/app/api/index.ts +++ b/packages/frontend/app/api/index.ts @@ -37,6 +37,7 @@ import { DesktopNotifPartial, EditCourseInfoParams, GetAlertsResponse, + AlertDeliveryMode, GetAvailableModelsBody, GetChatbotHistoryResponse, GetCourseResponse, @@ -1080,6 +1081,14 @@ class APIClient { alerts = { get: async (courseId: number): Promise => this.req('GET', `/api/v1/alerts/${courseId}`), + getAll: async ( + mode: AlertDeliveryMode = AlertDeliveryMode.FEED, + includeRead: boolean = false, + ): Promise => + this.req('GET', `/api/v1/alerts`, undefined, undefined, { + mode, + includeRead: includeRead ? 'true' : 'false', + }), create: async (params: CreateAlertParams): Promise => this.req('POST', `/api/v1/alerts`, CreateAlertResponse, params), close: async (alertId: number): Promise => diff --git a/packages/frontend/app/components/HeaderBar.tsx b/packages/frontend/app/components/HeaderBar.tsx index 30072e3f5..429124e5e 100644 --- a/packages/frontend/app/components/HeaderBar.tsx +++ b/packages/frontend/app/components/HeaderBar.tsx @@ -39,6 +39,7 @@ import { Popconfirm } from 'antd' import { sortQueues } from '../(dashboard)/course/[cid]/utils/commonCourseFunctions' import { useCourseFeatures } from '../hooks/useCourseFeatures' import CenteredSpinner from './CenteredSpinner' +import NotificationBell from './NotificationBell' import Image from 'next/image' import { useOrganizationSettings } from '@/app/hooks/useOrganizationSettings' @@ -355,6 +356,11 @@ const NavBar = ({ ) : null} {/* DESKTOP ONLY PART OF NAVBAR */} +
+ +
+
+ { )} +
+ +
void + className?: string +} + +type NotificationView = { + key: number + title: string + description?: string + ctaLabel?: string + onOpen?: () => Promise | void + isUnread: boolean + sent: Date + courseName?: string +} + +const alertMeta: Record = { + [AlertType.DOCUMENT_PROCESSED]: { + title: 'Document processed', + description: + 'Your uploaded document has been processed and is ready to use.', + destination: '/settings/chatbot_knowledge_base', + }, + [AlertType.REPHRASE_QUESTION]: { + title: 'Question rephrase requested', + description: + 'You have been asked to add more detail to your question. Your place in line is reserved while you edit.', + }, + [AlertType.PROMPT_STUDENT_TO_LEAVE_QUEUE]: { + title: 'Please leave the queue', + description: + 'You have been inactive for a while. Please leave the queue if you no longer need assistance.', + }, + [AlertType.EVENT_ENDED_CHECKOUT_STAFF]: { + title: 'Event has ended', + description: + 'The event you were assisting with has ended. Please check out.', + }, + [AlertType.ASYNC_QUESTION_UPDATE]: { + title: 'Anytime question update', + description: 'There was an update related to an Anytime Question.', + }, +} + +const NotificationBell: React.FC = ({ + showText = false, + onAfterNavigate, + className, +}) => { + const router = useRouter() + const [open, setOpen] = useState(false) + + const { data, isLoading, mutate } = useSWR( + ['alerts-feed'], + async () => API.alerts.getAll(AlertDeliveryMode.FEED), + { revalidateOnFocus: true }, + ) + + const alerts: Alert[] = useMemo(() => data?.alerts ?? [], [data]) + + const unreadCount = useMemo( + () => alerts.filter((alert) => !alert.readAt).length, + [alerts], + ) + + const uniqueCourseIds = useMemo(() => { + const ids = new Set() + for (const alert of alerts) { + if ((alert.payload as any)?.courseId) { + ids.add((alert.payload as any).courseId) + } + } + return Array.from(ids) + }, [alerts]) + + // fetch course names for each courseId in alerts + const { data: courseResponses } = useSWR( + uniqueCourseIds.length > 0 ? ['courses-for-alerts', uniqueCourseIds] : null, + async () => { + const results: Record = {} + await Promise.all( + uniqueCourseIds.map(async (id) => { + try { + const course: GetCourseResponse = await API.course.get(id) + results[id] = course?.name ?? `Course ${id}` + } catch { + results[id] = `Course ${id}` + } + }), + ) + return results + }, + { revalidateOnFocus: false }, + ) + + const courseNameMap = courseResponses ?? {} + + const markAsRead = async (alert: Alert) => { + if (alert.readAt) return + // optimistic update + mutate( + (current) => + current + ? { + ...current, + alerts: current.alerts.map((a) => + a.id === alert.id ? { ...a, readAt: new Date() as any } : a, + ), + } + : current, + { revalidate: false }, + ) + try { + await API.alerts.close(alert.id) + } finally { + await mutate(undefined, { revalidate: true }) + } + } + + const handleNavigate = + (alert: Alert, url: string) => async (): Promise => { + await markAsRead(alert) + router.push(url) + onAfterNavigate?.() + } + + const views: NotificationView[] = useMemo(() => { + return alerts + .filter((alert) => alert.deliveryMode === AlertDeliveryMode.FEED) + .map((alert) => { + const sentAt = new Date(alert.sent) + const courseId = (alert.payload as any)?.courseId + const courseName = courseId ? courseNameMap[courseId] : undefined + + if (alert.alertType === AlertType.DOCUMENT_PROCESSED) { + const payload = alert.payload as DocumentProcessedPayload + const destination = courseId + ? `/course/${courseId}/settings/chatbot_knowledge_base` + : undefined + + return { + key: alert.id, + title: `Document "${payload.documentName}" is ready`, + description: 'Uploaded course document finished processing.', + ctaLabel: destination ? 'View document' : undefined, + onOpen: destination + ? handleNavigate(alert, destination) + : async () => await markAsRead(alert), + isUnread: !alert.readAt, + sent: sentAt, + courseName, + } + } + + if (alert.alertType === AlertType.ASYNC_QUESTION_UPDATE) { + const payload = alert.payload as AsyncQuestionUpdatePayload + const destination = payload.courseId + ? `/course/${payload.courseId}/async_centre` + : undefined + const title = + payload.subtype === 'commentOnMyPost' + ? 'New comment on your Anytime Question' + : payload.subtype === 'commentOnOthersPost' + ? 'New comment on a followed Anytime Question' + : payload.subtype === 'humanAnswered' + ? 'Your Anytime Question was answered' + : payload.subtype === 'statusChanged' + ? 'Anytime Question status changed' + : payload.subtype === 'upvoted' + ? 'Your Anytime Question was upvoted' + : 'Anytime Question update' + return { + key: alert.id, + title, + description: payload.summary, + ctaLabel: destination ? 'Open' : undefined, + onOpen: destination + ? handleNavigate(alert, destination) + : async () => await markAsRead(alert), + isUnread: !alert.readAt, + sent: sentAt, + courseName, + } + } + + const meta = alertMeta[alert.alertType] + return { + key: alert.id, + title: meta?.title, + description: meta?.description, + onOpen: async () => await markAsRead(alert), + isUnread: !alert.readAt, + sent: sentAt, + courseName, + } + }) + .sort((a, b) => b.sent.getTime() - a.sent.getTime()) + }, [alerts, courseNameMap]) + + const markAllRead = async () => { + const unread = alerts.filter((a) => !a.readAt) + if (!unread.length) return + // optimistic update for all + mutate( + (current) => + current + ? { + ...current, + alerts: current.alerts.map((a) => + a.readAt ? a : { ...a, readAt: new Date() as any }, + ), + } + : current, + { revalidate: false }, + ) + try { + await Promise.all(unread.map((a) => API.alerts.close(a.id))) + } finally { + await mutate(undefined, { revalidate: true }) + } + } + + const content = ( +
+ {isLoading ? ( +
+ +
+ ) : views.length === 0 ? ( + + ) : ( + item.key} + dataSource={views} + renderItem={(item) => ( + { + e.stopPropagation() + if (item.onOpen) await item.onOpen() + }} + > + {item.ctaLabel} + , + ] + : undefined + } + > + + + {item.title} + + + {item.courseName && ( + + {item.courseName} + + )} + + + {dayjs(item.sent).fromNow()} + + + } + description={ + item.description && ( + + {item.description} + + ) + } + /> + {!item.ctaLabel && ( + + )} + + )} + /> + )} + + {views.length > 0 && unreadCount > 0 && ( +
+ +
+ )} +
+ ) + + const trigger = ( + + + + ) + + return ( + setOpen(visible)} + overlayStyle={{ padding: 0, borderRadius: 8 }} + > + {trigger} + + ) +} + +export default NotificationBell diff --git a/packages/server/migration/1755000000000-add-async-question-alert-type.ts b/packages/server/migration/1755000000000-add-async-question-alert-type.ts new file mode 100644 index 000000000..c7344c2eb --- /dev/null +++ b/packages/server/migration/1755000000000-add-async-question-alert-type.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAsyncQuestionAlertType1755000000000 + implements MigrationInterface +{ + name = 'AddAsyncQuestionAlertType1755000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE "public"."alert_model_alerttype_enum" RENAME TO "alert_model_alerttype_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."alert_model_alerttype_enum" AS ENUM('rephraseQuestion', 'eventEndedCheckoutStaff', 'promptStudentToLeaveQueue', 'documentProcessed', 'asyncQuestionUpdate')`, + ); + await queryRunner.query( + `ALTER TABLE "alert_model" ALTER COLUMN "alertType" TYPE "public"."alert_model_alerttype_enum" USING "alertType"::text::"public"."alert_model_alerttype_enum"`, + ); + await queryRunner.query( + `DROP TYPE "public"."alert_model_alerttype_enum_old"`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."alert_model_alerttype_enum_old" AS ENUM('rephraseQuestion', 'eventEndedCheckoutStaff', 'promptStudentToLeaveQueue', 'documentProcessed')`, + ); + await queryRunner.query( + `ALTER TABLE "alert_model" ALTER COLUMN "alertType" TYPE "public"."alert_model_alerttype_enum_old" USING "alertType"::text::"public"."alert_model_alerttype_enum_old"`, + ); + await queryRunner.query(`DROP TYPE "public"."alert_model_alerttype_enum"`); + await queryRunner.query( + `ALTER TYPE "public"."alert_model_alerttype_enum_old" RENAME TO "alert_model_alerttype_enum"`, + ); + } +} diff --git a/packages/server/src/alerts/alerts.controller.ts b/packages/server/src/alerts/alerts.controller.ts index be6c41d8d..6b88cba5e 100644 --- a/packages/server/src/alerts/alerts.controller.ts +++ b/packages/server/src/alerts/alerts.controller.ts @@ -1,5 +1,6 @@ import { AlertDeliveryMode, + AlertType, CreateAlertParams, CreateAlertResponse, ERROR_MESSAGES, @@ -31,6 +32,38 @@ import { IsNull } from 'typeorm'; export class AlertsController { constructor(private alertsService: AlertsService) {} + @Get() + async getAllAlerts( + @UserId() userId: number, + @Query('mode') mode?: string, + @Query('includeRead') includeRead?: string, + ): Promise { + const parsedMode = Object.values(AlertDeliveryMode).includes( + (mode as AlertDeliveryMode) ?? AlertDeliveryMode.FEED, + ) + ? (mode as AlertDeliveryMode) || AlertDeliveryMode.FEED + : AlertDeliveryMode.FEED; + + const includeReadFlag = includeRead === 'true'; + + const where: Record = { + userId, + deliveryMode: parsedMode, + }; + + if (parsedMode === AlertDeliveryMode.MODAL) { + where.resolved = IsNull(); + } else if (!includeReadFlag) { + where.readAt = IsNull(); + } + + const alerts = await AlertModel.find({ + where, + order: { sent: 'DESC' }, + }); + return { alerts: await this.alertsService.removeStaleAlerts(alerts) }; + } + @Get(':courseId') async getAlerts( @Param('courseId', ParseIntPipe) courseId: number, diff --git a/packages/server/src/alerts/alerts.service.ts b/packages/server/src/alerts/alerts.service.ts index 4d56d0b23..97418e513 100644 --- a/packages/server/src/alerts/alerts.service.ts +++ b/packages/server/src/alerts/alerts.service.ts @@ -6,6 +6,7 @@ import { RephraseQuestionPayload, PromptStudentToLeaveQueuePayload, DocumentProcessedPayload, + AsyncQuestionUpdatePayload, } from '@koh/common'; import { pick } from 'lodash'; import { Injectable } from '@nestjs/common'; @@ -54,6 +55,7 @@ export class AlertsService { case AlertType.EVENT_ENDED_CHECKOUT_STAFF: case AlertType.PROMPT_STUDENT_TO_LEAVE_QUEUE: case AlertType.DOCUMENT_PROCESSED: + case AlertType.ASYNC_QUESTION_UPDATE: nonStaleAlerts.push( pick(alert, [ 'sent', @@ -94,12 +96,22 @@ export class AlertsService { ); case AlertType.DOCUMENT_PROCESSED: + case AlertType.ASYNC_QUESTION_UPDATE: const docPayload = payload as DocumentProcessedPayload; - return ( - typeof docPayload.documentId === 'number' && - typeof docPayload.documentName === 'string' && - docPayload.documentName.trim().length > 0 - ); + // For async question update, ensure course/question IDs exist; for document processed, ensure doc info exists + if ((alertType as AlertType) === AlertType.DOCUMENT_PROCESSED) { + return ( + typeof docPayload.documentId === 'number' && + typeof docPayload.documentName === 'string' && + docPayload.documentName.trim().length > 0 + ); + } else { + const asyncPayload = payload as AsyncQuestionUpdatePayload; + return ( + typeof (asyncPayload as any).courseId === 'number' && + typeof (asyncPayload as any).questionId === 'number' + ); + } default: return true; diff --git a/packages/server/src/asyncQuestion/asyncQuestion.service.ts b/packages/server/src/asyncQuestion/asyncQuestion.service.ts index 119a15143..c2239b379 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.service.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.service.ts @@ -1,4 +1,10 @@ -import { MailServiceType, parseThinkBlock, Role } from '@koh/common'; +import { + AlertDeliveryMode, + AlertType, + MailServiceType, + parseThinkBlock, + Role, +} from '@koh/common'; import { Injectable } from '@nestjs/common'; import { MailService } from 'mail/mail.service'; import { UserSubscriptionModel } from 'mail/user-subscriptions.entity'; @@ -10,6 +16,7 @@ import * as Sentry from '@sentry/nestjs'; import { UnreadAsyncQuestionModel } from './unread-async-question.entity'; import { CourseSettingsModel } from '../course/course_settings.entity'; import { SentEmailModel } from '../mail/sent-email.entity'; +import { AlertModel } from '../alerts/alerts.entity'; @Injectable() export class AsyncQuestionService { @@ -55,6 +62,19 @@ export class AsyncQuestionService { ); Sentry.captureException(err); }); + await AlertModel.create({ + alertType: AlertType.ASYNC_QUESTION_UPDATE, + deliveryMode: AlertDeliveryMode.FEED, + sent: new Date(), + userId: question.creator.id, + courseId: question.courseId, + payload: { + courseId: question.courseId, + questionId: question.id, + subtype: 'commentOnMyPost', + summary: `${commenterIsStaff ? commenter.name : 'Someone'} commented on your question`, + } as any, + }).save(); } } @@ -115,6 +135,24 @@ export class AsyncQuestionService { } }); }); + // FEED alerts for participants (exclude commenter and creator via the query) + await Promise.all( + subscriptions.map((sub) => + AlertModel.create({ + alertType: AlertType.ASYNC_QUESTION_UPDATE, + deliveryMode: AlertDeliveryMode.FEED, + sent: new Date(), + userId: sub.userId, + courseId: updatedQuestion.courseId, + payload: { + courseId: updatedQuestion.courseId, + questionId: updatedQuestion.id, + subtype: 'commentOnOthersPost', + summary: `${commenterIsStaff ? commenter.name : 'Someone'} commented on an Anytime Question you follow`, + } as any, + }).save(), + ), + ); } async sendNeedsAttentionEmail(question: AsyncQuestionModel) { @@ -203,6 +241,19 @@ export class AsyncQuestionService { console.error('Failed to send email Human Answered email: ' + err); Sentry.captureException(err); }); + await AlertModel.create({ + alertType: AlertType.ASYNC_QUESTION_UPDATE, + deliveryMode: AlertDeliveryMode.FEED, + sent: new Date(), + userId: question.creator.id, + courseId: question.courseId, + payload: { + courseId: question.courseId, + questionId: question.id, + subtype: 'humanAnswered', + summary: 'Your Anytime Question has been answered', + } as any, + }).save(); } } @@ -274,6 +325,19 @@ export class AsyncQuestionService { console.error('Failed to send email Status Changed email: ' + err); Sentry.captureException(err); }); + await AlertModel.create({ + alertType: AlertType.ASYNC_QUESTION_UPDATE, + deliveryMode: AlertDeliveryMode.FEED, + sent: new Date(), + userId: question.creator.id, + courseId: question.courseId, + payload: { + courseId: question.courseId, + questionId: question.id, + subtype: 'statusChanged', + summary: `Your Anytime Question status changed to ${status}`, + } as any, + }).save(); } } @@ -306,6 +370,19 @@ export class AsyncQuestionService { console.error('Failed to send email Vote Question email: ' + err); Sentry.captureException(err); }); + await AlertModel.create({ + alertType: AlertType.ASYNC_QUESTION_UPDATE, + deliveryMode: AlertDeliveryMode.FEED, + sent: new Date(), + userId: updatedQuestion.creator.id, + courseId: updatedQuestion.courseId, + payload: { + courseId: updatedQuestion.courseId, + questionId: updatedQuestion.id, + subtype: 'upvoted', + summary: 'Your Anytime Question was upvoted', + } as any, + }).save(); } } diff --git a/packages/server/src/queue/queue-clean/queue-clean.service.ts b/packages/server/src/queue/queue-clean/queue-clean.service.ts index 8db7a130d..b10888693 100644 --- a/packages/server/src/queue/queue-clean/queue-clean.service.ts +++ b/packages/server/src/queue/queue-clean/queue-clean.service.ts @@ -218,7 +218,7 @@ export class QueueCleanService { sent: new Date(), userId: student.studentId, courseId: student.courseId, - payload: { queueId, queueQuestionId: student.questionId }, + payload: { queueId, queueQuestionId: student.questionId } as any, }).save(); // if the student does not respond in 10 minutes, resolve the alert and mark the question as LeftDueToNoStaff const jobName = `prompt-student-to-leave-queue-${queueId}-${student.studentId}`; From df5f5c3bace62ef13b81b10e620dc90164b147b3 Mon Sep 17 00:00:00 2001 From: ribhavsharma Date: Sat, 11 Oct 2025 14:10:22 -0700 Subject: [PATCH 3/8] fix tests --- packages/common/index.ts | 9 ++++----- packages/server/src/alerts/alerts.entity.ts | 17 +++++++++++++++-- .../src/asyncQuestion/asyncQuestion.service.ts | 8 ++++---- .../queue/queue-clean/queue-clean.service.ts | 2 +- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/common/index.ts b/packages/common/index.ts index 528103252..05d5b4256 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -2239,11 +2239,7 @@ export enum AlertDeliveryMode { FEED = 'feed', } -export class AlertPayload { - @IsOptional() - @IsInt() - courseId?: number -} +export class AlertPayload {} export class Alert { @IsEnum(AlertType) @@ -2272,6 +2268,9 @@ export class RephraseQuestionPayload extends AlertPayload { @IsInt() queueId!: number + + @IsInt() + courseId!: number } export class PromptStudentToLeaveQueuePayload extends AlertPayload { diff --git a/packages/server/src/alerts/alerts.entity.ts b/packages/server/src/alerts/alerts.entity.ts index 808afa5c5..380c9a0b0 100644 --- a/packages/server/src/alerts/alerts.entity.ts +++ b/packages/server/src/alerts/alerts.entity.ts @@ -1,4 +1,12 @@ -import { AlertDeliveryMode, AlertPayload, AlertType } from '@koh/common'; +import { + AlertDeliveryMode, + AlertPayload, + AlertType, + RephraseQuestionPayload, + PromptStudentToLeaveQueuePayload, + DocumentProcessedPayload, + AsyncQuestionUpdatePayload, +} from '@koh/common'; import { Exclude } from 'class-transformer'; import { BaseEntity, @@ -52,5 +60,10 @@ export class AlertModel extends BaseEntity { courseId: number; @Column({ type: 'json' }) - payload: AlertPayload; + payload: + | AlertPayload + | RephraseQuestionPayload + | PromptStudentToLeaveQueuePayload + | DocumentProcessedPayload + | AsyncQuestionUpdatePayload; } diff --git a/packages/server/src/asyncQuestion/asyncQuestion.service.ts b/packages/server/src/asyncQuestion/asyncQuestion.service.ts index c2239b379..6617fc4a8 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.service.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.service.ts @@ -73,7 +73,7 @@ export class AsyncQuestionService { questionId: question.id, subtype: 'commentOnMyPost', summary: `${commenterIsStaff ? commenter.name : 'Someone'} commented on your question`, - } as any, + }, }).save(); } } @@ -149,7 +149,7 @@ export class AsyncQuestionService { questionId: updatedQuestion.id, subtype: 'commentOnOthersPost', summary: `${commenterIsStaff ? commenter.name : 'Someone'} commented on an Anytime Question you follow`, - } as any, + }, }).save(), ), ); @@ -336,7 +336,7 @@ export class AsyncQuestionService { questionId: question.id, subtype: 'statusChanged', summary: `Your Anytime Question status changed to ${status}`, - } as any, + }, }).save(); } } @@ -381,7 +381,7 @@ export class AsyncQuestionService { questionId: updatedQuestion.id, subtype: 'upvoted', summary: 'Your Anytime Question was upvoted', - } as any, + }, }).save(); } } diff --git a/packages/server/src/queue/queue-clean/queue-clean.service.ts b/packages/server/src/queue/queue-clean/queue-clean.service.ts index b10888693..8db7a130d 100644 --- a/packages/server/src/queue/queue-clean/queue-clean.service.ts +++ b/packages/server/src/queue/queue-clean/queue-clean.service.ts @@ -218,7 +218,7 @@ export class QueueCleanService { sent: new Date(), userId: student.studentId, courseId: student.courseId, - payload: { queueId, queueQuestionId: student.questionId } as any, + payload: { queueId, queueQuestionId: student.questionId }, }).save(); // if the student does not respond in 10 minutes, resolve the alert and mark the question as LeftDueToNoStaff const jobName = `prompt-student-to-leave-queue-${queueId}-${student.studentId}`; From 4ae661ea7a507529b74e71055ebbb427e315e9b0 Mon Sep 17 00:00:00 2001 From: ribhavsharma Date: Sat, 11 Oct 2025 14:25:26 -0700 Subject: [PATCH 4/8] fix test --- .../server/src/queue/queue-clean/queue-clean.service.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/server/src/queue/queue-clean/queue-clean.service.ts b/packages/server/src/queue/queue-clean/queue-clean.service.ts index 8db7a130d..f16b2d3d9 100644 --- a/packages/server/src/queue/queue-clean/queue-clean.service.ts +++ b/packages/server/src/queue/queue-clean/queue-clean.service.ts @@ -214,11 +214,15 @@ export class QueueCleanService { } const alert = await AlertModel.create({ alertType: AlertType.PROMPT_STUDENT_TO_LEAVE_QUEUE, - deliveryMode: AlertDeliveryMode.MODAL, sent: new Date(), userId: student.studentId, courseId: student.courseId, - payload: { queueId, queueQuestionId: student.questionId }, + payload: { + queueId, + ...(student.questionId !== undefined + ? { queueQuestionId: student.questionId } + : {}), + }, }).save(); // if the student does not respond in 10 minutes, resolve the alert and mark the question as LeftDueToNoStaff const jobName = `prompt-student-to-leave-queue-${queueId}-${student.studentId}`; From f78c9cfd099958653e1979045f5af81aaa7f75d2 Mon Sep 17 00:00:00 2001 From: ribhavsharma Date: Sat, 11 Oct 2025 14:56:01 -0700 Subject: [PATCH 5/8] fix other broken test --- packages/server/src/alerts/alerts.service.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/server/src/alerts/alerts.service.ts b/packages/server/src/alerts/alerts.service.ts index 97418e513..1bab81b35 100644 --- a/packages/server/src/alerts/alerts.service.ts +++ b/packages/server/src/alerts/alerts.service.ts @@ -40,14 +40,7 @@ export class AlertsService { await alert.save(); } else { nonStaleAlerts.push( - pick(alert, [ - 'sent', - 'alertType', - 'payload', - 'id', - 'deliveryMode', - 'readAt', - ]), + pick(alert, ['sent', 'alertType', 'payload', 'id']), ); } break; From bfd787094ca77335c6bee88e6394a61be0e01ae2 Mon Sep 17 00:00:00 2001 From: ribhavsharma Date: Sun, 26 Oct 2025 19:42:41 -0700 Subject: [PATCH 6/8] addressing comments pt1 --- packages/common/index.ts | 15 ++ packages/frontend/app/api/index.ts | 12 +- .../app/components/NotificationBell.tsx | 158 +++++++++++------- .../server/src/alerts/alerts-clean.service.ts | 20 +++ .../server/src/alerts/alerts.controller.ts | 81 +++++++-- packages/server/src/alerts/alerts.module.ts | 4 +- .../asyncQuestion/asyncQuestion.service.ts | 41 +++-- 7 files changed, 241 insertions(+), 90 deletions(-) create mode 100644 packages/server/src/alerts/alerts-clean.service.ts diff --git a/packages/common/index.ts b/packages/common/index.ts index 05d5b4256..075261d27 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -2234,6 +2234,18 @@ export enum AlertType { ASYNC_QUESTION_UPDATE = 'asyncQuestionUpdate', } +// Allowed combinations to enforce front/back consistency +export const FEED_ALERT_TYPES: AlertType[] = [ + AlertType.DOCUMENT_PROCESSED, + AlertType.ASYNC_QUESTION_UPDATE, +] + +export const MODAL_ALERT_TYPES: AlertType[] = [ + AlertType.REPHRASE_QUESTION, + AlertType.EVENT_ENDED_CHECKOUT_STAFF, + AlertType.PROMPT_STUDENT_TO_LEAVE_QUEUE, +] + export enum AlertDeliveryMode { MODAL = 'modal', FEED = 'feed', @@ -2358,6 +2370,9 @@ export class CreateAlertResponse extends Alert {} export class GetAlertsResponse { @Type(() => Alert) alerts!: Alert[] + @IsOptional() + @IsInt() + total?: number } // not used anywhere diff --git a/packages/frontend/app/api/index.ts b/packages/frontend/app/api/index.ts index a4b67c3c7..1f2ae9e42 100644 --- a/packages/frontend/app/api/index.ts +++ b/packages/frontend/app/api/index.ts @@ -1079,20 +1079,30 @@ class APIClient { } alerts = { + markReadAll: async (): Promise => + this.req('PATCH', `/api/v1/alerts/mark-read-all`), get: async (courseId: number): Promise => this.req('GET', `/api/v1/alerts/${courseId}`), getAll: async ( mode: AlertDeliveryMode = AlertDeliveryMode.FEED, - includeRead: boolean = false, + includeRead: boolean = true, + limit?: number, + offset?: number, ): Promise => this.req('GET', `/api/v1/alerts`, undefined, undefined, { mode, includeRead: includeRead ? 'true' : 'false', + ...(limit !== undefined ? { limit: String(limit) } : {}), + ...(offset !== undefined ? { offset: String(offset) } : {}), }), create: async (params: CreateAlertParams): Promise => this.req('POST', `/api/v1/alerts`, CreateAlertResponse, params), close: async (alertId: number): Promise => this.req('PATCH', `/api/v1/alerts/${alertId}`), + markReadBulk: async (alertIds: number[]): Promise => + this.req('PATCH', `/api/v1/alerts/mark-read-bulk`, undefined, { + alertIds, + }), } organizations = { diff --git a/packages/frontend/app/components/NotificationBell.tsx b/packages/frontend/app/components/NotificationBell.tsx index ed51bba6b..ae94f257d 100644 --- a/packages/frontend/app/components/NotificationBell.tsx +++ b/packages/frontend/app/components/NotificationBell.tsx @@ -8,6 +8,7 @@ import { DocumentProcessedPayload, GetCourseResponse, AsyncQuestionUpdatePayload, + FEED_ALERT_TYPES, } from '@koh/common' import { Badge, @@ -20,8 +21,9 @@ import { Typography, Tag, } from 'antd' -import { BellOutlined } from '@ant-design/icons' +import { Bell } from 'lucide-react' import useSWR from 'swr' +import useSWRInfinite from 'swr/infinite' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' import { API } from '@/app/api' @@ -89,30 +91,37 @@ const NotificationBell: React.FC = ({ const router = useRouter() const [open, setOpen] = useState(false) - const { data, isLoading, mutate } = useSWR( - ['alerts-feed'], - async () => API.alerts.getAll(AlertDeliveryMode.FEED), - { revalidateOnFocus: true }, - ) + const PAGE_SIZE = 5 + const { data, isLoading, isValidating, size, setSize, mutate } = + useSWRInfinite( + (index) => ['alerts-feed', index, PAGE_SIZE], + async ([, indexKey, pageSize]) => + API.alerts.getAll( + AlertDeliveryMode.FEED, + false, + pageSize as number, + (indexKey as number) * (pageSize as number), + ), + { revalidateOnFocus: true }, + ) - const alerts: Alert[] = useMemo(() => data?.alerts ?? [], [data]) + const pages = data ?? [] + const [currentPage, setCurrentPage] = useState(0) + const currentPageAlerts: Alert[] = pages[currentPage]?.alerts ?? [] + const total = pages[0]?.total ?? 0 - const unreadCount = useMemo( - () => alerts.filter((alert) => !alert.readAt).length, - [alerts], - ) + const unreadCount = useMemo(() => total, [total]) const uniqueCourseIds = useMemo(() => { const ids = new Set() - for (const alert of alerts) { + for (const alert of currentPageAlerts) { if ((alert.payload as any)?.courseId) { ids.add((alert.payload as any).courseId) } } return Array.from(ids) - }, [alerts]) + }, [currentPageAlerts]) - // fetch course names for each courseId in alerts const { data: courseResponses } = useSWR( uniqueCourseIds.length > 0 ? ['courses-for-alerts', uniqueCourseIds] : null, async () => { @@ -136,17 +145,21 @@ const NotificationBell: React.FC = ({ const markAsRead = async (alert: Alert) => { if (alert.readAt) return - // optimistic update mutate( - (current) => - current - ? { - ...current, - alerts: current.alerts.map((a) => - a.id === alert.id ? { ...a, readAt: new Date() as any } : a, - ), - } - : current, + (currentPages) => + currentPages + ? currentPages.map((page, idx) => ({ + ...page, + alerts: + idx === currentPage && Array.isArray(page.alerts) + ? page.alerts.filter((a: Alert) => a.id !== alert.id) + : page.alerts, + total: + idx === 0 && typeof page.total === 'number' + ? Math.max(0, (page.total ?? 0) - 1) + : page.total, + })) + : currentPages, { revalidate: false }, ) try { @@ -164,8 +177,12 @@ const NotificationBell: React.FC = ({ } const views: NotificationView[] = useMemo(() => { - return alerts - .filter((alert) => alert.deliveryMode === AlertDeliveryMode.FEED) + return currentPageAlerts + .filter( + (alert) => + alert.deliveryMode === AlertDeliveryMode.FEED && + FEED_ALERT_TYPES.includes(alert.alertType), + ) .map((alert) => { const sentAt = new Date(alert.sent) const courseId = (alert.payload as any)?.courseId @@ -234,31 +251,31 @@ const NotificationBell: React.FC = ({ } }) .sort((a, b) => b.sent.getTime() - a.sent.getTime()) - }, [alerts, courseNameMap]) + }, [currentPageAlerts, courseNameMap]) const markAllRead = async () => { - const unread = alerts.filter((a) => !a.readAt) - if (!unread.length) return - // optimistic update for all + // optimistic: clear all loaded alerts and set total to 0 mutate( - (current) => - current - ? { - ...current, - alerts: current.alerts.map((a) => - a.readAt ? a : { ...a, readAt: new Date() as any }, - ), - } - : current, + (currentPages) => + currentPages + ? currentPages.map((p, idx) => ({ + ...p, + alerts: [], + total: idx === 0 ? 0 : p.total, + })) + : currentPages, { revalidate: false }, ) try { - await Promise.all(unread.map((a) => API.alerts.close(a.id))) + await API.alerts.markReadAll() } finally { await mutate(undefined, { revalidate: true }) } } + const isLoadingMore = isValidating && size > 0 + const hasMore = (pages[pages.length - 1]?.alerts?.length ?? 0) === PAGE_SIZE + const content = (
{isLoading ? ( @@ -373,28 +390,47 @@ const NotificationBell: React.FC = ({ /> )} - {views.length > 0 && unreadCount > 0 && ( -
+
+ {total > 0 ? ( +
+ + + + {`${currentPage * PAGE_SIZE + 1}-${currentPage * PAGE_SIZE + (currentPageAlerts?.length || 0)} of ${total}`} + +
+ ) : ( + + )} + {total > 0 && ( -
- )} + )} +
) - const trigger = ( - - - - ) - return ( = ({ onOpenChange={(visible) => setOpen(visible)} overlayStyle={{ padding: 0, borderRadius: 8 }} > - {trigger} + + + ) } diff --git a/packages/server/src/alerts/alerts-clean.service.ts b/packages/server/src/alerts/alerts-clean.service.ts new file mode 100644 index 000000000..080ed609f --- /dev/null +++ b/packages/server/src/alerts/alerts-clean.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { AlertModel } from './alerts.entity'; +import { AlertDeliveryMode } from '@koh/common'; + +@Injectable() +export class AlertsCleanService { + // Daily cleanup: delete FEED alerts read more than 30 days ago + @Cron(CronExpression.EVERY_DAY_AT_3AM) + async pruneOldReadFeed(): Promise { + const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + await AlertModel.createQueryBuilder() + .delete() + .from(AlertModel) + .where('"deliveryMode" = :mode', { mode: AlertDeliveryMode.FEED }) + .andWhere('"readAt" IS NOT NULL') + .andWhere('"readAt" < :cutoff', { cutoff }) + .execute(); + } +} diff --git a/packages/server/src/alerts/alerts.controller.ts b/packages/server/src/alerts/alerts.controller.ts index 6b88cba5e..78a801642 100644 --- a/packages/server/src/alerts/alerts.controller.ts +++ b/packages/server/src/alerts/alerts.controller.ts @@ -6,6 +6,8 @@ import { ERROR_MESSAGES, GetAlertsResponse, Role, + FEED_ALERT_TYPES, + MODAL_ALERT_TYPES, } from '@koh/common'; import { BadRequestException, @@ -18,6 +20,8 @@ import { Post, Query, UseGuards, + ParseBoolPipe, + ParseEnumPipe, } from '@nestjs/common'; import { JwtAuthGuard } from 'guards/jwt-auth.guard'; import { UserId } from 'decorators/user.decorator'; @@ -32,36 +36,65 @@ import { IsNull } from 'typeorm'; export class AlertsController { constructor(private alertsService: AlertsService) {} + @Patch('mark-read-bulk') + async markReadBulk( + @UserId() userId: number, + @Body('alertIds') alertIds: number[], + ): Promise { + if (!Array.isArray(alertIds) || alertIds.length === 0) return; + await AlertModel.createQueryBuilder() + .update(AlertModel) + .set({ readAt: () => 'NOW()' }) + .where('id IN (:...ids)', { ids: alertIds }) + .andWhere('userId = :userId', { userId }) + .andWhere('deliveryMode = :mode', { mode: AlertDeliveryMode.FEED }) + .execute(); + } + + // Mark all unread FEED alerts for current user as read + @Patch('mark-read-all') + async markReadAll(@UserId() userId: number): Promise { + console.log('Marking all feed alerts as read for user', userId); + await AlertModel.createQueryBuilder() + .update(AlertModel) + .set({ readAt: () => 'NOW()' }) + .where('userId = :userId', { userId }) + .andWhere('deliveryMode = :mode', { mode: AlertDeliveryMode.FEED }) + .andWhere('readAt IS NULL') + .execute(); + } + @Get() async getAllAlerts( @UserId() userId: number, - @Query('mode') mode?: string, - @Query('includeRead') includeRead?: string, + @Query('mode', new ParseEnumPipe(AlertDeliveryMode)) + mode: AlertDeliveryMode = AlertDeliveryMode.FEED, + @Query('includeRead', ParseBoolPipe) includeRead = true, + @Query('limit', ParseIntPipe) limit = 20, + @Query('offset', ParseIntPipe) offset = 0, ): Promise { - const parsedMode = Object.values(AlertDeliveryMode).includes( - (mode as AlertDeliveryMode) ?? AlertDeliveryMode.FEED, - ) - ? (mode as AlertDeliveryMode) || AlertDeliveryMode.FEED - : AlertDeliveryMode.FEED; - - const includeReadFlag = includeRead === 'true'; - const where: Record = { userId, - deliveryMode: parsedMode, + deliveryMode: mode, }; - if (parsedMode === AlertDeliveryMode.MODAL) { + if (mode === AlertDeliveryMode.MODAL) { where.resolved = IsNull(); - } else if (!includeReadFlag) { + } else if (!includeRead) { where.readAt = IsNull(); } + const total = await AlertModel.count({ where }); const alerts = await AlertModel.find({ where, - order: { sent: 'DESC' }, + order: { readAt: 'ASC', sent: 'DESC' }, + take: Math.max(1, Math.min(limit, 100)), + skip: Math.max(0, offset), }); - return { alerts: await this.alertsService.removeStaleAlerts(alerts) }; + return { + alerts: await this.alertsService.removeStaleAlerts(alerts), + total, + }; } @Get(':courseId') @@ -91,11 +124,15 @@ export class AlertsController { where.readAt = IsNull(); } + const total = await AlertModel.count({ where }); const alerts = await AlertModel.find({ where, order: { sent: 'DESC' }, }); - return { alerts: await this.alertsService.removeStaleAlerts(alerts) }; + return { + alerts: await this.alertsService.removeStaleAlerts(alerts), + total, + }; } @Post() @@ -112,6 +149,18 @@ export class AlertsController { ); } + // Enforce allowed alert types per delivery mode + if ( + (parsedMode === AlertDeliveryMode.FEED && + !FEED_ALERT_TYPES.includes(alertType)) || + (parsedMode === AlertDeliveryMode.MODAL && + !MODAL_ALERT_TYPES.includes(alertType)) + ) { + throw new BadRequestException( + 'Invalid alert type for selected delivery mode', + ); + } + if (parsedMode === AlertDeliveryMode.MODAL) { const anotherAlert = await AlertModel.findOne({ where: { diff --git a/packages/server/src/alerts/alerts.module.ts b/packages/server/src/alerts/alerts.module.ts index 36ad28244..4b0f47570 100644 --- a/packages/server/src/alerts/alerts.module.ts +++ b/packages/server/src/alerts/alerts.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; import { AlertsController } from './alerts.controller'; import { AlertsService } from './alerts.service'; +import { AlertsCleanService } from './alerts-clean.service'; @Module({ + imports: [], controllers: [AlertsController], - providers: [AlertsService], + providers: [AlertsService, AlertsCleanService], }) export class AlertsModule {} diff --git a/packages/server/src/asyncQuestion/asyncQuestion.service.ts b/packages/server/src/asyncQuestion/asyncQuestion.service.ts index 6617fc4a8..63fdc2b92 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.service.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.service.ts @@ -62,20 +62,23 @@ export class AsyncQuestionService { ); Sentry.captureException(err); }); - await AlertModel.create({ - alertType: AlertType.ASYNC_QUESTION_UPDATE, - deliveryMode: AlertDeliveryMode.FEED, - sent: new Date(), - userId: question.creator.id, - courseId: question.courseId, - payload: { - courseId: question.courseId, - questionId: question.id, - subtype: 'commentOnMyPost', - summary: `${commenterIsStaff ? commenter.name : 'Someone'} commented on your question`, - }, - }).save(); } + + const commenterIsStaff = + commenterRole === Role.TA || commenterRole === Role.PROFESSOR; + await AlertModel.create({ + alertType: AlertType.ASYNC_QUESTION_UPDATE, + deliveryMode: AlertDeliveryMode.FEED, + sent: new Date(), + userId: question.creator.id, + courseId: question.courseId, + payload: { + courseId: question.courseId, + questionId: question.id, + subtype: 'commentOnMyPost', + summary: `${commenterIsStaff ? commenter.name : 'Someone'} commented on your question`, + }, + }).save(); } /*send emails out to all users that have posted a comment on this question. @@ -136,13 +139,21 @@ export class AsyncQuestionService { }); }); // FEED alerts for participants (exclude commenter and creator via the query) + // FEED alerts to all participants who commented (excluding current commenter and creator), regardless of email subscriptions + const participantIds = Array.from( + new Set( + updatedQuestion.comments + .map((c) => c.creator.id) + .filter((id) => id !== commenter.id && id !== questionCreatorId), + ), + ); await Promise.all( - subscriptions.map((sub) => + participantIds.map((uid) => AlertModel.create({ alertType: AlertType.ASYNC_QUESTION_UPDATE, deliveryMode: AlertDeliveryMode.FEED, sent: new Date(), - userId: sub.userId, + userId: uid, courseId: updatedQuestion.courseId, payload: { courseId: updatedQuestion.courseId, From 4ea701e95ca9b312441d558473fc802f5c9bef1b Mon Sep 17 00:00:00 2001 From: ribhavsharma Date: Mon, 27 Oct 2025 12:58:34 -0700 Subject: [PATCH 7/8] addressing more comments --- .../app/components/NotificationBell.tsx | 293 +++++++++--------- .../asyncQuestion/asyncQuestion.controller.ts | 8 +- .../asyncQuestion/asyncQuestion.service.ts | 9 +- 3 files changed, 151 insertions(+), 159 deletions(-) diff --git a/packages/frontend/app/components/NotificationBell.tsx b/packages/frontend/app/components/NotificationBell.tsx index ae94f257d..1385062f2 100644 --- a/packages/frontend/app/components/NotificationBell.tsx +++ b/packages/frontend/app/components/NotificationBell.tsx @@ -40,7 +40,6 @@ type AlertMetadata = { type NotificationBellProps = { showText?: boolean - onAfterNavigate?: () => void className?: string } @@ -85,7 +84,6 @@ const alertMeta: Record = { const NotificationBell: React.FC = ({ showText = false, - onAfterNavigate, className, }) => { const router = useRouter() @@ -173,7 +171,6 @@ const NotificationBell: React.FC = ({ (alert: Alert, url: string) => async (): Promise => { await markAsRead(alert) router.push(url) - onAfterNavigate?.() } const views: NotificationView[] = useMemo(() => { @@ -276,164 +273,160 @@ const NotificationBell: React.FC = ({ const isLoadingMore = isValidating && size > 0 const hasMore = (pages[pages.length - 1]?.alerts?.length ?? 0) === PAGE_SIZE - const content = ( -
- {isLoading ? ( -
- -
- ) : views.length === 0 ? ( - - ) : ( - item.key} - dataSource={views} - renderItem={(item) => ( - { - e.stopPropagation() - if (item.onOpen) await item.onOpen() - }} + return ( + + {isLoading ? ( +
+ +
+ ) : views.length === 0 ? ( + + ) : ( + item.key} + dataSource={views} + renderItem={(item) => ( + { + e.stopPropagation() + if (item.onOpen) await item.onOpen() + }} + > + {item.ctaLabel} + , + ] + : undefined + } + > + - {item.ctaLabel} - , - ] - : undefined - } - > - - + {item.title} + + {item.courseName && ( + + {item.courseName} + + )} + + {dayjs(item.sent).fromNow()} + + + } + description={ + item.description && ( + + {item.description} + + ) + } + /> + {!item.ctaLabel && ( + + )} + + )} + /> + )} - - {dayjs(item.sent).fromNow()} - - - } - description={ - item.description && ( - - {item.description} - - ) - } - /> - {!item.ctaLabel && ( +
+ {total > 0 ? ( +
+ - )} - - )} - /> - )} - -
- {total > 0 ? ( -
- - - - {`${currentPage * PAGE_SIZE + 1}-${currentPage * PAGE_SIZE + (currentPageAlerts?.length || 0)} of ${total}`} - + + {`${currentPage * PAGE_SIZE + 1}-${currentPage * PAGE_SIZE + (currentPageAlerts?.length || 0)} of ${total}`} + +
+ ) : ( + + )} + {total > 0 && ( + + )}
- ) : ( - - )} - {total > 0 && ( - - )} -
-
- ) - - return ( - + } trigger="click" placement="bottomRight" open={open} diff --git a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts index f2f3bb972..ecd6d0a95 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts @@ -122,7 +122,7 @@ export class asyncQuestionController { // Check if the question was upvoted and send email if subscribed if (newValue === 1 && userId !== updatedQuestion.creator.id) { - await this.asyncQuestionService.sendUpvotedEmail(updatedQuestion); + await this.asyncQuestionService.sendUpvotedEmailAndAlert(updatedQuestion); } return res.status(HttpStatus.OK).send({ @@ -382,7 +382,7 @@ export class asyncQuestionController { // don't send status change email if its deleted // (I don't like the vibes of notifying a student that their question was deleted by staff) // Though technically speaking this isn't even really used yet since there isn't a status that the TA would really turn it to that isn't HumanAnswered or TADeleted - await this.asyncQuestionService.sendGenericStatusChangeEmail( + await this.asyncQuestionService.sendGenericStatusChangeEmailAndAlert( question, body.status, ); @@ -516,7 +516,7 @@ export class asyncQuestionController { // don't send email if its a comment on your own post if (question.creatorId !== user.id) { - await this.asyncQuestionService.sendNewCommentOnMyQuestionEmail( + await this.asyncQuestionService.sendNewCommentOnMyQuestionEmailAndAlert( user, courseRole, updatedQuestion, @@ -524,7 +524,7 @@ export class asyncQuestionController { ); } // send emails out to all users that have posted a comment on this question (it also performs checks) - await this.asyncQuestionService.sendNewCommentOnOtherQuestionEmail( + await this.asyncQuestionService.sendNewCommentOnOtherQuestionEmailAndAlert( user, courseRole, question.creatorId, diff --git a/packages/server/src/asyncQuestion/asyncQuestion.service.ts b/packages/server/src/asyncQuestion/asyncQuestion.service.ts index 63fdc2b92..ce112a6b4 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.service.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.service.ts @@ -22,7 +22,7 @@ import { AlertModel } from '../alerts/alerts.entity'; export class AsyncQuestionService { constructor(private mailService: MailService) {} - async sendNewCommentOnMyQuestionEmail( + async sendNewCommentOnMyQuestionEmailAndAlert( commenter: UserModel, commenterRole: Role, question: AsyncQuestionModel, @@ -84,7 +84,7 @@ export class AsyncQuestionService { /*send emails out to all users that have posted a comment on this question. Note that updatedQuestion must have comments and comments.creator relations */ - async sendNewCommentOnOtherQuestionEmail( + async sendNewCommentOnOtherQuestionEmailAndAlert( commenter: UserModel, commenterRole: Role, questionCreatorId: number, @@ -138,7 +138,6 @@ export class AsyncQuestionService { } }); }); - // FEED alerts for participants (exclude commenter and creator via the query) // FEED alerts to all participants who commented (excluding current commenter and creator), regardless of email subscriptions const participantIds = Array.from( new Set( @@ -304,7 +303,7 @@ export class AsyncQuestionService { } /* Not really used right now since the only status that staff can change is changing it to "Human Answered" */ - async sendGenericStatusChangeEmail( + async sendGenericStatusChangeEmailAndAlert( question: AsyncQuestionModel, status: string, ) { @@ -352,7 +351,7 @@ export class AsyncQuestionService { } } - async sendUpvotedEmail(updatedQuestion: AsyncQuestionModel) { + async sendUpvotedEmailAndAlert(updatedQuestion: AsyncQuestionModel) { const subscription = await UserSubscriptionModel.findOne({ where: { userId: updatedQuestion.creator.id, From a4b5c52e1ac7be076006dcb7cfef2129acb7100e Mon Sep 17 00:00:00 2001 From: ribhavsharma Date: Mon, 27 Oct 2025 13:41:20 -0700 Subject: [PATCH 8/8] address comments, use alertsContext --- .../app/(dashboard)/course/[cid]/layout.tsx | 23 ++- packages/frontend/app/(dashboard)/layout.tsx | 65 +++---- .../app/components/AlertsContainer.tsx | 22 +-- .../app/components/NotificationBell.tsx | 87 +++------- .../frontend/app/contexts/alertsContext.tsx | 160 ++++++++++++++++++ 5 files changed, 243 insertions(+), 114 deletions(-) create mode 100644 packages/frontend/app/contexts/alertsContext.tsx diff --git a/packages/frontend/app/(dashboard)/course/[cid]/layout.tsx b/packages/frontend/app/(dashboard)/course/[cid]/layout.tsx index 492acb510..a927b272c 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/layout.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/layout.tsx @@ -1,7 +1,10 @@ 'use client' -import { use } from 'react' +import { use, useEffect } from 'react' +import useSWR from 'swr' import AlertsContainer from '@/app/components/AlertsContainer' +import { useAlertsContext } from '@/app/contexts/alertsContext' +import { API } from '@/app/api' type Params = Promise<{ cid: string }> @@ -15,6 +18,24 @@ export default function Layout(props: { const { cid } = params + const { setThisCourseAlerts, clearThisCourseAlerts } = useAlertsContext() + + const { data } = useSWR( + `/api/v1/alerts/course/${cid}`, + async () => API.alerts.get(Number(cid)), + { refreshInterval: 60000 }, + ) + + useEffect(() => { + setThisCourseAlerts(data?.alerts ?? []) + }, [data?.alerts]) + + useEffect(() => { + return () => { + clearThisCourseAlerts() + } + }, []) + return ( <> diff --git a/packages/frontend/app/(dashboard)/layout.tsx b/packages/frontend/app/(dashboard)/layout.tsx index 8d83ae080..780723de8 100644 --- a/packages/frontend/app/(dashboard)/layout.tsx +++ b/packages/frontend/app/(dashboard)/layout.tsx @@ -15,6 +15,7 @@ import ChatbotContextProvider from './course/[cid]/components/chatbot/ChatbotPro import FooterBar from './components/FooterBar' import { AsyncToasterProvider } from '../contexts/AsyncToasterContext' import { LogoutOutlined, ReloadOutlined } from '@ant-design/icons' +import { AlertsProvider } from '../contexts/alertsContext' import { getErrorMessage } from '../utils/generalUtils' const Layout: React.FC = ({ children }) => { @@ -96,37 +97,39 @@ const Layout: React.FC = ({ children }) => { ) : ( -
- - - Skip to main content - - - -
- {/* This flex flex-grow is needed so that the scroll bar doesn't show up on every page */} -
- - {pathname === '/courses' && ( - Organization Banner - )} - {/* On certain pages (like pages with big tables), we want to let the width take up the whole page */} - {URLSegments[4] === 'edit_questions' || - URLSegments[4] === 'chatbot_questions' ? ( -
{children}
- ) : ( - - {children} - - )} -
-
+ +
+ + + Skip to main content + + + +
+ {/* This flex flex-grow is needed so that the scroll bar doesn't show up on every page */} +
+ + {pathname === '/courses' && ( + Organization Banner + )} + {/* On certain pages (like pages with big tables), we want to let the width take up the whole page */} + {URLSegments[4] === 'edit_questions' || + URLSegments[4] === 'chatbot_questions' ? ( +
{children}
+ ) : ( + + {children} + + )} +
+
+
diff --git a/packages/frontend/app/components/AlertsContainer.tsx b/packages/frontend/app/components/AlertsContainer.tsx index eac861208..8d17e50fb 100644 --- a/packages/frontend/app/components/AlertsContainer.tsx +++ b/packages/frontend/app/components/AlertsContainer.tsx @@ -3,10 +3,10 @@ import { PromptStudentToLeaveQueuePayload, RephraseQuestionPayload, } from '@koh/common' -import useSWR from 'swr' import { useRouter } from 'next/navigation' import StudentRephraseModal from '../(dashboard)/course/[cid]/queue/[qid]/components/modals/StudentRephraseModal' import { API } from '../api' +import { useAlertsContext } from '@/app/contexts/alertsContext' import EventEndedCheckoutStaffModal from '../(dashboard)/course/[cid]/queue/[qid]/components/modals/EventEndedCheckoutStaffModal' import PromptStudentToLeaveQueueModal from '../(dashboard)/course/[cid]/queue/[qid]/components/modals/PromptStudentToLeaveQueueModal' @@ -15,23 +15,15 @@ type AlertsContainerProps = { } const AlertsContainer: React.FC = ({ courseId }) => { const router = useRouter() - const { data, mutate: mutateAlerts } = useSWR( - '/api/v1/alerts', - async () => API.alerts.get(courseId), - { - refreshInterval: 60000, // revalidate every minute - }, - ) - const alerts = data?.alerts + const { thisCourseAlerts, closeCourseAlert } = useAlertsContext() + const alerts = thisCourseAlerts const handleCloseRephrase = async ( alertId: number, courseId: number, queueId: number, ) => { - await API.alerts.close(alertId) - - await mutateAlerts() + await closeCourseAlert(alertId) router.push(`/course/${courseId}/queue/${queueId}?edit_question=true`) } @@ -51,8 +43,7 @@ const AlertsContainer: React.FC = ({ courseId }) => { { - await API.alerts.close(alert.id) - await mutateAlerts() + await closeCourseAlert(alert.id) }} /> ) @@ -67,8 +58,7 @@ const AlertsContainer: React.FC = ({ courseId }) => { .queueQuestionId } handleClose={async () => { - await API.alerts.close(alert.id) - await mutateAlerts() + await closeCourseAlert(alert.id) }} /> ) diff --git a/packages/frontend/app/components/NotificationBell.tsx b/packages/frontend/app/components/NotificationBell.tsx index 1385062f2..798d3c3a8 100644 --- a/packages/frontend/app/components/NotificationBell.tsx +++ b/packages/frontend/app/components/NotificationBell.tsx @@ -23,10 +23,10 @@ import { } from 'antd' import { Bell } from 'lucide-react' import useSWR from 'swr' -import useSWRInfinite from 'swr/infinite' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' import { API } from '@/app/api' +import { useAlertsContext } from '@/app/contexts/alertsContext' import { useRouter } from 'next/navigation' const { Text } = Typography @@ -89,24 +89,19 @@ const NotificationBell: React.FC = ({ const router = useRouter() const [open, setOpen] = useState(false) - const PAGE_SIZE = 5 - const { data, isLoading, isValidating, size, setSize, mutate } = - useSWRInfinite( - (index) => ['alerts-feed', index, PAGE_SIZE], - async ([, indexKey, pageSize]) => - API.alerts.getAll( - AlertDeliveryMode.FEED, - false, - pageSize as number, - (indexKey as number) * (pageSize as number), - ), - { revalidateOnFocus: true }, - ) - - const pages = data ?? [] - const [currentPage, setCurrentPage] = useState(0) - const currentPageAlerts: Alert[] = pages[currentPage]?.alerts ?? [] - const total = pages[0]?.total ?? 0 + const { + total, + isLoading, + isValidating, + size, + setSize, + currentPage, + setCurrentPage, + currentPageAlerts, + markRead, + markAllRead, + pageSize, + } = useAlertsContext() const unreadCount = useMemo(() => total, [total]) @@ -143,28 +138,7 @@ const NotificationBell: React.FC = ({ const markAsRead = async (alert: Alert) => { if (alert.readAt) return - mutate( - (currentPages) => - currentPages - ? currentPages.map((page, idx) => ({ - ...page, - alerts: - idx === currentPage && Array.isArray(page.alerts) - ? page.alerts.filter((a: Alert) => a.id !== alert.id) - : page.alerts, - total: - idx === 0 && typeof page.total === 'number' - ? Math.max(0, (page.total ?? 0) - 1) - : page.total, - })) - : currentPages, - { revalidate: false }, - ) - try { - await API.alerts.close(alert.id) - } finally { - await mutate(undefined, { revalidate: true }) - } + await markRead(alert.id) } const handleNavigate = @@ -250,29 +224,10 @@ const NotificationBell: React.FC = ({ .sort((a, b) => b.sent.getTime() - a.sent.getTime()) }, [currentPageAlerts, courseNameMap]) - const markAllRead = async () => { - // optimistic: clear all loaded alerts and set total to 0 - mutate( - (currentPages) => - currentPages - ? currentPages.map((p, idx) => ({ - ...p, - alerts: [], - total: idx === 0 ? 0 : p.total, - })) - : currentPages, - { revalidate: false }, - ) - try { - await API.alerts.markReadAll() - } finally { - await mutate(undefined, { revalidate: true }) - } + const markAllReadLocal = async () => { + await markAllRead() } - const isLoadingMore = isValidating && size > 0 - const hasMore = (pages[pages.length - 1]?.alerts?.length ?? 0) === PAGE_SIZE - return ( = ({ type="link" size="small" disabled={currentPage <= 0} - onClick={() => setCurrentPage((p) => Math.max(0, p - 1))} + onClick={() => setCurrentPage(Math.max(0, currentPage - 1))} > Prev - {`${currentPage * PAGE_SIZE + 1}-${currentPage * PAGE_SIZE + (currentPageAlerts?.length || 0)} of ${total}`} + {`${currentPage * pageSize + 1}-${currentPage * pageSize + (currentPageAlerts?.length || 0)} of ${total}`}
) : ( )} {total > 0 && ( - )} diff --git a/packages/frontend/app/contexts/alertsContext.tsx b/packages/frontend/app/contexts/alertsContext.tsx new file mode 100644 index 000000000..cea29a9d0 --- /dev/null +++ b/packages/frontend/app/contexts/alertsContext.tsx @@ -0,0 +1,160 @@ +'use client' + +import React, { createContext, useContext, useMemo, useState } from 'react' +import useSWRInfinite from 'swr/infinite' +import { API } from '@/app/api' +import { Alert, AlertDeliveryMode, GetAlertsResponse } from '@koh/common' + +type AlertsContextValue = { + pages: GetAlertsResponse[] + total: number + isLoading: boolean + isValidating: boolean + size: number + setSize: (s: number) => void + mutate: (data?: any, opts?: any) => Promise + currentPage: number + setCurrentPage: (p: number) => void + currentPageAlerts: Alert[] + markRead: (alertId: number) => Promise + markAllRead: () => Promise + pageSize: number + // course-level alerts state (populated by course layout) + thisCourseAlerts: Alert[] + setThisCourseAlerts: (alerts: Alert[]) => void + clearThisCourseAlerts: () => void + closeCourseAlert: (alertId: number) => Promise +} + +const AlertsContext = createContext(undefined) + +export const AlertsProvider: React.FC<{ + children: React.ReactNode + pageSize?: number +}> = ({ children, pageSize = 5 }) => { + const { data, isLoading, isValidating, size, setSize, mutate } = + useSWRInfinite( + (index) => ['alerts-feed', index, pageSize], + async ([, indexKey, ps]) => + API.alerts.getAll( + AlertDeliveryMode.FEED, + false, + ps as number, + (indexKey as number) * (ps as number), + ), + { revalidateOnFocus: true }, + ) + + const [currentPage, setCurrentPage] = useState(0) + const pages = data ?? [] + const total = pages[0]?.total ?? 0 + const currentPageAlerts: Alert[] = pages[currentPage]?.alerts ?? [] + + // course-level alerts + const [thisCourseAlerts, setThisCourseAlertsState] = useState([]) + const setThisCourseAlerts = (alerts: Alert[]) => + setThisCourseAlertsState(alerts) + const clearThisCourseAlerts = () => setThisCourseAlertsState([]) + + const markRead = async (alertId: number) => { + // optimistic: remove from current page and decrement total + mutate( + (currentPages: GetAlertsResponse[] | undefined) => + currentPages + ? currentPages.map((page, idx) => ({ + ...page, + alerts: + idx === currentPage && Array.isArray(page.alerts) + ? page.alerts.filter((a) => a.id !== alertId) + : page.alerts, + total: + idx === 0 && typeof page.total === 'number' + ? Math.max(0, (page.total ?? 0) - 1) + : page.total, + })) + : currentPages, + { revalidate: false }, + ) + try { + await API.alerts.close(alertId) + } finally { + await mutate(undefined, { revalidate: true }) + } + } + + const markAllRead = async () => { + // optimistic: clear all and set total 0 + mutate( + (currentPages: GetAlertsResponse[] | undefined) => + currentPages + ? currentPages.map((p, idx) => ({ + ...p, + alerts: [], + total: idx === 0 ? 0 : p.total, + })) + : currentPages, + { revalidate: false }, + ) + try { + await API.alerts.markReadAll() + } finally { + await mutate(undefined, { revalidate: true }) + } + } + + const closeCourseAlert = async (alertId: number) => { + // optimistic removal from course-level alerts, and refresh feed afterwards + setThisCourseAlertsState((prev) => prev.filter((a) => a.id !== alertId)) + try { + await API.alerts.close(alertId) + } finally { + await mutate(undefined, { revalidate: true }) + } + } + + const value = useMemo( + () => ({ + pages, + total, + isLoading, + isValidating, + size, + setSize, + mutate, + currentPage, + setCurrentPage, + currentPageAlerts, + markRead, + markAllRead, + pageSize, + thisCourseAlerts, + setThisCourseAlerts, + clearThisCourseAlerts, + closeCourseAlert, + }), + [ + pages, + total, + isLoading, + isValidating, + size, + setSize, + mutate, + currentPage, + currentPageAlerts, + pageSize, + thisCourseAlerts, + ], + ) + + return ( + {children} + ) +} + +export const useAlertsContext = (): AlertsContextValue => { + const ctx = useContext(AlertsContext) + if (!ctx) + throw new Error('useAlertsContext must be used within AlertsProvider') + return ctx +}