From d942657abd3dedbc28d4c549fa0156aec31e8544 Mon Sep 17 00:00:00 2001
From: ribhavsharma
Date: Mon, 3 Nov 2025 23:56:08 -0800
Subject: [PATCH 01/11] feat: add option to send email notificatoin when
chatbot answer is changed
---
packages/common/index.ts | 5 +
.../components/EditChatbotQuestionModal.tsx | 65 ++++++++++-
packages/frontend/app/api/index.ts | 16 +++
.../server/src/chatbot/chatbot.controller.ts | 105 +++++++++++++++++-
4 files changed, 188 insertions(+), 3 deletions(-)
diff --git a/packages/common/index.ts b/packages/common/index.ts
index 0285a3ba3..985c72b03 100644
--- a/packages/common/index.ts
+++ b/packages/common/index.ts
@@ -312,6 +312,7 @@ export enum MailServiceType {
ASYNC_QUESTION_NEW_COMMENT_ON_MY_POST = 'async_question_new_comment_on_my_post',
ASYNC_QUESTION_NEW_COMMENT_ON_OTHERS_POST = 'async_question_new_comment_on_others_post',
COURSE_CLONE_SUMMARY = 'course_clone_summary',
+ CHATBOT_ANSWER_UPDATED = 'chatbot_answer_updated',
}
/**
* Represents one of three possible user roles in a course.
@@ -484,6 +485,10 @@ export interface UpdateChatbotQuestionParams {
docId: string
pageNumbersString: string
}[]
+ // Frontend-only flag to request notification emails after an update.
+ // Server must validate the answer actually changed and will strip this before
+ // forwarding to the external chatbot service.
+ emailNotifyOnAnswerUpdate?: boolean
}
// this is the response from the backend when new questions are asked
diff --git a/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_questions/components/EditChatbotQuestionModal.tsx b/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_questions/components/EditChatbotQuestionModal.tsx
index e7b94bc83..7fbc5962e 100644
--- a/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_questions/components/EditChatbotQuestionModal.tsx
+++ b/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_questions/components/EditChatbotQuestionModal.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react'
+import { useEffect, useState, memo } from 'react'
import {
Button,
Checkbox,
@@ -46,6 +46,22 @@ interface EditChatbotQuestionModalProps {
cid: number
deleteQuestion: (id: string) => void
}
+type AnswerUpdateCheckboxProps = {
+ form: any
+ originalAnswer: string
+ checked?: boolean
+ onChange?: (e: any) => void
+}
+
+const AnswerUpdateCheckbox = memo(
+ ({ form, originalAnswer, checked, onChange }) => {
+ const currentAnswer = Form.useWatch('answer', form)
+ const changed = (currentAnswer ?? '') !== (originalAnswer ?? '')
+ return (
+
+ )
+ },
+)
const EditChatbotQuestionModal: React.FC = ({
open,
@@ -158,8 +174,33 @@ const EditChatbotQuestionModal: React.FC = ({
await API.chatbot.staffOnly
.updateQuestion(cid, valuesWithId)
- .then(() => {
+ .then(async () => {
message.success('Question updated successfully')
+ const notify = (values as any).emailNotifyOnAnswerUpdate
+ if (notify) {
+ try {
+ const resp = await API.chatbot.staffOnly.notifyAnswerUpdate(
+ cid,
+ editingRecord.vectorStoreId,
+ {
+ oldAnswer: editingRecord.answer,
+ newAnswer: values.answer,
+ oldQuestion: editingRecord.question,
+ newQuestion: values.question,
+ },
+ )
+ if (resp?.recipients != undefined) {
+ message.success(
+ `Notification email sent to ${resp.recipients} student${
+ resp.recipients === 1 ? '' : 's'
+ }`,
+ )
+ }
+ } catch (e) {
+ const errorMessage = getErrorMessage(e)
+ message.error('Failed to send notification email: ' + errorMessage)
+ }
+ }
onSuccessfulUpdate()
})
.catch((e) => {
@@ -227,6 +268,7 @@ const EditChatbotQuestionModal: React.FC = ({
question: editingRecord.question,
verified: editingRecord.verified,
suggested: editingRecord.suggested,
+ emailNotifyOnAnswerUpdate: false,
sourceDocuments: editingRecord.sourceDocuments,
}}
clearOnDestroy
@@ -256,6 +298,25 @@ const EditChatbotQuestionModal: React.FC = ({
>
+
+
+ Sends an email to the student(s) who previously asked this
+ question in this course with a before/after of the answer.
+
+
+ }
+ >
+
+
=>
this.req('DELETE', `/api/v1/chatbot/document/${courseId}/${docId}`),
+ notifyAnswerUpdate: async (
+ courseId: number,
+ questionId: string,
+ body: {
+ oldAnswer: string
+ newAnswer: string
+ oldQuestion?: string
+ newQuestion?: string
+ },
+ ): Promise<{ recipients: number }> =>
+ this.req(
+ 'POST',
+ `/api/v1/chatbot/question/${courseId}/${questionId}/notify`,
+ undefined,
+ body,
+ ),
uploadDocument: async (
courseId: number,
body: FormData,
diff --git a/packages/server/src/chatbot/chatbot.controller.ts b/packages/server/src/chatbot/chatbot.controller.ts
index 0b727fa33..c8e403f4f 100644
--- a/packages/server/src/chatbot/chatbot.controller.ts
+++ b/packages/server/src/chatbot/chatbot.controller.ts
@@ -45,6 +45,7 @@ import {
GetChatbotHistoryResponse,
GetInteractionsAndQuestionsResponse,
InteractionResponse,
+ MailServiceType,
LLMType,
OllamaLLMType,
OpenAILLMType,
@@ -87,6 +88,8 @@ import {
IgnoreableClassSerializerInterceptor,
IgnoreSerializer,
} from '../interceptors/IgnoreableClassSerializerInterceptor';
+import { MailService } from '../mail/mail.service';
+import { ChatbotQuestionModel } from './question.entity';
@Controller('chatbot')
@UseGuards(JwtAuthGuard, EmailVerifiedGuard)
@@ -95,6 +98,7 @@ export class ChatbotController {
constructor(
private readonly chatbotService: ChatbotService,
private readonly chatbotApiService: ChatbotApiService,
+ private readonly mailService: MailService,
) {}
//
@@ -331,8 +335,10 @@ export class ChatbotController {
@User({ chat_token: true }) user: UserModel,
): Promise {
handleChatbotTokenCheck(user);
+ // Do not forward frontend-only flags to the chatbot API
+ const { emailNotifyOnAnswerUpdate, ...sanitized } = questionData as any;
return await this.chatbotApiService.updateQuestion(
- questionData,
+ sanitized,
courseId,
user.chat_token.token,
);
@@ -378,6 +384,103 @@ export class ChatbotController {
// }
// resets all chatbot data for the course. Unused
+
+ // Professor-only: send notification email to all students who asked this question
+ // Body must include oldAnswer and newAnswer, and can optionally include question changes for context
+ @Post('question/:courseId/:questionId/notify')
+ @UseGuards(CourseRolesGuard)
+ @Roles(Role.PROFESSOR)
+ async notifyUpdatedAnswer(
+ @Param('courseId', ParseIntPipe) courseId: number,
+ @Param('questionId') questionId: string,
+ @Body()
+ body: {
+ oldAnswer: string;
+ newAnswer: string;
+ oldQuestion?: string;
+ newQuestion?: string;
+ },
+ @User({ chat_token: true }) user: UserModel,
+ ): Promise<{ recipients: number }> {
+ handleChatbotTokenCheck(user);
+
+ const { oldAnswer, newAnswer, oldQuestion, newQuestion } = body ?? {};
+ if (oldAnswer == undefined || newAnswer == undefined) {
+ throw new BadRequestException(
+ 'oldAnswer and newAnswer must be provided to send notifications.',
+ );
+ }
+ // Enforce: only allow notify when answer changed
+ if ((oldAnswer ?? '').trim() === (newAnswer ?? '').trim()) {
+ throw new BadRequestException(
+ 'Cannot send notification: answer was not changed.',
+ );
+ }
+
+ // Find all interactions in this course where this question was asked
+ const asked = await ChatbotQuestionModel.find({
+ where: { vectorStoreId: questionId },
+ relations: { interaction: { user: true, course: true } },
+ });
+ const recipients = Array.from(
+ new Set(
+ asked
+ .filter((a) => a.interaction?.course?.id === courseId)
+ .map((a) => a.interaction?.user?.email)
+ .filter((e): e is string => !!e),
+ ),
+ );
+
+ if (recipients.length === 0) {
+ return { recipients: 0 };
+ }
+
+ // Building email content
+ const qChanged =
+ (oldQuestion ?? '').trim() !== (newQuestion ?? oldQuestion ?? '').trim();
+
+ const questionSection = qChanged
+ ? `| Question (before) | ${
+ oldQuestion ?? ''
+ } |
+ | Question (after) | ${
+ newQuestion ?? ''
+ } |
`
+ : `| Question | ${
+ oldQuestion ?? newQuestion ?? ''
+ } |
`;
+
+ const html = `
+
+
The answer to a chatbot question you asked has been updated.
+
+ ${questionSection}
+
+ | Answer (before) |
+ ${oldAnswer} |
+
+
+ | Answer (after) |
+ ${newAnswer} |
+
+
+
+
You can revisit the course chatbot from your course page.
+
+ `;
+
+ await this.mailService
+ .sendEmail({
+ receiverOrReceivers: recipients,
+ subject: 'Chatbot answer updated',
+ type: MailServiceType.CHATBOT_ANSWER_UPDATED,
+ content: html,
+ track: false,
+ })
+ .catch(() => {});
+
+ return { recipients: recipients.length };
+ }
// @Get('resetCourse/:courseId')
// @UseGuards(CourseRolesGuard)
// @Roles(Role.PROFESSOR, Role.TA)
From 7fbf3926c8cd7247dc95f16615444cfacbe74865 Mon Sep 17 00:00:00 2001
From: ribhavsharma
Date: Tue, 11 Nov 2025 15:32:15 -0800
Subject: [PATCH 02/11] add mail service to provider
---
packages/server/src/chatbot/chatbot.module.ts | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/packages/server/src/chatbot/chatbot.module.ts b/packages/server/src/chatbot/chatbot.module.ts
index ee640d5ce..3dba0cdcf 100644
--- a/packages/server/src/chatbot/chatbot.module.ts
+++ b/packages/server/src/chatbot/chatbot.module.ts
@@ -7,6 +7,7 @@ import { CacheModule } from '@nestjs/cache-manager';
import { ChatbotDataSourceService } from './chatbot-datasource/chatbot-datasource.service';
import { ChatbotDataSourceModule } from './chatbot-datasource/chatbot-datasource.module';
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions';
+import { MailService } from 'mail/mail.service';
@Module({
controllers: [ChatbotController],
@@ -21,7 +22,12 @@ export class ChatbotModule {
CacheModule.register(),
ChatbotDataSourceModule.forRoot(connectionOptions),
],
- providers: [ChatbotService, ChatbotApiService, ChatbotSettingsSubscriber],
+ providers: [
+ ChatbotService,
+ ChatbotApiService,
+ ChatbotSettingsSubscriber,
+ MailService,
+ ],
};
}
}
From 31447deff13c9c0b6363db346d0b5cd1ece2ee9e Mon Sep 17 00:00:00 2001
From: ribhavsharma
Date: Mon, 24 Nov 2025 17:52:50 -0800
Subject: [PATCH 03/11] remove unnecessary dependency from lms test
---
.../lmsIntegration/lmsIntegration.service.spec.ts | 14 ++------------
1 file changed, 2 insertions(+), 12 deletions(-)
diff --git a/packages/server/src/lmsIntegration/lmsIntegration.service.spec.ts b/packages/server/src/lmsIntegration/lmsIntegration.service.spec.ts
index d0bfa627a..ccc551dda 100644
--- a/packages/server/src/lmsIntegration/lmsIntegration.service.spec.ts
+++ b/packages/server/src/lmsIntegration/lmsIntegration.service.spec.ts
@@ -6,11 +6,7 @@ import {
} from './lmsIntegration.service';
import { Test, TestingModule } from '@nestjs/testing';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
-import {
- TestChatbotConnectionOptions,
- TestConfigModule,
- TestTypeOrmModule,
-} from '../../test/util/testUtils';
+import { TestConfigModule, TestTypeOrmModule } from '../../test/util/testUtils';
import {
AbstractLMSAdapter,
LMSIntegrationAdapter,
@@ -39,7 +35,6 @@ import { LMSAnnouncementModel } from './lmsAnnouncement.entity';
import { LMSAssignmentModel } from './lmsAssignment.entity';
import { FactoryModule } from 'factory/factory.module';
import { FactoryService } from 'factory/factory.service';
-import { ChatbotModule } from '../chatbot/chatbot.module';
import { ChatbotApiService } from '../chatbot/chatbot-api.service';
const mockCacheManager = {
@@ -60,12 +55,7 @@ describe('LMSIntegrationService', () => {
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
- imports: [
- TestTypeOrmModule,
- TestConfigModule,
- FactoryModule,
- ChatbotModule.forRoot(TestChatbotConnectionOptions),
- ],
+ imports: [TestTypeOrmModule, TestConfigModule, FactoryModule],
providers: [
LMSIntegrationService,
LMSIntegrationAdapter,
From eca8b32a2e7f9105874185761cde82138b4026af Mon Sep 17 00:00:00 2001
From: Adam Fipke
Date: Tue, 25 Nov 2025 18:08:54 -0800
Subject: [PATCH 04/11] reduce bottom margin of answer field
---
.../chatbot_questions/components/EditChatbotQuestionModal.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_questions/components/EditChatbotQuestionModal.tsx b/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_questions/components/EditChatbotQuestionModal.tsx
index 7fbc5962e..64e61c263 100644
--- a/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_questions/components/EditChatbotQuestionModal.tsx
+++ b/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_questions/components/EditChatbotQuestionModal.tsx
@@ -287,6 +287,7 @@ const EditChatbotQuestionModal: React.FC = ({
,
classNames: {
From 11eeef31edd5e661fabb9f3942d4e2f9e2a7286c Mon Sep 17 00:00:00 2001
From: ribhavsharma
Date: Mon, 1 Dec 2025 21:45:51 -0800
Subject: [PATCH 05/11] addressing feedback
---
packages/common/index.ts | 20 ++-
.../components/EditChatbotQuestionModal.tsx | 44 ++++---
packages/frontend/app/api/index.ts | 4 +-
.../src/chatbot/chatbot.controller.spec.ts | 122 ++++++++++++++++++
.../server/src/chatbot/chatbot.controller.ts | 77 ++++++-----
.../src/chatbot/chatbot.service.spec.ts | 9 ++
packages/server/test/chatbot.integration.ts | 113 +++++++++++++++-
7 files changed, 330 insertions(+), 59 deletions(-)
create mode 100644 packages/server/src/chatbot/chatbot.controller.spec.ts
diff --git a/packages/common/index.ts b/packages/common/index.ts
index 985c72b03..7eb9d7dbc 100644
--- a/packages/common/index.ts
+++ b/packages/common/index.ts
@@ -485,10 +485,22 @@ export interface UpdateChatbotQuestionParams {
docId: string
pageNumbersString: string
}[]
- // Frontend-only flag to request notification emails after an update.
- // Server must validate the answer actually changed and will strip this before
- // forwarding to the external chatbot service.
- emailNotifyOnAnswerUpdate?: boolean
+}
+
+export class NotifyUpdatedChatbotAnswerParams {
+ @IsString()
+ oldAnswer!: string
+
+ @IsString()
+ newAnswer!: string
+
+ @IsString()
+ @IsOptional()
+ oldQuestion?: string
+
+ @IsString()
+ @IsOptional()
+ newQuestion?: string
}
// this is the response from the backend when new questions are asked
diff --git a/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_questions/components/EditChatbotQuestionModal.tsx b/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_questions/components/EditChatbotQuestionModal.tsx
index 64e61c263..d16d40c79 100644
--- a/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_questions/components/EditChatbotQuestionModal.tsx
+++ b/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_questions/components/EditChatbotQuestionModal.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState, memo } from 'react'
+import { useEffect, useState } from 'react'
import {
Button,
Checkbox,
@@ -31,6 +31,7 @@ interface FormValues {
answer: string
verified: boolean
suggested: boolean
+ emailNotifyOnAnswerUpdate?: boolean
sourceDocuments: SourceDocument[]
selectedDocuments: {
docId: string
@@ -53,15 +54,16 @@ type AnswerUpdateCheckboxProps = {
onChange?: (e: any) => void
}
-const AnswerUpdateCheckbox = memo(
- ({ form, originalAnswer, checked, onChange }) => {
- const currentAnswer = Form.useWatch('answer', form)
- const changed = (currentAnswer ?? '') !== (originalAnswer ?? '')
- return (
-
- )
- },
-)
+const AnswerUpdateCheckbox: React.FC = ({
+ form,
+ originalAnswer,
+ checked,
+ onChange,
+}) => {
+ const currentAnswer = Form.useWatch('answer', form)
+ const changed = (currentAnswer ?? '') !== (originalAnswer ?? '')
+ return
+}
const EditChatbotQuestionModal: React.FC = ({
open,
@@ -166,8 +168,9 @@ const EditChatbotQuestionModal: React.FC = ({
})
}
+ const { emailNotifyOnAnswerUpdate, ...sanitizedValues } = values
const valuesWithId = {
- ...values,
+ ...sanitizedValues,
id: editingRecord.vectorStoreId,
sourceDocuments: values.sourceDocuments || [],
}
@@ -176,8 +179,7 @@ const EditChatbotQuestionModal: React.FC = ({
.updateQuestion(cid, valuesWithId)
.then(async () => {
message.success('Question updated successfully')
- const notify = (values as any).emailNotifyOnAnswerUpdate
- if (notify) {
+ if (emailNotifyOnAnswerUpdate) {
try {
const resp = await API.chatbot.staffOnly.notifyAnswerUpdate(
cid,
@@ -190,11 +192,15 @@ const EditChatbotQuestionModal: React.FC = ({
},
)
if (resp?.recipients != undefined) {
- message.success(
- `Notification email sent to ${resp.recipients} student${
- resp.recipients === 1 ? '' : 's'
- }`,
- )
+ if (resp.recipients === 5) {
+ message.success('Notification email sent to 5 users (max 5).')
+ } else {
+ message.success(
+ `Notification email sent to ${resp.recipients} user${
+ resp.recipients === 1 ? '' : 's'
+ }`,
+ )
+ }
}
} catch (e) {
const errorMessage = getErrorMessage(e)
@@ -308,7 +314,7 @@ const EditChatbotQuestionModal: React.FC = ({
Sends an email to the student(s) who previously asked this
- question in this course with a before/after of the answer.
+ question with a before/after of the answer.
}
diff --git a/packages/frontend/app/api/index.ts b/packages/frontend/app/api/index.ts
index a04a9c161..95a0030b2 100644
--- a/packages/frontend/app/api/index.ts
+++ b/packages/frontend/app/api/index.ts
@@ -314,7 +314,7 @@ class APIClient {
this.req('DELETE', `/api/v1/chatbot/document/${courseId}/${docId}`),
notifyAnswerUpdate: async (
courseId: number,
- questionId: string,
+ vectorStoreId: string,
body: {
oldAnswer: string
newAnswer: string
@@ -324,7 +324,7 @@ class APIClient {
): Promise<{ recipients: number }> =>
this.req(
'POST',
- `/api/v1/chatbot/question/${courseId}/${questionId}/notify`,
+ `/api/v1/chatbot/question/${courseId}/${vectorStoreId}/notify`,
undefined,
body,
),
diff --git a/packages/server/src/chatbot/chatbot.controller.spec.ts b/packages/server/src/chatbot/chatbot.controller.spec.ts
new file mode 100644
index 000000000..1c284a623
--- /dev/null
+++ b/packages/server/src/chatbot/chatbot.controller.spec.ts
@@ -0,0 +1,122 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { ChatbotController } from './chatbot.controller';
+import { ChatbotService } from './chatbot.service';
+import { ChatbotApiService } from './chatbot-api.service';
+import { MailService } from 'mail/mail.service';
+import { ChatbotQuestionModel } from './question.entity';
+import { BadRequestException } from '@nestjs/common';
+import { NotifyUpdatedChatbotAnswerParams, Role } from '@koh/common';
+
+describe('ChatbotController - notifyUpdatedAnswer', () => {
+ let controller: ChatbotController;
+ let mailService: { sendEmail: jest.Mock };
+
+ const mockChatbotService = {};
+ const mockChatbotApiService = {};
+
+ beforeAll(async () => {
+ mailService = {
+ sendEmail: jest.fn(),
+ };
+
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [ChatbotController],
+ providers: [
+ { provide: ChatbotService, useValue: mockChatbotService },
+ { provide: ChatbotApiService, useValue: mockChatbotApiService },
+ { provide: MailService, useValue: mailService },
+ ],
+ }).compile();
+
+ controller = module.get(ChatbotController);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ mailService.sendEmail.mockReset();
+ });
+
+ const makeUser = (courseId: number, role: Role): any => ({
+ id: 1,
+ courses: [{ courseId, role }],
+ });
+
+ const makeBody = (
+ overrides: Partial = {},
+ ): NotifyUpdatedChatbotAnswerParams => ({
+ oldAnswer: 'old',
+ newAnswer: 'new',
+ ...overrides,
+ });
+
+ it('throws BadRequestException when answer did not change', async () => {
+ const body = makeBody({ oldAnswer: 'same', newAnswer: ' same ' });
+
+ await expect(
+ controller.notifyUpdatedAnswer(
+ 1,
+ 'vec-id',
+ body,
+ makeUser(1, Role.PROFESSOR),
+ ),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it('throws BadRequestException when there are no recipients', async () => {
+ jest.spyOn(ChatbotQuestionModel, 'find').mockResolvedValue([] as any);
+
+ const body = makeBody();
+
+ await expect(
+ controller.notifyUpdatedAnswer(
+ 1,
+ 'vec-id',
+ body,
+ makeUser(1, Role.PROFESSOR),
+ ),
+ ).rejects.toBeInstanceOf(BadRequestException);
+
+ expect(ChatbotQuestionModel.find).toHaveBeenCalledTimes(1);
+ expect(mailService.sendEmail).not.toHaveBeenCalled();
+ });
+
+ it('sends individual emails and caps at 5 recipients', async () => {
+ const courseId = 1;
+ const vectorStoreId = 'vec-id';
+ const emails = [
+ 'u1@example.com',
+ 'u2@example.com',
+ 'u3@example.com',
+ 'u4@example.com',
+ 'u5@example.com',
+ 'u6@example.com',
+ ];
+
+ jest.spyOn(ChatbotQuestionModel, 'find').mockResolvedValue(
+ emails.map((email) => ({
+ interaction: {
+ course: { id: courseId, name: 'Test Course' },
+ user: { email },
+ },
+ })) as any,
+ );
+
+ const body = makeBody();
+
+ const result = await controller.notifyUpdatedAnswer(
+ courseId,
+ vectorStoreId,
+ body,
+ makeUser(courseId, Role.PROFESSOR),
+ );
+
+ // Should only send to first 5 emails
+ expect(mailService.sendEmail).toHaveBeenCalledTimes(5);
+ const calledRecipients = mailService.sendEmail.mock.calls.map(
+ (args) => args[0].receiverOrReceivers,
+ );
+ expect(calledRecipients).toEqual(emails.slice(0, 5));
+
+ expect(result).toEqual({ recipients: 5 });
+ });
+});
diff --git a/packages/server/src/chatbot/chatbot.controller.ts b/packages/server/src/chatbot/chatbot.controller.ts
index c8e403f4f..73bb68530 100644
--- a/packages/server/src/chatbot/chatbot.controller.ts
+++ b/packages/server/src/chatbot/chatbot.controller.ts
@@ -52,6 +52,7 @@ import {
OrganizationChatbotSettings,
OrganizationChatbotSettingsDefaults,
OrganizationRole,
+ NotifyUpdatedChatbotAnswerParams,
Role,
UpdateChatbotProviderBody,
UpdateChatbotQuestionParams,
@@ -335,10 +336,8 @@ export class ChatbotController {
@User({ chat_token: true }) user: UserModel,
): Promise {
handleChatbotTokenCheck(user);
- // Do not forward frontend-only flags to the chatbot API
- const { emailNotifyOnAnswerUpdate, ...sanitized } = questionData as any;
return await this.chatbotApiService.updateQuestion(
- sanitized,
+ questionData,
courseId,
user.chat_token.token,
);
@@ -387,29 +386,17 @@ export class ChatbotController {
// Professor-only: send notification email to all students who asked this question
// Body must include oldAnswer and newAnswer, and can optionally include question changes for context
- @Post('question/:courseId/:questionId/notify')
+ @Post('question/:courseId/:vectorStoreId/notify')
@UseGuards(CourseRolesGuard)
@Roles(Role.PROFESSOR)
async notifyUpdatedAnswer(
@Param('courseId', ParseIntPipe) courseId: number,
- @Param('questionId') questionId: string,
- @Body()
- body: {
- oldAnswer: string;
- newAnswer: string;
- oldQuestion?: string;
- newQuestion?: string;
- },
- @User({ chat_token: true }) user: UserModel,
+ @Param('vectorStoreId') vectorStoreId: string,
+ @Body() body: NotifyUpdatedChatbotAnswerParams,
+ @User({ courses: true }) user: UserModel,
): Promise<{ recipients: number }> {
- handleChatbotTokenCheck(user);
-
const { oldAnswer, newAnswer, oldQuestion, newQuestion } = body ?? {};
- if (oldAnswer == undefined || newAnswer == undefined) {
- throw new BadRequestException(
- 'oldAnswer and newAnswer must be provided to send notifications.',
- );
- }
+
// Enforce: only allow notify when answer changed
if ((oldAnswer ?? '').trim() === (newAnswer ?? '').trim()) {
throw new BadRequestException(
@@ -419,7 +406,14 @@ export class ChatbotController {
// Find all interactions in this course where this question was asked
const asked = await ChatbotQuestionModel.find({
- where: { vectorStoreId: questionId },
+ where: {
+ vectorStoreId: vectorStoreId,
+ interaction: {
+ course: {
+ id: courseId,
+ },
+ },
+ },
relations: { interaction: { user: true, course: true } },
});
const recipients = Array.from(
@@ -432,10 +426,11 @@ export class ChatbotController {
);
if (recipients.length === 0) {
- return { recipients: 0 };
+ throw new BadRequestException(
+ 'Cannot send notification: no recipients for this question.',
+ );
}
- // Building email content
const qChanged =
(oldQuestion ?? '').trim() !== (newQuestion ?? oldQuestion ?? '').trim();
@@ -450,9 +445,21 @@ export class ChatbotController {
oldQuestion ?? newQuestion ?? ''
}`;
+ const courseName = asked[0]?.interaction?.course?.name ?? 'your course';
+
+ const courseRole =
+ user.courses?.find((uc: any) => uc.courseId === courseId)?.role ?? null;
+
+ const staffDescriptor =
+ courseRole === Role.PROFESSOR
+ ? 'the Instructor'
+ : courseRole === Role.TA
+ ? 'a TA'
+ : 'a staff member';
+
const html = `
-
The answer to a chatbot question you asked has been updated.
+
The answer to a chatbot question you asked in ${courseName} has been updated by ${staffDescriptor}.
${questionSection}
@@ -465,21 +472,27 @@ export class ChatbotController {
-
You can revisit the course chatbot from your course page.
+
+ Want to follow up? Create an
+ Anytime Question
+ to discuss it more!
+
`;
- await this.mailService
- .sendEmail({
- receiverOrReceivers: recipients,
- subject: 'Chatbot answer updated',
+ const limitedRecipients = recipients.slice(0, 5);
+
+ for (const email of limitedRecipients) {
+ await this.mailService.sendEmail({
+ receiverOrReceivers: email,
+ subject: `Chatbot answer updated by staff member for ${courseName}`,
type: MailServiceType.CHATBOT_ANSWER_UPDATED,
content: html,
track: false,
- })
- .catch(() => {});
+ });
+ }
- return { recipients: recipients.length };
+ return { recipients: limitedRecipients.length };
}
// @Get('resetCourse/:courseId')
// @UseGuards(CourseRolesGuard)
diff --git a/packages/server/src/chatbot/chatbot.service.spec.ts b/packages/server/src/chatbot/chatbot.service.spec.ts
index 9140becbe..a2b9a83a8 100644
--- a/packages/server/src/chatbot/chatbot.service.spec.ts
+++ b/packages/server/src/chatbot/chatbot.service.spec.ts
@@ -51,6 +51,7 @@ import {
chatbotTables,
} from './chatbot-datasource/chatbot-datasource.service';
import { ChatbotApiService } from './chatbot-api.service';
+import { MailService } from 'mail/mail.service';
const mockCacheManager = {
get: jest.fn(),
@@ -59,6 +60,10 @@ const mockCacheManager = {
reset: jest.fn(),
};
+const mockMailService = {
+ sendEmail: jest.fn(),
+};
+
describe('ChatbotService', () => {
let service: ChatbotService;
let subscriber: ChatbotSettingsSubscriber;
@@ -91,6 +96,10 @@ describe('ChatbotService', () => {
provide: CACHE_MANAGER,
useValue: mockCacheManager,
},
+ {
+ provide: MailService,
+ useValue: mockMailService,
+ },
],
}).compile();
diff --git a/packages/server/test/chatbot.integration.ts b/packages/server/test/chatbot.integration.ts
index b6f89c132..30b56c520 100644
--- a/packages/server/test/chatbot.integration.ts
+++ b/packages/server/test/chatbot.integration.ts
@@ -7,6 +7,7 @@ import {
CreateOrganizationChatbotSettingsBody,
dropUndefined,
ERROR_MESSAGES,
+ MailServiceType,
OrganizationRole,
Role,
UpdateChatbotProviderBody,
@@ -25,7 +26,12 @@ import {
UserCourseFactory,
UserFactory,
} from './util/factories';
-import { setupIntegrationTest } from './util/testUtils';
+import {
+ expectEmailNotSent,
+ expectEmailSent,
+ overrideEmailService,
+ setupIntegrationTest,
+} from './util/testUtils';
import { ChatbotQuestionModel } from 'chatbot/question.entity';
import { OrganizationModel } from '../src/organization/organization.entity';
import { CourseModel } from '../src/course/course.entity';
@@ -40,7 +46,10 @@ import { UserModel } from '../src/profile/user.entity';
import { ChatbotApiService } from '../src/chatbot/chatbot-api.service';
describe('ChatbotController Integration', () => {
- const { supertest } = setupIntegrationTest(ChatbotModule);
+ const { supertest } = setupIntegrationTest(
+ ChatbotModule,
+ overrideEmailService,
+ );
describe('PATCH /chatbot/questionScore/:courseId/:questionId', () => {
it('should allow a student to update the score of a question', async () => {
@@ -133,6 +142,106 @@ describe('ChatbotController Integration', () => {
});
});
+ describe('POST /chatbot/question/:courseId/:vectorStoreId/notify', () => {
+ it('sends individual emails (capped at 5 recipients) to users who asked the question', async () => {
+ const professor = await UserFactory.create();
+ const course = await CourseFactory.create();
+ await UserCourseFactory.create({
+ user: professor,
+ course,
+ role: Role.PROFESSOR,
+ });
+
+ const vectorStoreId = 'vec-notify-123';
+
+ const students = await Promise.all(
+ Array.from({ length: 6 }).map(() => UserFactory.create()),
+ );
+
+ for (const student of students) {
+ await UserCourseFactory.create({
+ user: student,
+ course,
+ role: Role.STUDENT,
+ });
+ const interaction = await InteractionFactory.create({
+ user: student,
+ course,
+ });
+ await ChatbotQuestionModel.create({
+ vectorStoreId,
+ questionText: 'Original question text',
+ responseText: 'Old answer',
+ verified: true,
+ suggested: true,
+ sourceDocuments: [],
+ interaction,
+ }).save();
+ }
+
+ const body = {
+ oldAnswer: 'Old answer',
+ newAnswer: 'New updated answer',
+ oldQuestion: 'Old question?',
+ newQuestion: 'New updated question?',
+ };
+
+ const res = await supertest({ userId: professor.id })
+ .post(`/chatbot/question/${course.id}/${vectorStoreId}/notify`)
+ .send(body)
+ .expect(201);
+
+ expect(res.body).toEqual({ recipients: 5 });
+ expectEmailSent(
+ students.slice(0, 5).map((s) => s.email),
+ Array(5).fill(MailServiceType.CHATBOT_ANSWER_UPDATED),
+ );
+ });
+
+ it('returns 400 and does not send emails when answer did not change', async () => {
+ const professor = await UserFactory.create();
+ const course = await CourseFactory.create();
+ await UserCourseFactory.create({
+ user: professor,
+ course,
+ role: Role.PROFESSOR,
+ });
+
+ const vectorStoreId = 'vec-notify-unchanged';
+ const student = await UserFactory.create();
+ await UserCourseFactory.create({
+ user: student,
+ course,
+ role: Role.STUDENT,
+ });
+ const interaction = await InteractionFactory.create({
+ user: student,
+ course,
+ });
+ await ChatbotQuestionModel.create({
+ vectorStoreId,
+ questionText: 'Some question',
+ responseText: 'Same answer',
+ verified: true,
+ suggested: true,
+ sourceDocuments: [],
+ interaction,
+ }).save();
+
+ const body = {
+ oldAnswer: 'Same answer',
+ newAnswer: ' Same answer ',
+ };
+
+ await supertest({ userId: professor.id })
+ .post(`/chatbot/question/${course.id}/${vectorStoreId}/notify`)
+ .send(body)
+ .expect(400);
+
+ expectEmailNotSent();
+ });
+ });
+
describe('GET /chatbot/history', () => {
it('should return the chatbot history for a user', async () => {
const user = await UserFactory.create();
From 6110ee7f255e5e7e7fa36482aaab97d3f1fa7181 Mon Sep 17 00:00:00 2001
From: ribhavsharma
Date: Mon, 1 Dec 2025 22:21:48 -0800
Subject: [PATCH 06/11] trying to fix test
---
packages/server/test/chatbot.integration.ts | 4 ----
1 file changed, 4 deletions(-)
diff --git a/packages/server/test/chatbot.integration.ts b/packages/server/test/chatbot.integration.ts
index 30b56c520..96c22fb8c 100644
--- a/packages/server/test/chatbot.integration.ts
+++ b/packages/server/test/chatbot.integration.ts
@@ -172,9 +172,7 @@ describe('ChatbotController Integration', () => {
vectorStoreId,
questionText: 'Original question text',
responseText: 'Old answer',
- verified: true,
suggested: true,
- sourceDocuments: [],
interaction,
}).save();
}
@@ -222,9 +220,7 @@ describe('ChatbotController Integration', () => {
vectorStoreId,
questionText: 'Some question',
responseText: 'Same answer',
- verified: true,
suggested: true,
- sourceDocuments: [],
interaction,
}).save();
From 0003ee1824271f532996f5746e7df26fb164d769 Mon Sep 17 00:00:00 2001
From: ribhavsharma
Date: Mon, 1 Dec 2025 23:06:29 -0800
Subject: [PATCH 07/11] fixing... test
---
packages/server/test/chatbot.integration.ts | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/packages/server/test/chatbot.integration.ts b/packages/server/test/chatbot.integration.ts
index 96c22fb8c..275167fac 100644
--- a/packages/server/test/chatbot.integration.ts
+++ b/packages/server/test/chatbot.integration.ts
@@ -143,6 +143,10 @@ describe('ChatbotController Integration', () => {
});
describe('POST /chatbot/question/:courseId/:vectorStoreId/notify', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
it('sends individual emails (capped at 5 recipients) to users who asked the question', async () => {
const professor = await UserFactory.create();
const course = await CourseFactory.create();
@@ -155,7 +159,11 @@ describe('ChatbotController Integration', () => {
const vectorStoreId = 'vec-notify-123';
const students = await Promise.all(
- Array.from({ length: 6 }).map(() => UserFactory.create()),
+ Array.from({ length: 6 }).map((_, i) =>
+ UserFactory.create({
+ email: `chatbot-notify-${i}@example.com`,
+ }),
+ ),
);
for (const student of students) {
From 9569e980e961982f9e4bf41ea80ec2683c64367c Mon Sep 17 00:00:00 2001
From: Adam Fipke
Date: Tue, 2 Dec 2025 15:03:55 -0800
Subject: [PATCH 08/11] fix notifyUpdatedAnswer only callable by profs. Removed
an . Added a comment explaining why we limit the number of recpients
---
packages/server/src/chatbot/chatbot.controller.ts | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/packages/server/src/chatbot/chatbot.controller.ts b/packages/server/src/chatbot/chatbot.controller.ts
index 73bb68530..78dc9f4b9 100644
--- a/packages/server/src/chatbot/chatbot.controller.ts
+++ b/packages/server/src/chatbot/chatbot.controller.ts
@@ -384,11 +384,11 @@ export class ChatbotController {
// resets all chatbot data for the course. Unused
- // Professor-only: send notification email to all students who asked this question
+ // staff-only: send notification email to all students who asked this question
// Body must include oldAnswer and newAnswer, and can optionally include question changes for context
@Post('question/:courseId/:vectorStoreId/notify')
@UseGuards(CourseRolesGuard)
- @Roles(Role.PROFESSOR)
+ @Roles(Role.PROFESSOR, Role.TA)
async notifyUpdatedAnswer(
@Param('courseId', ParseIntPipe) courseId: number,
@Param('vectorStoreId') vectorStoreId: string,
@@ -448,7 +448,7 @@ export class ChatbotController {
const courseName = asked[0]?.interaction?.course?.name ?? 'your course';
const courseRole =
- user.courses?.find((uc: any) => uc.courseId === courseId)?.role ?? null;
+ user.courses?.find((uc) => uc.courseId === courseId)?.role ?? null;
const staffDescriptor =
courseRole === Role.PROFESSOR
@@ -479,7 +479,9 @@ export class ChatbotController {
`;
-
+ // TODO: Once we get our official HelpMe email, we can remove this cap
+ // (this was added so we don't get rate limited by gmail as easily).
+ // Note that we CANNOT just 1 email with all the recipients, since then students will be able to see each others emails (and that they asked a particular question)
const limitedRecipients = recipients.slice(0, 5);
for (const email of limitedRecipients) {
From cf46dada8cd9b7ab15eff38841e328a172308e06 Mon Sep 17 00:00:00 2001
From: ribhavsharma
Date: Wed, 25 Feb 2026 19:10:12 -0800
Subject: [PATCH 09/11] addressing feedback
---
.../components/EditChatbotQuestionModal.tsx | 8 +-
packages/frontend/app/api/index.ts | 6 +-
.../src/chatbot/chatbot.controller.spec.ts | 122 -------
.../server/src/chatbot/chatbot.controller.ts | 114 +------
.../src/chatbot/chatbot.service.spec.ts | 146 +++++++++
.../server/src/chatbot/chatbot.service.ts | 137 ++++++++
packages/server/test/chatbot.integration.ts | 303 ++++++++++++++----
7 files changed, 549 insertions(+), 287 deletions(-)
delete mode 100644 packages/server/src/chatbot/chatbot.controller.spec.ts
diff --git a/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_questions/components/EditChatbotQuestionModal.tsx b/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_questions/components/EditChatbotQuestionModal.tsx
index 90dc9995a..7886457b5 100644
--- a/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_questions/components/EditChatbotQuestionModal.tsx
+++ b/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_questions/components/EditChatbotQuestionModal.tsx
@@ -192,8 +192,12 @@ const EditChatbotQuestionModal: React.FC = ({
},
)
if (resp?.recipients != undefined) {
- if (resp.recipients === 5) {
- message.success('Notification email sent to 5 users (max 5).')
+ if (resp.totalRecipients === 5) {
+ message.success(
+ `Notification email sent to ${resp.recipients} user${
+ resp.recipients === 1 ? '' : 's'
+ } (max 5).`,
+ )
} else {
message.success(
`Notification email sent to ${resp.recipients} user${
diff --git a/packages/frontend/app/api/index.ts b/packages/frontend/app/api/index.ts
index 4e37f9d9e..c903a9527 100644
--- a/packages/frontend/app/api/index.ts
+++ b/packages/frontend/app/api/index.ts
@@ -404,7 +404,11 @@ export class APIClient {
oldQuestion?: string
newQuestion?: string
},
- ): Promise<{ recipients: number }> =>
+ ): Promise<{
+ recipients: number
+ totalRecipients: number
+ unsubscribedRecipients: number
+ }> =>
this.req(
'POST',
`/api/v1/chatbot/question/${courseId}/${vectorStoreId}/notify`,
diff --git a/packages/server/src/chatbot/chatbot.controller.spec.ts b/packages/server/src/chatbot/chatbot.controller.spec.ts
deleted file mode 100644
index 1c284a623..000000000
--- a/packages/server/src/chatbot/chatbot.controller.spec.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-import { Test, TestingModule } from '@nestjs/testing';
-import { ChatbotController } from './chatbot.controller';
-import { ChatbotService } from './chatbot.service';
-import { ChatbotApiService } from './chatbot-api.service';
-import { MailService } from 'mail/mail.service';
-import { ChatbotQuestionModel } from './question.entity';
-import { BadRequestException } from '@nestjs/common';
-import { NotifyUpdatedChatbotAnswerParams, Role } from '@koh/common';
-
-describe('ChatbotController - notifyUpdatedAnswer', () => {
- let controller: ChatbotController;
- let mailService: { sendEmail: jest.Mock };
-
- const mockChatbotService = {};
- const mockChatbotApiService = {};
-
- beforeAll(async () => {
- mailService = {
- sendEmail: jest.fn(),
- };
-
- const module: TestingModule = await Test.createTestingModule({
- controllers: [ChatbotController],
- providers: [
- { provide: ChatbotService, useValue: mockChatbotService },
- { provide: ChatbotApiService, useValue: mockChatbotApiService },
- { provide: MailService, useValue: mailService },
- ],
- }).compile();
-
- controller = module.get(ChatbotController);
- });
-
- afterEach(() => {
- jest.restoreAllMocks();
- mailService.sendEmail.mockReset();
- });
-
- const makeUser = (courseId: number, role: Role): any => ({
- id: 1,
- courses: [{ courseId, role }],
- });
-
- const makeBody = (
- overrides: Partial = {},
- ): NotifyUpdatedChatbotAnswerParams => ({
- oldAnswer: 'old',
- newAnswer: 'new',
- ...overrides,
- });
-
- it('throws BadRequestException when answer did not change', async () => {
- const body = makeBody({ oldAnswer: 'same', newAnswer: ' same ' });
-
- await expect(
- controller.notifyUpdatedAnswer(
- 1,
- 'vec-id',
- body,
- makeUser(1, Role.PROFESSOR),
- ),
- ).rejects.toBeInstanceOf(BadRequestException);
- });
-
- it('throws BadRequestException when there are no recipients', async () => {
- jest.spyOn(ChatbotQuestionModel, 'find').mockResolvedValue([] as any);
-
- const body = makeBody();
-
- await expect(
- controller.notifyUpdatedAnswer(
- 1,
- 'vec-id',
- body,
- makeUser(1, Role.PROFESSOR),
- ),
- ).rejects.toBeInstanceOf(BadRequestException);
-
- expect(ChatbotQuestionModel.find).toHaveBeenCalledTimes(1);
- expect(mailService.sendEmail).not.toHaveBeenCalled();
- });
-
- it('sends individual emails and caps at 5 recipients', async () => {
- const courseId = 1;
- const vectorStoreId = 'vec-id';
- const emails = [
- 'u1@example.com',
- 'u2@example.com',
- 'u3@example.com',
- 'u4@example.com',
- 'u5@example.com',
- 'u6@example.com',
- ];
-
- jest.spyOn(ChatbotQuestionModel, 'find').mockResolvedValue(
- emails.map((email) => ({
- interaction: {
- course: { id: courseId, name: 'Test Course' },
- user: { email },
- },
- })) as any,
- );
-
- const body = makeBody();
-
- const result = await controller.notifyUpdatedAnswer(
- courseId,
- vectorStoreId,
- body,
- makeUser(courseId, Role.PROFESSOR),
- );
-
- // Should only send to first 5 emails
- expect(mailService.sendEmail).toHaveBeenCalledTimes(5);
- const calledRecipients = mailService.sendEmail.mock.calls.map(
- (args) => args[0].receiverOrReceivers,
- );
- expect(calledRecipients).toEqual(emails.slice(0, 5));
-
- expect(result).toEqual({ recipients: 5 });
- });
-});
diff --git a/packages/server/src/chatbot/chatbot.controller.ts b/packages/server/src/chatbot/chatbot.controller.ts
index 78dc9f4b9..93031fe08 100644
--- a/packages/server/src/chatbot/chatbot.controller.ts
+++ b/packages/server/src/chatbot/chatbot.controller.ts
@@ -45,7 +45,6 @@ import {
GetChatbotHistoryResponse,
GetInteractionsAndQuestionsResponse,
InteractionResponse,
- MailServiceType,
LLMType,
OllamaLLMType,
OpenAILLMType,
@@ -89,8 +88,6 @@ import {
IgnoreableClassSerializerInterceptor,
IgnoreSerializer,
} from '../interceptors/IgnoreableClassSerializerInterceptor';
-import { MailService } from '../mail/mail.service';
-import { ChatbotQuestionModel } from './question.entity';
@Controller('chatbot')
@UseGuards(JwtAuthGuard, EmailVerifiedGuard)
@@ -99,7 +96,6 @@ export class ChatbotController {
constructor(
private readonly chatbotService: ChatbotService,
private readonly chatbotApiService: ChatbotApiService,
- private readonly mailService: MailService,
) {}
//
@@ -394,107 +390,17 @@ export class ChatbotController {
@Param('vectorStoreId') vectorStoreId: string,
@Body() body: NotifyUpdatedChatbotAnswerParams,
@User({ courses: true }) user: UserModel,
- ): Promise<{ recipients: number }> {
- const { oldAnswer, newAnswer, oldQuestion, newQuestion } = body ?? {};
-
- // Enforce: only allow notify when answer changed
- if ((oldAnswer ?? '').trim() === (newAnswer ?? '').trim()) {
- throw new BadRequestException(
- 'Cannot send notification: answer was not changed.',
- );
- }
-
- // Find all interactions in this course where this question was asked
- const asked = await ChatbotQuestionModel.find({
- where: {
- vectorStoreId: vectorStoreId,
- interaction: {
- course: {
- id: courseId,
- },
- },
- },
- relations: { interaction: { user: true, course: true } },
- });
- const recipients = Array.from(
- new Set(
- asked
- .filter((a) => a.interaction?.course?.id === courseId)
- .map((a) => a.interaction?.user?.email)
- .filter((e): e is string => !!e),
- ),
+ ): Promise<{
+ recipients: number;
+ totalRecipients: number;
+ unsubscribedRecipients: number;
+ }> {
+ return await this.chatbotService.notifyUpdatedAnswer(
+ courseId,
+ vectorStoreId,
+ body,
+ user,
);
-
- if (recipients.length === 0) {
- throw new BadRequestException(
- 'Cannot send notification: no recipients for this question.',
- );
- }
-
- const qChanged =
- (oldQuestion ?? '').trim() !== (newQuestion ?? oldQuestion ?? '').trim();
-
- const questionSection = qChanged
- ? `| Question (before) | ${
- oldQuestion ?? ''
- } |
- | Question (after) | ${
- newQuestion ?? ''
- } |
`
- : `| Question | ${
- oldQuestion ?? newQuestion ?? ''
- } |
`;
-
- const courseName = asked[0]?.interaction?.course?.name ?? 'your course';
-
- const courseRole =
- user.courses?.find((uc) => uc.courseId === courseId)?.role ?? null;
-
- const staffDescriptor =
- courseRole === Role.PROFESSOR
- ? 'the Instructor'
- : courseRole === Role.TA
- ? 'a TA'
- : 'a staff member';
-
- const html = `
-
-
The answer to a chatbot question you asked in ${courseName} has been updated by ${staffDescriptor}.
-
- ${questionSection}
-
- | Answer (before) |
- ${oldAnswer} |
-
-
- | Answer (after) |
- ${newAnswer} |
-
-
-
-
- Want to follow up? Create an
- Anytime Question
- to discuss it more!
-
-
- `;
- // TODO: Once we get our official HelpMe email, we can remove this cap
- // (this was added so we don't get rate limited by gmail as easily).
- // Note that we CANNOT just 1 email with all the recipients, since then students will be able to see each others emails (and that they asked a particular question)
- const limitedRecipients = recipients.slice(0, 5);
-
- for (const email of limitedRecipients) {
- await this.mailService.sendEmail({
- receiverOrReceivers: email,
- subject: `Chatbot answer updated by staff member for ${courseName}`,
- type: MailServiceType.CHATBOT_ANSWER_UPDATED,
- content: html,
- track: false,
- });
- }
-
- return { recipients: limitedRecipients.length };
}
// @Get('resetCourse/:courseId')
// @UseGuards(CourseRolesGuard)
diff --git a/packages/server/src/chatbot/chatbot.service.spec.ts b/packages/server/src/chatbot/chatbot.service.spec.ts
index a2b9a83a8..c97754422 100644
--- a/packages/server/src/chatbot/chatbot.service.spec.ts
+++ b/packages/server/src/chatbot/chatbot.service.spec.ts
@@ -37,7 +37,9 @@ import {
CreateOrganizationChatbotSettingsBody,
dropUndefined,
ERROR_MESSAGES,
+ NotifyUpdatedChatbotAnswerParams,
OrganizationChatbotSettingsDefaults,
+ Role,
UpdateChatbotProviderBody,
UpdateLLMTypeBody,
UpsertCourseChatbotSettings,
@@ -52,6 +54,7 @@ import {
} from './chatbot-datasource/chatbot-datasource.service';
import { ChatbotApiService } from './chatbot-api.service';
import { MailService } from 'mail/mail.service';
+import { UserSubscriptionModel } from '../mail/user-subscriptions.entity';
const mockCacheManager = {
get: jest.fn(),
@@ -142,6 +145,7 @@ describe('ChatbotService', () => {
).then();
});
updateChatbotRepositorySpy.mockRestore();
+ mockMailService.sendEmail.mockReset();
});
beforeEach(async () => {
@@ -1885,4 +1889,146 @@ describe('ChatbotService', () => {
},
);
});
+
+ describe('notifyUpdatedAnswer', () => {
+ const makeBody = (
+ overrides: Partial = {},
+ ): NotifyUpdatedChatbotAnswerParams => ({
+ oldAnswer: 'old',
+ newAnswer: 'new',
+ ...overrides,
+ });
+
+ const makeUser = (courseId: number, role: Role): any => ({
+ id: 1,
+ courses: [{ courseId, role }],
+ });
+
+ it('throws when answer did not change', async () => {
+ await expect(
+ service.notifyUpdatedAnswer(
+ 1,
+ 'vec-id',
+ makeBody({ oldAnswer: 'same', newAnswer: ' same ' }),
+ makeUser(1, Role.PROFESSOR),
+ ),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it('throws when there are no recipients', async () => {
+ const findSpy = jest
+ .spyOn(ChatbotQuestionModel, 'find')
+ .mockResolvedValue([] as any);
+
+ await expect(
+ service.notifyUpdatedAnswer(
+ 1,
+ 'vec-id',
+ makeBody(),
+ makeUser(1, Role.PROFESSOR),
+ ),
+ ).rejects.toBeInstanceOf(BadRequestException);
+
+ expect(mockMailService.sendEmail).not.toHaveBeenCalled();
+ findSpy.mockRestore();
+ });
+
+ it('sends individual emails and caps at 5 recipients', async () => {
+ const courseId = 1;
+ const vectorStoreId = 'vec-id';
+ const emails = [
+ 'u1@example.com',
+ 'u2@example.com',
+ 'u3@example.com',
+ 'u4@example.com',
+ 'u5@example.com',
+ 'u6@example.com',
+ ];
+
+ const findSpy = jest
+ .spyOn(ChatbotQuestionModel, 'find')
+ .mockResolvedValue(
+ emails.map((email) => ({
+ interaction: {
+ course: { id: courseId, name: 'Test Course' },
+ user: { email },
+ },
+ })) as any,
+ );
+ const subscriptionSpy = jest
+ .spyOn(UserSubscriptionModel, 'find')
+ .mockResolvedValue([]);
+
+ const result = await service.notifyUpdatedAnswer(
+ courseId,
+ vectorStoreId,
+ makeBody(),
+ makeUser(courseId, Role.PROFESSOR),
+ );
+
+ expect(mockMailService.sendEmail).toHaveBeenCalledTimes(5);
+ const calledRecipients = mockMailService.sendEmail.mock.calls.map(
+ (args) => args[0].receiverOrReceivers,
+ );
+ expect(calledRecipients).toEqual(emails.slice(0, 5));
+ expect(result).toEqual({
+ recipients: 5,
+ totalRecipients: 5,
+ unsubscribedRecipients: 0,
+ });
+ findSpy.mockRestore();
+ subscriptionSpy.mockRestore();
+ });
+
+ it('does not email unsubscribed recipients and reports counts', async () => {
+ const courseId = 1;
+ const vectorStoreId = 'vec-id';
+ const emails = [
+ 'u1@example.com',
+ 'u2@example.com',
+ 'u3@example.com',
+ 'u4@example.com',
+ 'u5@example.com',
+ ];
+ const unsubscribedEmails = [emails[1], emails[3]];
+
+ const findSpy = jest
+ .spyOn(ChatbotQuestionModel, 'find')
+ .mockResolvedValue(
+ emails.map((email) => ({
+ interaction: {
+ course: { id: courseId, name: 'Test Course' },
+ user: { email },
+ },
+ })) as any,
+ );
+ const subscriptionSpy = jest
+ .spyOn(UserSubscriptionModel, 'find')
+ .mockResolvedValue(
+ unsubscribedEmails.map((email) => ({ user: { email } })) as any,
+ );
+
+ const result = await service.notifyUpdatedAnswer(
+ courseId,
+ vectorStoreId,
+ makeBody(),
+ makeUser(courseId, Role.PROFESSOR),
+ );
+
+ expect(mockMailService.sendEmail).toHaveBeenCalledTimes(3);
+ const calledRecipients = mockMailService.sendEmail.mock.calls.map(
+ (args) => args[0].receiverOrReceivers,
+ );
+ expect(calledRecipients).toEqual(
+ emails.filter((email) => !unsubscribedEmails.includes(email)),
+ );
+ expect(result).toEqual({
+ recipients: 3,
+ totalRecipients: 5,
+ unsubscribedRecipients: 2,
+ });
+ findSpy.mockRestore();
+ subscriptionSpy.mockRestore();
+ });
+ });
});
diff --git a/packages/server/src/chatbot/chatbot.service.ts b/packages/server/src/chatbot/chatbot.service.ts
index d5a971567..1931af7b2 100644
--- a/packages/server/src/chatbot/chatbot.service.ts
+++ b/packages/server/src/chatbot/chatbot.service.ts
@@ -21,10 +21,13 @@ import {
CreateOrganizationChatbotSettingsBody,
dropUndefined,
ERROR_MESSAGES,
+ MailServiceType,
+ NotifyUpdatedChatbotAnswerParams,
OllamaLLMType,
OllamaModelDescription,
OpenAILLMType,
OrganizationChatbotSettingsDefaults,
+ Role,
UpdateChatbotProviderBody,
UpdateLLMTypeBody,
UpsertCourseChatbotSettings,
@@ -39,6 +42,8 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { ChatbotApiService } from './chatbot-api.service';
import { ChatbotDataSourceService } from './chatbot-datasource/chatbot-datasource.service';
+import { MailService } from '../mail/mail.service';
+import { UserSubscriptionModel } from '../mail/user-subscriptions.entity';
// OpenAI makes it really annoying to scrape any data related
// to the model capabilities (text, image processing) (selfish, much?)
@@ -137,6 +142,7 @@ export class ChatbotService {
private dataSource: DataSource,
private chatbotDataSource: ChatbotDataSourceService,
private chatbotApiService: ChatbotApiService,
+ private readonly mailService: MailService,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
) {}
@@ -316,6 +322,137 @@ export class ChatbotService {
return question;
}
+ async notifyUpdatedAnswer(
+ courseId: number,
+ vectorStoreId: string,
+ body: NotifyUpdatedChatbotAnswerParams,
+ user: UserModel,
+ ): Promise<{
+ recipients: number;
+ totalRecipients: number;
+ unsubscribedRecipients: number;
+ }> {
+ const { oldAnswer, newAnswer, oldQuestion, newQuestion } = body ?? {};
+
+ if ((oldAnswer ?? '').trim() === (newAnswer ?? '').trim()) {
+ throw new BadRequestException(
+ 'Cannot send notification: answer was not changed.',
+ );
+ }
+
+ const asked = await ChatbotQuestionModel.find({
+ where: {
+ vectorStoreId,
+ interaction: {
+ course: {
+ id: courseId,
+ },
+ },
+ },
+ relations: { interaction: { user: true, course: true } },
+ });
+
+ const recipients = Array.from(
+ new Set(
+ asked
+ .filter((a) => a.interaction?.course?.id === courseId)
+ .map((a) => a.interaction?.user?.email)
+ .filter((email): email is string => !!email),
+ ),
+ );
+
+ if (recipients.length === 0) {
+ throw new BadRequestException(
+ 'Cannot send notification: no recipients for this question.',
+ );
+ }
+
+ const qChanged =
+ (oldQuestion ?? '').trim() !== (newQuestion ?? oldQuestion ?? '').trim();
+
+ const questionSection = qChanged
+ ? `| Question (before) | ${
+ oldQuestion ?? ''
+ } |
+ | Question (after) | ${
+ newQuestion ?? ''
+ } |
`
+ : `| Question | ${
+ oldQuestion ?? newQuestion ?? ''
+ } |
`;
+
+ const courseName = asked[0]?.interaction?.course?.name ?? 'your course';
+
+ const courseRole =
+ user.courses?.find((uc) => uc.courseId === courseId)?.role ?? null;
+
+ const staffDescriptor =
+ courseRole === Role.PROFESSOR
+ ? 'the Instructor'
+ : courseRole === Role.TA
+ ? 'a TA'
+ : 'a staff member';
+
+ const html = `
+
+
The answer to a chatbot question you asked in ${courseName} has been updated by ${staffDescriptor}.
+
+ ${questionSection}
+
+ | Answer (before) |
+ ${oldAnswer} |
+
+
+ | Answer (after) |
+ ${newAnswer} |
+
+
+
+
+ Want to follow up? Create an
+ Anytime Question
+ to discuss it more!
+
+
+ `;
+
+ // TODO: Once we get our official HelpMe email, we can remove this cap
+ // (this was added so we don't get rate limited by gmail as easily).
+ // Note that we CANNOT just 1 email with all the recipients, since then students
+ // will be able to see each others emails (and that they asked a particular question)
+ const limitedRecipients = recipients.slice(0, 5);
+ const unsubscribed = await UserSubscriptionModel.find({
+ where: {
+ isSubscribed: false,
+ user: { email: In(limitedRecipients) },
+ service: { serviceType: MailServiceType.CHATBOT_ANSWER_UPDATED },
+ },
+ relations: { user: true },
+ });
+ const unsubscribedEmails = new Set(
+ unsubscribed.map((sub) => sub.user.email),
+ );
+ const subscribedRecipients = limitedRecipients.filter(
+ (email) => !unsubscribedEmails.has(email),
+ );
+
+ for (const email of subscribedRecipients) {
+ await this.mailService.sendEmail({
+ receiverOrReceivers: email,
+ subject: `Chatbot answer updated by staff member for ${courseName}`,
+ type: MailServiceType.CHATBOT_ANSWER_UPDATED,
+ content: html,
+ track: false,
+ });
+ }
+
+ return {
+ recipients: subscribedRecipients.length,
+ totalRecipients: limitedRecipients.length,
+ unsubscribedRecipients: unsubscribedEmails.size,
+ };
+ }
+
async isChatbotServiceLegacy(courseId: number): Promise {
const course = await CourseModel.findOne({
where: { id: courseId },
diff --git a/packages/server/test/chatbot.integration.ts b/packages/server/test/chatbot.integration.ts
index 072e2f690..a78c310a1 100644
--- a/packages/server/test/chatbot.integration.ts
+++ b/packages/server/test/chatbot.integration.ts
@@ -19,16 +19,19 @@ import {
CourseFactory,
InteractionFactory,
LLMTypeFactory,
+ mailServiceFactory,
OrganizationChatbotSettingsFactory,
OrganizationCourseFactory,
OrganizationFactory,
OrganizationUserFactory,
+ userSubscriptionFactory,
UserCourseFactory,
UserFactory,
} from './util/factories';
import {
expectEmailNotSent,
expectEmailSent,
+ mockEmailService,
overrideEmailService,
setupIntegrationTest,
} from './util/testUtils';
@@ -147,97 +150,281 @@ describe('ChatbotController Integration', () => {
jest.clearAllMocks();
});
- it('sends individual emails (capped at 5 recipients) to users who asked the question', async () => {
- const professor = await UserFactory.create();
+ const createStaffForCourse = async (role: Role = Role.PROFESSOR) => {
+ const staff = await UserFactory.create();
const course = await CourseFactory.create();
await UserCourseFactory.create({
- user: professor,
+ user: staff,
course,
- role: Role.PROFESSOR,
+ role,
+ });
+ return { staff, course };
+ };
+
+ const createAskedQuestion = async (
+ course: CourseModel,
+ vectorStoreId: string,
+ email: string,
+ ) => {
+ const student = await UserFactory.create({ email });
+ await UserCourseFactory.create({
+ user: student,
+ course,
+ role: Role.STUDENT,
+ });
+ const interaction = await InteractionFactory.create({
+ user: student,
+ course,
+ });
+ await ChatbotQuestionModel.create({
+ vectorStoreId,
+ questionText: 'Original question text',
+ responseText: 'Old answer',
+ suggested: true,
+ interaction,
+ }).save();
+ return student;
+ };
+
+ const notifyBody = (overrides: Record = {}) => ({
+ oldAnswer: 'Old answer',
+ newAnswer: 'New updated answer',
+ oldQuestion: 'Old question?',
+ newQuestion: 'New updated question?',
+ ...overrides,
+ });
+
+ it('only allows professors and TAs to use notify endpoint', async () => {
+ const { course } = await createStaffForCourse(Role.PROFESSOR);
+ const vectorStoreId = 'vec-notify-access-control';
+ await createAskedQuestion(course, vectorStoreId, 'asked@example.com');
+
+ const ta = await UserFactory.create();
+ await UserCourseFactory.create({
+ user: ta,
+ course,
+ role: Role.TA,
+ });
+
+ const student = await UserFactory.create();
+ await UserCourseFactory.create({
+ user: student,
+ course,
+ role: Role.STUDENT,
});
+ await supertest({ userId: ta.id })
+ .post(`/chatbot/question/${course.id}/${vectorStoreId}/notify`)
+ .send(notifyBody())
+ .expect(201);
+
+ await supertest({ userId: student.id })
+ .post(`/chatbot/question/${course.id}/${vectorStoreId}/notify`)
+ .send(notifyBody())
+ .expect(403);
+ });
+
+ it('sends individual emails (capped at 5 recipients) to users who asked the question', async () => {
+ const { staff, course } = await createStaffForCourse(Role.PROFESSOR);
+
const vectorStoreId = 'vec-notify-123';
const students = await Promise.all(
Array.from({ length: 6 }).map((_, i) =>
- UserFactory.create({
- email: `chatbot-notify-${i}@example.com`,
- }),
+ createAskedQuestion(
+ course,
+ vectorStoreId,
+ `chatbot-notify-${i}@example.com`,
+ ),
),
);
- for (const student of students) {
- await UserCourseFactory.create({
- user: student,
- course,
- role: Role.STUDENT,
- });
- const interaction = await InteractionFactory.create({
+ const res = await supertest({ userId: staff.id })
+ .post(`/chatbot/question/${course.id}/${vectorStoreId}/notify`)
+ .send(notifyBody())
+ .expect(201);
+
+ expect(res.body).toEqual({
+ recipients: 5,
+ totalRecipients: 5,
+ unsubscribedRecipients: 0,
+ });
+ expectEmailSent(
+ students.slice(0, 5).map((s) => s.email),
+ Array(5).fill(MailServiceType.CHATBOT_ANSWER_UPDATED),
+ );
+ });
+
+ it('respects explicit unsubscribes and returns recipient counts', async () => {
+ const { staff, course } = await createStaffForCourse(Role.PROFESSOR);
+
+ const vectorStoreId = 'vec-notify-unsubscribed';
+ const students = await Promise.all(
+ Array.from({ length: 5 }).map((_, i) =>
+ createAskedQuestion(
+ course,
+ vectorStoreId,
+ `chatbot-notify-unsub-${i}@example.com`,
+ ),
+ ),
+ );
+
+ const mailService = await mailServiceFactory.create({
+ serviceType: MailServiceType.CHATBOT_ANSWER_UPDATED,
+ name: 'chatbot_answer_updated',
+ });
+
+ for (const [i, student] of students.entries()) {
+ await userSubscriptionFactory.create({
user: student,
- course,
+ service: mailService,
+ isSubscribed: i < 3,
});
- await ChatbotQuestionModel.create({
- vectorStoreId,
- questionText: 'Original question text',
- responseText: 'Old answer',
- suggested: true,
- interaction,
- }).save();
}
- const body = {
- oldAnswer: 'Old answer',
- newAnswer: 'New updated answer',
- oldQuestion: 'Old question?',
- newQuestion: 'New updated question?',
- };
-
- const res = await supertest({ userId: professor.id })
+ const res = await supertest({ userId: staff.id })
.post(`/chatbot/question/${course.id}/${vectorStoreId}/notify`)
- .send(body)
+ .send(notifyBody())
.expect(201);
- expect(res.body).toEqual({ recipients: 5 });
+ expect(res.body).toEqual({
+ recipients: 3,
+ totalRecipients: 5,
+ unsubscribedRecipients: 2,
+ });
expectEmailSent(
- students.slice(0, 5).map((s) => s.email),
- Array(5).fill(MailServiceType.CHATBOT_ANSWER_UPDATED),
+ students.slice(0, 3).map((s) => s.email),
+ Array(3).fill(MailServiceType.CHATBOT_ANSWER_UPDATED),
);
});
it('returns 400 and does not send emails when answer did not change', async () => {
- const professor = await UserFactory.create();
- const course = await CourseFactory.create();
- await UserCourseFactory.create({
- user: professor,
- course,
- role: Role.PROFESSOR,
- });
-
+ const { staff, course } = await createStaffForCourse(Role.PROFESSOR);
const vectorStoreId = 'vec-notify-unchanged';
- const student = await UserFactory.create();
+ await createAskedQuestion(course, vectorStoreId, 'unchanged@example.com');
+
+ await supertest({ userId: staff.id })
+ .post(`/chatbot/question/${course.id}/${vectorStoreId}/notify`)
+ .send(
+ notifyBody({ oldAnswer: 'Same answer', newAnswer: ' Same answer ' }),
+ )
+ .expect(400);
+
+ expectEmailNotSent();
+ });
+
+ it('only notifies students who asked the target question', async () => {
+ const { staff, course } = await createStaffForCourse(Role.PROFESSOR);
+ const vectorStoreId = 'vec-notify-only-asked';
+ const askedStudents = await Promise.all(
+ Array.from({ length: 2 }).map((_, i) =>
+ createAskedQuestion(course, vectorStoreId, `asked-${i}@example.com`),
+ ),
+ );
+
+ const otherStudent = await UserFactory.create({
+ email: 'not-asked@example.com',
+ });
await UserCourseFactory.create({
- user: student,
+ user: otherStudent,
course,
role: Role.STUDENT,
});
- const interaction = await InteractionFactory.create({
- user: student,
+
+ await supertest({ userId: staff.id })
+ .post(`/chatbot/question/${course.id}/${vectorStoreId}/notify`)
+ .send(notifyBody())
+ .expect(201);
+
+ expectEmailSent(
+ askedStudents.map((s) => s.email),
+ Array(askedStudents.length).fill(
+ MailServiceType.CHATBOT_ANSWER_UPDATED,
+ ),
+ );
+ const calledRecipients = mockEmailService.sendEmail.mock.calls.map(
+ (call) => call[0].receiverOrReceivers,
+ );
+ expect(calledRecipients).not.toContain(otherStudent.email);
+ });
+
+ it('succeeds when oldAnswer is empty string and newAnswer is non-empty', async () => {
+ const { staff, course } = await createStaffForCourse(Role.PROFESSOR);
+ const vectorStoreId = 'vec-notify-empty-old';
+ await createAskedQuestion(course, vectorStoreId, 'empty-old@example.com');
+
+ const res = await supertest({ userId: staff.id })
+ .post(`/chatbot/question/${course.id}/${vectorStoreId}/notify`)
+ .send(notifyBody({ oldAnswer: '', newAnswer: 'Updated answer' }))
+ .expect(201);
+
+ expect(res.body.recipients).toBe(1);
+ });
+
+ it('succeeds when newAnswer is empty string and oldAnswer is non-empty', async () => {
+ const { staff, course } = await createStaffForCourse(Role.PROFESSOR);
+ const vectorStoreId = 'vec-notify-empty-new';
+ await createAskedQuestion(course, vectorStoreId, 'empty-new@example.com');
+
+ const res = await supertest({ userId: staff.id })
+ .post(`/chatbot/question/${course.id}/${vectorStoreId}/notify`)
+ .send(notifyBody({ oldAnswer: 'Old answer', newAnswer: '' }))
+ .expect(201);
+
+ expect(res.body.recipients).toBe(1);
+ });
+
+ it('returns 400 if oldAnswer is missing', async () => {
+ const { staff, course } = await createStaffForCourse(Role.PROFESSOR);
+ const vectorStoreId = 'vec-notify-missing-old';
+ await createAskedQuestion(
course,
- });
- await ChatbotQuestionModel.create({
vectorStoreId,
- questionText: 'Some question',
- responseText: 'Same answer',
- suggested: true,
- interaction,
- }).save();
+ 'missing-old@example.com',
+ );
- const body = {
- oldAnswer: 'Same answer',
- newAnswer: ' Same answer ',
- };
+ await supertest({ userId: staff.id })
+ .post(`/chatbot/question/${course.id}/${vectorStoreId}/notify`)
+ .send({ newAnswer: 'updated' })
+ .expect(400);
+ });
+
+ it('returns 400 if newAnswer is missing', async () => {
+ const { staff, course } = await createStaffForCourse(Role.PROFESSOR);
+ const vectorStoreId = 'vec-notify-missing-new';
+ await createAskedQuestion(
+ course,
+ vectorStoreId,
+ 'missing-new@example.com',
+ );
+
+ await supertest({ userId: staff.id })
+ .post(`/chatbot/question/${course.id}/${vectorStoreId}/notify`)
+ .send({ oldAnswer: 'old' })
+ .expect(400);
+ });
+
+ it.each([
+ [{ oldAnswer: 123, newAnswer: 'new' }, 'oldAnswer number'],
+ [{ oldAnswer: 'old', newAnswer: 456 }, 'newAnswer number'],
+ [
+ { oldAnswer: 'old', newAnswer: 'new', oldQuestion: 999 },
+ 'oldQuestion number',
+ ],
+ [
+ { oldAnswer: 'old', newAnswer: 'new', newQuestion: false },
+ 'newQuestion boolean',
+ ],
+ ])('returns 400 for invalid DTO type: %s', async (body, _label) => {
+ const { staff, course } = await createStaffForCourse(Role.PROFESSOR);
+ const vectorStoreId = `vec-notify-invalid-${Math.random().toString(36).slice(2)}`;
+ await createAskedQuestion(
+ course,
+ vectorStoreId,
+ 'invalid-type@example.com',
+ );
- await supertest({ userId: professor.id })
+ await supertest({ userId: staff.id })
.post(`/chatbot/question/${course.id}/${vectorStoreId}/notify`)
.send(body)
.expect(400);
From c41c2ef38b53d00117846ca22e11de336c503618 Mon Sep 17 00:00:00 2001
From: ribhavsharma
Date: Wed, 25 Feb 2026 19:34:54 -0800
Subject: [PATCH 10/11] fix test
---
packages/server/test/chatbot.integration.ts | 37 ++++++++++++++++++---
1 file changed, 33 insertions(+), 4 deletions(-)
diff --git a/packages/server/test/chatbot.integration.ts b/packages/server/test/chatbot.integration.ts
index a78c310a1..17f3a5a32 100644
--- a/packages/server/test/chatbot.integration.ts
+++ b/packages/server/test/chatbot.integration.ts
@@ -249,8 +249,20 @@ describe('ChatbotController Integration', () => {
totalRecipients: 5,
unsubscribedRecipients: 0,
});
- expectEmailSent(
- students.slice(0, 5).map((s) => s.email),
+ const calledEmails = mockEmailService.sendEmail.mock.calls.map(
+ (args) => args[0].receiverOrReceivers,
+ );
+ const calledTypes = mockEmailService.sendEmail.mock.calls.map(
+ (args) => args[0].type,
+ );
+ const allStudentEmails = new Set(students.map((s) => s.email));
+
+ expect(calledEmails).toHaveLength(5);
+ expect(new Set(calledEmails).size).toBe(5);
+ for (const email of calledEmails) {
+ expect(allStudentEmails.has(email)).toBe(true);
+ }
+ expect(calledTypes).toEqual(
Array(5).fill(MailServiceType.CHATBOT_ANSWER_UPDATED),
);
});
@@ -292,8 +304,25 @@ describe('ChatbotController Integration', () => {
totalRecipients: 5,
unsubscribedRecipients: 2,
});
- expectEmailSent(
- students.slice(0, 3).map((s) => s.email),
+ const calledEmails = mockEmailService.sendEmail.mock.calls.map(
+ (args) => args[0].receiverOrReceivers,
+ );
+ const calledTypes = mockEmailService.sendEmail.mock.calls.map(
+ (args) => args[0].type,
+ );
+ const subscribedEmails = new Set(
+ students.slice(0, 3).map((student) => student.email),
+ );
+ const unsubscribedEmails = new Set(
+ students.slice(3).map((student) => student.email),
+ );
+
+ expect(calledEmails).toHaveLength(3);
+ for (const email of calledEmails) {
+ expect(subscribedEmails.has(email)).toBe(true);
+ expect(unsubscribedEmails.has(email)).toBe(false);
+ }
+ expect(calledTypes).toEqual(
Array(3).fill(MailServiceType.CHATBOT_ANSWER_UPDATED),
);
});
From 980bd5d2226533887b6b4955d766705a0339a71d Mon Sep 17 00:00:00 2001
From: ribhavsharma
Date: Wed, 25 Feb 2026 21:48:11 -0800
Subject: [PATCH 11/11] fixing flaky test + rm questionable workaround
---
packages/server/test/question.integration.ts | 40 ++++++++------------
1 file changed, 16 insertions(+), 24 deletions(-)
diff --git a/packages/server/test/question.integration.ts b/packages/server/test/question.integration.ts
index 9c60f15ad..90aafe3b2 100644
--- a/packages/server/test/question.integration.ts
+++ b/packages/server/test/question.integration.ts
@@ -29,7 +29,6 @@ import {
modifyMockNotifs,
setupIntegrationTest,
} from './util/testUtils';
-import { forEach } from 'lodash';
import { QuestionTypeModel } from 'questionType/question-type.entity';
import { StudentTaskProgressModel } from 'studentTaskProgress/studentTaskProgress.entity';
@@ -202,7 +201,7 @@ describe('Question Integration', () => {
const questionTypes = [];
- forEach(QuestionTypes, async (questionType) => {
+ for (const questionType of QuestionTypes) {
await QuestionTypeFactory.create({
name: questionType.name,
color: questionType.color,
@@ -217,7 +216,7 @@ describe('Question Integration', () => {
queueId: queue.id,
};
questionTypes.push(sendQuestionTypes);
- });
+ }
await TACourseFactory.create({ user, courseId: queue.courseId });
expect(await QuestionModel.count({ where: { queueId: 1 } })).toEqual(0);
@@ -225,14 +224,7 @@ describe('Question Integration', () => {
expect(response.status).toBe(403);
});
it('post question fails with non-existent queue', async () => {
- const course = await CourseFactory.create();
const user = await UserFactory.create();
- const ta = await TACourseFactory.create({
- course: course,
- user: await UserFactory.create(),
- });
- // wait 0.25s to help deadlock issue?
- await new Promise((resolve) => setTimeout(resolve, 250));
await supertest({ userId: user.id })
.post('/questions/999')
.send({
@@ -266,14 +258,14 @@ describe('Question Integration', () => {
course: course2,
});
const questionTypes = [];
- forEach(QuestionTypes, async (questionType) => {
+ for (const questionType of QuestionTypes) {
const currentQuestionType = await QuestionTypeFactory.create({
name: questionType.name,
color: questionType.color,
cid: course2.id,
});
questionTypes.push(currentQuestionType);
- });
+ }
const response = await postQuestion(user, queueImNotIn, questionTypes);
expect(response.status).toBe(404);
@@ -288,14 +280,14 @@ describe('Question Integration', () => {
isDisabled: true,
});
const questionTypes = [];
- forEach(QuestionTypes, async (questionType) => {
+ for (const questionType of QuestionTypes) {
const currentQuestionType = await QuestionTypeFactory.create({
name: questionType.name,
color: questionType.color,
cid: course.id,
});
questionTypes.push(currentQuestionType);
- });
+ }
const user = await UserFactory.create();
await StudentCourseFactory.create({ user, courseId: queue.courseId });
@@ -370,14 +362,14 @@ describe('Question Integration', () => {
});
const questionTypes = [];
- forEach(QuestionTypes, async (questionType) => {
+ for (const questionType of QuestionTypes) {
const currentQuestionType = await QuestionTypeFactory.create({
name: questionType.name,
color: questionType.color,
cid: course.id,
});
questionTypes.push(currentQuestionType);
- });
+ }
const response = await postQuestion(user, queue2, questionTypes);
@@ -710,14 +702,14 @@ describe('Question Integration', () => {
status: OpenQuestionStatus.Drafting,
});
const questionTypes = [];
- forEach(QuestionTypes, async (questionType) => {
+ for (const questionType of QuestionTypes) {
const currentQuestionType = await QuestionTypeFactory.create({
name: questionType.name,
color: questionType.color,
cid: course2.id,
});
questionTypes.push(currentQuestionType);
- });
+ }
const response = await postQuestion(user, queue2, questionTypes);
expect(response.status).toBe(201);
@@ -751,14 +743,14 @@ describe('Question Integration', () => {
status: OpenQuestionStatus.Drafting,
});
const questionTypes = [];
- forEach(QuestionTypes, async (questionType) => {
+ for (const questionType of QuestionTypes) {
const currentQuestionType = await QuestionTypeFactory.create({
name: questionType.name,
color: questionType.color,
cid: queue.courseId,
});
questionTypes.push(currentQuestionType);
- });
+ }
const response = await postQuestion(user, queue, questionTypes);
expect(response.status).toBe(201);
@@ -792,14 +784,14 @@ describe('Question Integration', () => {
courseId: queue.courseId,
});
const questionTypes = [];
- forEach(QuestionTypes, async (questionType) => {
+ for (const questionType of QuestionTypes) {
const currentQuestionType = await QuestionTypeFactory.create({
name: questionType.name,
color: questionType.color,
cid: queue.courseId,
});
questionTypes.push(currentQuestionType);
- });
+ }
const response = await postQuestion(user, queue, questionTypes);
expect(response.status).toBe(201);
@@ -829,14 +821,14 @@ describe('Question Integration', () => {
courseId: queue.courseId,
});
const questionTypes = [];
- forEach(QuestionTypes, async (questionType) => {
+ for (const questionType of QuestionTypes) {
const currentQuestionType = await QuestionTypeFactory.create({
name: questionType.name,
color: questionType.color,
cid: queue.courseId,
});
questionTypes.push(currentQuestionType);
- });
+ }
const response = await postQuestion(user, queue, questionTypes);
expect(response.status).toBe(201);