diff --git a/packages/common/index.ts b/packages/common/index.ts index f39b4a0ee..075261d27 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -2230,6 +2230,25 @@ export enum AlertType { REPHRASE_QUESTION = 'rephraseQuestion', EVENT_ENDED_CHECKOUT_STAFF = 'eventEndedCheckoutStaff', PROMPT_STUDENT_TO_LEAVE_QUEUE = 'promptStudentToLeaveQueue', + DOCUMENT_PROCESSED = 'documentProcessed', + 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', } export class AlertPayload {} @@ -2238,12 +2257,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 +2292,35 @@ export class PromptStudentToLeaveQueuePayload extends AlertPayload { queueQuestionId?: number } +export class DocumentProcessedPayload extends AlertPayload { + @IsInt() + documentId!: number + + @IsString() + 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 @@ -2296,6 +2351,10 @@ export class CreateAlertParams { @IsEnum(AlertType) alertType!: AlertType + @IsOptional() + @IsEnum(AlertDeliveryMode) + deliveryMode?: AlertDeliveryMode + @IsInt() courseId!: number @@ -2311,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/(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)/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/(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/api/index.ts b/packages/frontend/app/api/index.ts index 28031576f..1f2ae9e42 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, @@ -1078,12 +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 = 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/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/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 */} +
+ +
+
+ { )} +
+ +
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, + className, +}) => { + const router = useRouter() + const [open, setOpen] = useState(false) + + const { + total, + isLoading, + isValidating, + size, + setSize, + currentPage, + setCurrentPage, + currentPageAlerts, + markRead, + markAllRead, + pageSize, + } = useAlertsContext() + + const unreadCount = useMemo(() => total, [total]) + + const uniqueCourseIds = useMemo(() => { + const ids = new Set() + for (const alert of currentPageAlerts) { + if ((alert.payload as any)?.courseId) { + ids.add((alert.payload as any).courseId) + } + } + return Array.from(ids) + }, [currentPageAlerts]) + + 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 + await markRead(alert.id) + } + + const handleNavigate = + (alert: Alert, url: string) => async (): Promise => { + await markAsRead(alert) + router.push(url) + } + + const views: NotificationView[] = useMemo(() => { + 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 + 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()) + }, [currentPageAlerts, courseNameMap]) + + const markAllReadLocal = async () => { + await markAllRead() + } + + return ( + + {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 && ( + + )} + + )} + /> + )} + +
+ {total > 0 ? ( +
+ + + + {`${currentPage * pageSize + 1}-${currentPage * pageSize + (currentPageAlerts?.length || 0)} of ${total}`} + +
+ ) : ( + + )} + {total > 0 && ( + + )} +
+ + } + trigger="click" + placement="bottomRight" + open={open} + onOpenChange={(visible) => setOpen(visible)} + overlayStyle={{ padding: 0, borderRadius: 8 }} + > + + + +
+ ) +} + +export default NotificationBell 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 +} 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/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-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 5e2ccbcea..78a801642 100644 --- a/packages/server/src/alerts/alerts.controller.ts +++ b/packages/server/src/alerts/alerts.controller.ts @@ -1,9 +1,13 @@ import { + AlertDeliveryMode, + AlertType, CreateAlertParams, CreateAlertResponse, ERROR_MESSAGES, GetAlertsResponse, Role, + FEED_ALERT_TYPES, + MODAL_ALERT_TYPES, } from '@koh/common'; import { BadRequestException, @@ -14,7 +18,10 @@ import { ParseIntPipe, Patch, Post, + Query, UseGuards, + ParseBoolPipe, + ParseEnumPipe, } from '@nestjs/common'; import { JwtAuthGuard } from 'guards/jwt-auth.guard'; import { UserId } from 'decorators/user.decorator'; @@ -29,19 +36,103 @@ 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', new ParseEnumPipe(AlertDeliveryMode)) + mode: AlertDeliveryMode = AlertDeliveryMode.FEED, + @Query('includeRead', ParseBoolPipe) includeRead = true, + @Query('limit', ParseIntPipe) limit = 20, + @Query('offset', ParseIntPipe) offset = 0, + ): Promise { + const where: Record = { + userId, + deliveryMode: mode, + }; + + if (mode === AlertDeliveryMode.MODAL) { + where.resolved = IsNull(); + } else if (!includeRead) { + where.readAt = IsNull(); + } + + const total = await AlertModel.count({ where }); + const alerts = await AlertModel.find({ + where, + 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), + total, + }; + } + @Get(':courseId') 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 total = await AlertModel.count({ where }); const alerts = await AlertModel.find({ - where: { - courseId, - userId, - resolved: IsNull(), - }, + where, + order: { sent: 'DESC' }, }); - return { alerts: await this.alertsService.removeStaleAlerts(alerts) }; + return { + alerts: await this.alertsService.removeStaleAlerts(alerts), + total, + }; } @Post() @@ -49,7 +140,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 +149,38 @@ export class AlertsController { ); } - const anotherAlert = await AlertModel.findOne({ - where: { - alertType, - userId: targetUserId, - resolved: IsNull(), - }, - }); - - // If the same user already has an alert for this then don't create a new one - if (anotherAlert) { + // 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( - ERROR_MESSAGES.alertController.duplicateAlert, + 'Invalid alert type for selected delivery mode', ); } + if (parsedMode === AlertDeliveryMode.MODAL) { + const anotherAlert = await AlertModel.findOne({ + where: { + alertType, + deliveryMode: parsedMode, + userId: targetUserId, + resolved: IsNull(), + }, + }); + + 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 +207,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..380c9a0b0 100644 --- a/packages/server/src/alerts/alerts.entity.ts +++ b/packages/server/src/alerts/alerts.entity.ts @@ -1,4 +1,12 @@ -import { AlertPayload, AlertType } from '@koh/common'; +import { + AlertDeliveryMode, + AlertPayload, + AlertType, + RephraseQuestionPayload, + PromptStudentToLeaveQueuePayload, + DocumentProcessedPayload, + AsyncQuestionUpdatePayload, +} from '@koh/common'; import { Exclude } from 'class-transformer'; import { BaseEntity, @@ -19,12 +27,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; @@ -42,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/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/alerts/alerts.service.ts b/packages/server/src/alerts/alerts.service.ts index 860786784..1bab81b35 100644 --- a/packages/server/src/alerts/alerts.service.ts +++ b/packages/server/src/alerts/alerts.service.ts @@ -1,9 +1,12 @@ import { Alert, + AlertDeliveryMode, AlertPayload, AlertType, RephraseQuestionPayload, PromptStudentToLeaveQueuePayload, + DocumentProcessedPayload, + AsyncQuestionUpdatePayload, } from '@koh/common'; import { pick } from 'lodash'; import { Injectable } from '@nestjs/common'; @@ -17,10 +20,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,8 +34,8 @@ 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 { @@ -43,21 +44,27 @@ export class AlertsService { ); } 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: + case AlertType.ASYNC_QUESTION_UPDATE: 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 +88,24 @@ export class AlertsService { typeof promptPayload.queueQuestionId === 'number') ); + case AlertType.DOCUMENT_PROCESSED: + case AlertType.ASYNC_QUESTION_UPDATE: + const docPayload = payload as DocumentProcessedPayload; + // 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.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 119a15143..ce112a6b4 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,12 +16,13 @@ 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 { constructor(private mailService: MailService) {} - async sendNewCommentOnMyQuestionEmail( + async sendNewCommentOnMyQuestionEmailAndAlert( commenter: UserModel, commenterRole: Role, question: AsyncQuestionModel, @@ -56,12 +63,28 @@ export class AsyncQuestionService { Sentry.captureException(err); }); } + + 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. Note that updatedQuestion must have comments and comments.creator relations */ - async sendNewCommentOnOtherQuestionEmail( + async sendNewCommentOnOtherQuestionEmailAndAlert( commenter: UserModel, commenterRole: Role, questionCreatorId: number, @@ -115,6 +138,31 @@ export class AsyncQuestionService { } }); }); + // 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( + participantIds.map((uid) => + AlertModel.create({ + alertType: AlertType.ASYNC_QUESTION_UPDATE, + deliveryMode: AlertDeliveryMode.FEED, + sent: new Date(), + userId: uid, + courseId: updatedQuestion.courseId, + payload: { + courseId: updatedQuestion.courseId, + questionId: updatedQuestion.id, + subtype: 'commentOnOthersPost', + summary: `${commenterIsStaff ? commenter.name : 'Someone'} commented on an Anytime Question you follow`, + }, + }).save(), + ), + ); } async sendNeedsAttentionEmail(question: AsyncQuestionModel) { @@ -203,6 +251,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(); } } @@ -242,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, ) { @@ -274,10 +335,23 @@ 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}`, + }, + }).save(); } } - async sendUpvotedEmail(updatedQuestion: AsyncQuestionModel) { + async sendUpvotedEmailAndAlert(updatedQuestion: AsyncQuestionModel) { const subscription = await UserSubscriptionModel.findOne({ where: { userId: updatedQuestion.creator.id, @@ -306,6 +380,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', + }, + }).save(); } } 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..f16b2d3d9 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,6 +205,9 @@ export class QueueCleanService { .andWhere('alert.payload::jsonb @> :payload', { payload: JSON.stringify({ queueId }), }) + .andWhere('alert."deliveryMode" = :deliveryMode', { + deliveryMode: AlertDeliveryMode.MODAL, + }) .getOne(); if (existingAlert) { return; @@ -210,7 +217,12 @@ export class QueueCleanService { 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}`;