From 84efdc7d2f0055222289e9f3fcccb6cd44468e2d Mon Sep 17 00:00:00 2001 From: MithishR Date: Sat, 24 Jan 2026 13:56:28 -0800 Subject: [PATCH 01/13] initial cron job script --- packages/common/index.ts | 1 + packages/server/src/mail/mail.module.ts | 3 +- .../server/src/mail/weekly-summary.service.ts | 448 ++++++++++++++++++ 3 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/mail/weekly-summary.service.ts diff --git a/packages/common/index.ts b/packages/common/index.ts index c47949a68..817badf42 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -351,6 +351,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', + WEEKLY_COURSE_SUMMARY= 'weekly_course_summary', } /** * Represents one of three possible user roles in a course. diff --git a/packages/server/src/mail/mail.module.ts b/packages/server/src/mail/mail.module.ts index 83ca66309..1863a5b30 100644 --- a/packages/server/src/mail/mail.module.ts +++ b/packages/server/src/mail/mail.module.ts @@ -5,12 +5,13 @@ import { MailController } from './mail.controller'; import { MailServicesController } from './mail-services.controller'; import { UserModel } from 'profile/user.entity'; import { MailerService } from './mailer.service'; +import { WeeklySummaryService } from './weekly-summary.service'; @Global() @Module({ controllers: [MailController, MailServicesController], imports: [ConfigModule], - providers: [MailService, MailerService], + providers: [MailService, MailerService, WeeklySummaryService], exports: [MailService], }) export class MailModule {} diff --git a/packages/server/src/mail/weekly-summary.service.ts b/packages/server/src/mail/weekly-summary.service.ts new file mode 100644 index 000000000..18ba02bc7 --- /dev/null +++ b/packages/server/src/mail/weekly-summary.service.ts @@ -0,0 +1,448 @@ +import { Injectable } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { MailService } from './mail.service'; +import { UserCourseModel } from '../profile/user-course.entity'; +import { InteractionModel } from '../chatbot/interaction.entity'; +import { AsyncQuestionModel } from '../asyncQuestion/asyncQuestion.entity'; +import { CourseModel } from '../course/course.entity'; +import { MailServiceType, Role } from '@koh/common'; +import { MoreThanOrEqual } from 'typeorm'; +import * as Sentry from '@sentry/nestjs'; +import { UserSubscriptionModel } from './user-subscriptions.entity'; + +interface ChatbotStats { + totalQuestions: number; + uniqueStudents: number; + avgQuestionsPerStudent: number; + byDayOfWeek: { day: string; count: number }[]; + mostActiveDay: string; +} + +interface AsyncQuestionStats { + total: number; + aiResolved: number; + humanAnswered: number; + stillNeedHelp: number; + withNewComments: number; + avgResponseTime: number | null; +} + +@Injectable() +export class WeeklySummaryService { + constructor(private mailService: MailService) {} + + // Run every week + @Cron(CronExpression.EVERY_WEEK) + async sendWeeklySummaries() { + console.log('Starting weekly summary email job...'); + const startTime = Date.now(); + + try { + const lastWeek = new Date(); + lastWeek.setDate(lastWeek.getDate() - 7); + + // Get all professors with their courses + const professorCourses = await UserCourseModel.createQueryBuilder('uc') + .innerJoinAndSelect('uc.user', 'user') + .innerJoinAndSelect('uc.course', 'course') + .leftJoinAndSelect('course.semester', 'semester') + .where('uc.role = :role', { role: Role.PROFESSOR }) + .andWhere('course.deletedAt IS NULL') + .andWhere('course.enabled = :enabled', { enabled: true }) + .getMany(); + + console.log( + `Found ${professorCourses.length} professor-course relationships`, + ); + + let emailsSent = 0; + let emailsFailed = 0; + + // Process each professor-course pair + for (const professorCourse of professorCourses) { + try { + // Check if professor is subscribed to weekly summaries + const subscription = await UserSubscriptionModel.findOne({ + where: { + userId: professorCourse.user.id, + isSubscribed: true, + service: { + serviceType: MailServiceType.WEEKLY_COURSE_SUMMARY, + }, + }, + relations: ['service'], + }); + + if (!subscription) { + console.log( + `Professor ${professorCourse.user.email} unsubscribed from weekly summaries`, + ); + continue; + } + + // Gather statistics + const chatbotStats = await this.getChatbotStats( + professorCourse.courseId, + lastWeek, + ); + const asyncStats = await this.getAsyncQuestionStats( + professorCourse.courseId, + lastWeek, + ); + + // Check if there's any activity + const hasActivity = + chatbotStats.totalQuestions > 0 || asyncStats.total > 0; + + // If no activity, check if archiving + if (!hasActivity) { + const shouldSuggestArchive = await this.shouldSuggestArchiving( + professorCourse.course, + ); + + if (shouldSuggestArchive) { + // Send archive suggestion email + await this.sendArchiveSuggestionEmail(professorCourse); + emailsSent++; + } + continue; + } + + // Build and send the email + const emailHtml = this.buildWeeklySummaryEmail( + professorCourse.course, + chatbotStats, + asyncStats, + ); + + await this.mailService.sendEmail({ + receiverOrReceivers: professorCourse.user.email, + type: MailServiceType.WEEKLY_COURSE_SUMMARY, + subject: `HelpMe Weekly Summary: ${professorCourse.course.name} - Week of ${this.formatDate(lastWeek)}`, + content: emailHtml, + }); + + emailsSent++; + console.log( + `Sent weekly summary to ${professorCourse.user.email} for course ${professorCourse.course.name}`, + ); + } catch (error) { + emailsFailed++; + console.error( + `Failed to send weekly summary for course ${professorCourse.courseId} to ${professorCourse.user.email}:`, + error, + ); + Sentry.captureException(error, { + extra: { + courseId: professorCourse.courseId, + userId: professorCourse.user.id, + }, + }); + } + } + + const duration = Date.now() - startTime; + console.log( + `Weekly summary job completed in ${duration}ms. Sent: ${emailsSent}, Failed: ${emailsFailed}`, + ); + } catch (error) { + console.error('Fatal error in weekly summary job:', error); + Sentry.captureException(error); + } + } + + private async getChatbotStats( + courseId: number, + since: Date, + ): Promise { + const interactions = await InteractionModel.createQueryBuilder('interaction') + .leftJoinAndSelect('interaction.questions', 'questions') + .leftJoinAndSelect('interaction.user', 'user') + .where('interaction.course = :courseId', { courseId }) + .andWhere('interaction.timestamp >= :since', { since }) + .getMany(); + + const totalQuestions = interactions.reduce( + (sum, i) => sum + (i.questions?.length || 0), + 0, + ); + + const uniqueStudents = new Set(interactions.map((i) => i.user.id)).size; + + const avgQuestionsPerStudent = + uniqueStudents > 0 ? totalQuestions / uniqueStudents : 0; + + const byDayOfWeek = this.groupByDayOfWeek(interactions); + + const mostActiveDay = + byDayOfWeek.length > 0 + ? byDayOfWeek.reduce((max, day) => + day.count > max.count ? day : max, + ).day + : 'N/A'; + + return { + totalQuestions, + uniqueStudents, + avgQuestionsPerStudent, + byDayOfWeek, + mostActiveDay, + }; + } + + private async getAsyncQuestionStats( + courseId: number, + since: Date, + ): Promise { + const questions = await AsyncQuestionModel.createQueryBuilder('aq') + .leftJoinAndSelect('aq.comments', 'comments') + .where('aq.courseId = :courseId', { courseId }) + .andWhere('aq.createdAt >= :since', { since }) + .getMany(); + + const total = questions.length; + const aiResolved = questions.filter( + (q) => q.aiAnswerText && q.status === 'AIAnswered', + ).length; + const humanAnswered = questions.filter( + (q) => q.answerText && q.status === 'HumanAnswered', + ).length; + const stillNeedHelp = questions.filter((q) => !q.closedAt).length; + const withNewComments = questions.filter((q) => + q.comments?.some((c) => c.createdAt >= since), + ).length; + + // Calculate average response time for answered questions + const answeredQuestions = questions.filter((q) => q.closedAt); + const avgResponseTime = + answeredQuestions.length > 0 + ? answeredQuestions.reduce( + (sum, q) => + sum + + (q.closedAt.getTime() - new Date(q.createdAt).getTime()) / + (1000 * 60 * 60), + 0, + ) / answeredQuestions.length + : null; + + return { + total, + aiResolved, + humanAnswered, + stillNeedHelp, + withNewComments, + avgResponseTime, + }; + } + + private groupByDayOfWeek( + interactions: InteractionModel[], + ): { day: string; count: number }[] { + const dayNames = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + ]; + const dayCounts = new Array(7).fill(0); + + interactions.forEach((interaction) => { + const dayOfWeek = new Date(interaction.timestamp).getDay(); + dayCounts[dayOfWeek]++; + }); + + return dayNames.map((day, index) => ({ + day, + count: dayCounts[index], + })); + } + +//Edge cases + private async shouldSuggestArchiving(course: CourseModel): Promise { + // Check if semester has ended + if (course.semester?.endDate) { + const semesterEndDate = new Date(course.semester.endDate); + if (semesterEndDate < new Date()) { + return true; + } + } + + // Check for recent activity (4 weeks) + const fourWeeksAgo = new Date(); + fourWeeksAgo.setDate(fourWeeksAgo.getDate() - 28); + + const recentInteractions = await InteractionModel.count({ + where: { + course: { id: course.id }, + timestamp: MoreThanOrEqual(fourWeeksAgo), + }, + }); + + const recentAsyncQuestions = await AsyncQuestionModel.count({ + where: { + courseId: course.id, + createdAt: MoreThanOrEqual(fourWeeksAgo), + }, + }); + + return recentInteractions === 0 && recentAsyncQuestions === 0; + } + + private buildWeeklySummaryEmail( + course: CourseModel, + chatbotStats: ChatbotStats, + asyncStats: AsyncQuestionStats, + ): string { + const lastWeek = new Date(); + lastWeek.setDate(lastWeek.getDate() - 7); + + let html = ` +
+

Weekly Summary: ${course.name}

+

Week of ${this.formatDate(lastWeek)} - ${this.formatDate(new Date())}

+
+ `; + + // Chatbot Activity Section + if (chatbotStats.totalQuestions > 0) { + html += ` +

Chatbot Activity

+
    +
  • ${chatbotStats.totalQuestions} questions asked by ${chatbotStats.uniqueStudents} unique student${chatbotStats.uniqueStudents !== 1 ? 's' : ''}
  • +
  • Average: ${chatbotStats.avgQuestionsPerStudent.toFixed(1)} questions per student
  • +
  • Most active day: ${chatbotStats.mostActiveDay}
  • +
+ +

Daily Breakdown:

+ + `; + + chatbotStats.byDayOfWeek.forEach((dayData) => { + if (dayData.count > 0) { + const barWidth = Math.max( + (dayData.count / chatbotStats.totalQuestions) * 100, + 5, + ); + html += ` + + + + + `; + } + }); + + html += ` +
${dayData.day}: +
+ ${dayData.count} +
+ `; + } else { + html += ` +

Chatbot Activity

+

No chatbot questions this week.

+ `; + } + + // Async Questions Section + if (asyncStats.total > 0) { + html += ` +
+

Anytime Questions

+
    +
  • ${asyncStats.total} new question${asyncStats.total !== 1 ? 's' : ''} posted
  • +
  • ${asyncStats.aiResolved} resolved via AI
  • +
  • ${asyncStats.humanAnswered} answered by staff
  • +
  • ${asyncStats.stillNeedHelp} still need help
  • + `; + + if (asyncStats.withNewComments > 0) { + html += `
  • ${asyncStats.withNewComments} question${asyncStats.withNewComments !== 1 ? 's' : ''} received new comments
  • `; + } + + if (asyncStats.avgResponseTime !== null) { + html += `
  • ⏱Average response time: ${asyncStats.avgResponseTime.toFixed(1)} hours
  • `; + } + + html += ` +
+ `; + + if (asyncStats.stillNeedHelp > 0) { + html += ` +
+ Action Needed: ${asyncStats.stillNeedHelp} question${asyncStats.stillNeedHelp !== 1 ? 's' : ''} still need${asyncStats.stillNeedHelp === 1 ? 's' : ''} your attention. +
+ `; + } + } else { + html += ` +
+

Anytime Questions

+

No async questions this week.

+ `; + } + + html += ` +
+

+ + View Course Dashboard + +

+
+ `; + + return html; + } + + private async sendArchiveSuggestionEmail(professorCourse: any): Promise { + const course = professorCourse.course; + + const html = ` +
+

📋 Course Activity Update: ${course.name}

+ +
+

We noticed that ${course.name} hasn't had any activity in the last 4 weeks.

+
+ +

Possible reasons:

+
    + ${course.semester?.endDate && new Date(course.semester.endDate) < new Date() ? '
  • The semester has ended
  • ' : ''} + ${!course.enabled ? '
  • The course is currently disabled
  • ' : ''} +
  • The course may no longer be active
  • +
+ +

Consider archiving this course to keep your course list organized.

+ +

+ + Manage Course Settings + +

+
+ `; + + await this.mailService.sendEmail({ + receiverOrReceivers: professorCourse.user.email, + type: MailServiceType.WEEKLY_COURSE_SUMMARY, + subject: `HelpMe - Consider Archiving: ${course.name}`, + content: html, + }); + } + + private formatDate(date: Date): string { + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } +} From 851c3af533522f6d2415cea58205c3375a7c477c Mon Sep 17 00:00:00 2001 From: MithishR Date: Wed, 28 Jan 2026 18:09:34 -0800 Subject: [PATCH 02/13] changes --- packages/server/src/mail/mail.module.ts | 3 +- .../server/src/mail/weekly-summary.service.ts | 37 +++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/server/src/mail/mail.module.ts b/packages/server/src/mail/mail.module.ts index 1863a5b30..03efcc7be 100644 --- a/packages/server/src/mail/mail.module.ts +++ b/packages/server/src/mail/mail.module.ts @@ -6,12 +6,13 @@ import { MailServicesController } from './mail-services.controller'; import { UserModel } from 'profile/user.entity'; import { MailerService } from './mailer.service'; import { WeeklySummaryService } from './weekly-summary.service'; +import { WeeklySummaryCommand } from './weekly-summary.command'; @Global() @Module({ controllers: [MailController, MailServicesController], imports: [ConfigModule], - providers: [MailService, MailerService, WeeklySummaryService], + providers: [MailService, MailerService, WeeklySummaryService, WeeklySummaryCommand], exports: [MailService], }) export class MailModule {} diff --git a/packages/server/src/mail/weekly-summary.service.ts b/packages/server/src/mail/weekly-summary.service.ts index 18ba02bc7..9423b3f4c 100644 --- a/packages/server/src/mail/weekly-summary.service.ts +++ b/packages/server/src/mail/weekly-summary.service.ts @@ -61,24 +61,23 @@ export class WeeklySummaryService { // Process each professor-course pair for (const professorCourse of professorCourses) { try { - // Check if professor is subscribed to weekly summaries - const subscription = await UserSubscriptionModel.findOne({ - where: { - userId: professorCourse.user.id, - isSubscribed: true, - service: { - serviceType: MailServiceType.WEEKLY_COURSE_SUMMARY, - }, - }, - relations: ['service'], - }); - - if (!subscription) { - console.log( - `Professor ${professorCourse.user.email} unsubscribed from weekly summaries`, - ); - continue; - } + // const subscription = await UserSubscriptionModel.findOne({ + // where: { + // userId: professorCourse.user.id, + // isSubscribed: true, + // service: { + // serviceType: MailServiceType.WEEKLY_COURSE_SUMMARY, + // }, + // }, + // relations: ['service'], + // }); + + // if (!subscription) { + // console.log( + // `Professor ${professorCourse.user.email} unsubscribed from weekly summaries`, + // ); + // continue; + // } // Gather statistics const chatbotStats = await this.getChatbotStats( @@ -145,6 +144,7 @@ export class WeeklySummaryService { console.log( `Weekly summary job completed in ${duration}ms. Sent: ${emailsSent}, Failed: ${emailsFailed}`, ); + } catch (error) { console.error('Fatal error in weekly summary job:', error); Sentry.captureException(error); @@ -260,7 +260,6 @@ export class WeeklySummaryService { })); } -//Edge cases private async shouldSuggestArchiving(course: CourseModel): Promise { // Check if semester has ended if (course.semester?.endDate) { From 090406d9c015bf7a7e962458525a7990a07d26d4 Mon Sep 17 00:00:00 2001 From: MithishR Date: Wed, 28 Jan 2026 18:57:21 -0800 Subject: [PATCH 03/13] removing bug --- packages/server/src/mail/mail.module.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/server/src/mail/mail.module.ts b/packages/server/src/mail/mail.module.ts index 03efcc7be..1863a5b30 100644 --- a/packages/server/src/mail/mail.module.ts +++ b/packages/server/src/mail/mail.module.ts @@ -6,13 +6,12 @@ import { MailServicesController } from './mail-services.controller'; import { UserModel } from 'profile/user.entity'; import { MailerService } from './mailer.service'; import { WeeklySummaryService } from './weekly-summary.service'; -import { WeeklySummaryCommand } from './weekly-summary.command'; @Global() @Module({ controllers: [MailController, MailServicesController], imports: [ConfigModule], - providers: [MailService, MailerService, WeeklySummaryService, WeeklySummaryCommand], + providers: [MailService, MailerService, WeeklySummaryService], exports: [MailService], }) export class MailModule {} From 33505f96b0342962d84529555c1754ca1a506b3e Mon Sep 17 00:00:00 2001 From: MithishR Date: Sat, 31 Jan 2026 13:38:49 -0800 Subject: [PATCH 04/13] fix retrieval and html logic --- .../server/src/mail/weekly-summary.service.ts | 255 +++++++++++++++--- 1 file changed, 213 insertions(+), 42 deletions(-) diff --git a/packages/server/src/mail/weekly-summary.service.ts b/packages/server/src/mail/weekly-summary.service.ts index 9423b3f4c..61cb51a5a 100644 --- a/packages/server/src/mail/weekly-summary.service.ts +++ b/packages/server/src/mail/weekly-summary.service.ts @@ -55,15 +55,27 @@ export class WeeklySummaryService { `Found ${professorCourses.length} professor-course relationships`, ); + const professorMap = new Map(); + for (const pc of professorCourses) { + if (!professorMap.has(pc.user.id)) { + professorMap.set(pc.user.id, []); + } + professorMap.get(pc.user.id).push(pc); + } + + console.log(`Grouped into ${professorMap.size} unique professors`); + let emailsSent = 0; let emailsFailed = 0; - // Process each professor-course pair - for (const professorCourse of professorCourses) { + // Process each professor with all their courses + for (const [professorId, courses] of professorMap.entries()) { + const professor = courses[0].user; + try { // const subscription = await UserSubscriptionModel.findOne({ // where: { - // userId: professorCourse.user.id, + // userId: professorId, // isSubscribed: true, // service: { // serviceType: MailServiceType.WEEKLY_COURSE_SUMMARY, @@ -74,67 +86,80 @@ export class WeeklySummaryService { // if (!subscription) { // console.log( - // `Professor ${professorCourse.user.email} unsubscribed from weekly summaries`, + // `Professor ${professor.email} unsubscribed from weekly summaries`, // ); // continue; // } - // Gather statistics - const chatbotStats = await this.getChatbotStats( - professorCourse.courseId, - lastWeek, - ); - const asyncStats = await this.getAsyncQuestionStats( - professorCourse.courseId, - lastWeek, - ); - - // Check if there's any activity - const hasActivity = - chatbotStats.totalQuestions > 0 || asyncStats.total > 0; - - // If no activity, check if archiving - if (!hasActivity) { - const shouldSuggestArchive = await this.shouldSuggestArchiving( - professorCourse.course, + // Gather statistics for all courses + const courseStatsArray = []; + for (const professorCourse of courses) { + const chatbotStats = await this.getChatbotStats( + professorCourse.courseId, + lastWeek, + ); + const asyncStats = await this.getAsyncQuestionStats( + professorCourse.courseId, + lastWeek, ); - if (shouldSuggestArchive) { - // Send archive suggestion email - await this.sendArchiveSuggestionEmail(professorCourse); - emailsSent++; + const hasActivity = + chatbotStats.totalQuestions > 0 || asyncStats.total > 0; + + // If no activity, check if should suggest archiving + if (!hasActivity) { + const shouldSuggestArchive = await this.shouldSuggestArchiving( + professorCourse.course, + ); + courseStatsArray.push({ + course: professorCourse.course, + chatbotStats, + asyncStats, + suggestArchive: shouldSuggestArchive, + }); + } else { + courseStatsArray.push({ + course: professorCourse.course, + chatbotStats, + asyncStats, + suggestArchive: false, + }); } - continue; } - // Build and send the email - const emailHtml = this.buildWeeklySummaryEmail( - professorCourse.course, - chatbotStats, - asyncStats, + // Build consolidated email with all courses + const emailHtml = this.buildConsolidatedWeeklySummaryEmail( + courseStatsArray, + lastWeek, ); + const courseNames = courses.map((c) => c.course.name).join(', '); + const subject = + courses.length === 1 + ? `HelpMe Weekly Summary: ${courses[0].course.name} - Week of ${this.formatDate(lastWeek)}` + : `HelpMe Weekly Summary: ${courses.length} Courses - Week of ${this.formatDate(lastWeek)}`; + await this.mailService.sendEmail({ - receiverOrReceivers: professorCourse.user.email, + receiverOrReceivers: professor.email, type: MailServiceType.WEEKLY_COURSE_SUMMARY, - subject: `HelpMe Weekly Summary: ${professorCourse.course.name} - Week of ${this.formatDate(lastWeek)}`, + subject, content: emailHtml, }); emailsSent++; console.log( - `Sent weekly summary to ${professorCourse.user.email} for course ${professorCourse.course.name}`, + `Sent consolidated weekly summary to ${professor.email} for ${courses.length} course(s): ${courseNames}`, ); } catch (error) { emailsFailed++; console.error( - `Failed to send weekly summary for course ${professorCourse.courseId} to ${professorCourse.user.email}:`, + `Failed to send weekly summary to ${professor.email}:`, error, ); Sentry.captureException(error, { extra: { - courseId: professorCourse.courseId, - userId: professorCourse.user.id, + professorId, + courseCount: courses.length, }, }); } @@ -290,6 +315,152 @@ export class WeeklySummaryService { return recentInteractions === 0 && recentAsyncQuestions === 0; } + private buildConsolidatedWeeklySummaryEmail( + courseStatsArray: Array<{ + course: CourseModel; + chatbotStats: ChatbotStats; + asyncStats: AsyncQuestionStats; + suggestArchive: boolean; + }>, + weekStartDate: Date, + ): string { + const weekEndDate = new Date(); + + let html = ` +
+

+ HelpMe Weekly Summary +

+

+ Week of ${this.formatDate(weekStartDate)} - ${this.formatDate(weekEndDate)} +

+

+ Summary for ${courseStatsArray.length} course${courseStatsArray.length !== 1 ? 's' : ''} +

+ `; + + // Process each course + for (const courseData of courseStatsArray) { + const { course, chatbotStats, asyncStats, suggestArchive } = courseData; + + html += ` +
+

${course.name}

+ `; + + if (suggestArchive) { + html += ` +
+

Consider Archiving This Course

+

+ No activity in the past 4 weeks. You may want to archive this course if the semester has ended. +

+
+ `; + continue; // Skip stats for archived courses + } + + const hasActivity = chatbotStats.totalQuestions > 0 || asyncStats.total > 0; + + if (!hasActivity) { + html += ` +

No activity this week.

+ `; + } else { + // Chatbot Activity Section + if (chatbotStats.totalQuestions > 0) { + html += ` +

Chatbot Activity

+
    +
  • ${chatbotStats.totalQuestions} questions asked by ${chatbotStats.uniqueStudents} unique student${chatbotStats.uniqueStudents !== 1 ? 's' : ''}
  • +
  • Average: ${chatbotStats.avgQuestionsPerStudent.toFixed(1)} questions per student
  • +
  • Most active day: ${chatbotStats.mostActiveDay}
  • +
+ +

Daily Breakdown:

+ + `; + + chatbotStats.byDayOfWeek.forEach((dayData) => { + if (dayData.count > 0) { + const barWidth = Math.max( + (dayData.count / chatbotStats.totalQuestions) * 100, + 5, + ); + html += ` + + + + + `; + } + }); + + html += ` +
${dayData.day}: +
+ ${dayData.count} +
+ `; + } + + // Async Questions Section + if (asyncStats.total > 0) { + html += ` +

Async Questions

+
    +
  • ${asyncStats.total} total questions
  • +
  • ${asyncStats.aiResolved} resolved by AI
  • +
  • ${asyncStats.humanAnswered} answered by staff
  • +
  • ${asyncStats.stillNeedHelp} still need help
  • +
  • ${asyncStats.withNewComments} with new comments this week
  • + `; + + if (asyncStats.avgResponseTime !== null) { + html += ` +
  • Average response time: ${asyncStats.avgResponseTime.toFixed(1)} hours
  • + `; + } + + html += ` +
+ `; + + if (asyncStats.stillNeedHelp > 0) { + html += ` +
+

+ ${asyncStats.stillNeedHelp} question${asyncStats.stillNeedHelp !== 1 ? 's' : ''} still need${asyncStats.stillNeedHelp === 1 ? 's' : ''} attention +

+
+ `; + } + } else if (chatbotStats.totalQuestions > 0) { + html += ` +

Async Questions

+

No async questions this week.

+ `; + } + } + + html += ` +
+ `; + } + + // Footer + html += ` +
+

+ Weekly summary from HelpMe.
+ Manage your email preferences in settings. +

+
+ `; + + return html; + } + private buildWeeklySummaryEmail( course: CourseModel, chatbotStats: ChatbotStats, @@ -364,7 +535,7 @@ export class WeeklySummaryService { } if (asyncStats.avgResponseTime !== null) { - html += `
  • ⏱Average response time: ${asyncStats.avgResponseTime.toFixed(1)} hours
  • `; + html += `
  • Average response time: ${asyncStats.avgResponseTime.toFixed(1)} hours
  • `; } html += ` @@ -405,10 +576,10 @@ export class WeeklySummaryService { const html = `
    -

    📋 Course Activity Update: ${course.name}

    +

    Course Activity Update: ${course.name}

    -

    We noticed that ${course.name} hasn't had any activity in the last 4 weeks.

    +

    ${course.name} has had no activity in the last 4 weeks.

    Possible reasons:

    @@ -418,7 +589,7 @@ export class WeeklySummaryService {
  • The course may no longer be active
  • -

    Consider archiving this course to keep your course list organized.

    +

    Consider archiving this course to keep your course list organized.

    Date: Sun, 1 Feb 2026 15:23:05 -0800 Subject: [PATCH 05/13] Fix bug related to queue and async --- .../server/src/mail/weekly-summary.service.ts | 119 +++++++++++++++++- 1 file changed, 117 insertions(+), 2 deletions(-) diff --git a/packages/server/src/mail/weekly-summary.service.ts b/packages/server/src/mail/weekly-summary.service.ts index 61cb51a5a..db646003c 100644 --- a/packages/server/src/mail/weekly-summary.service.ts +++ b/packages/server/src/mail/weekly-summary.service.ts @@ -4,6 +4,7 @@ import { MailService } from './mail.service'; import { UserCourseModel } from '../profile/user-course.entity'; import { InteractionModel } from '../chatbot/interaction.entity'; import { AsyncQuestionModel } from '../asyncQuestion/asyncQuestion.entity'; +import { QuestionModel } from '../question/question.entity'; import { CourseModel } from '../course/course.entity'; import { MailServiceType, Role } from '@koh/common'; import { MoreThanOrEqual } from 'typeorm'; @@ -27,6 +28,13 @@ interface AsyncQuestionStats { avgResponseTime: number | null; } +interface QueueStats { + totalQuestions: number; + uniqueStudents: number; + avgWaitTime: number | null; + avgHelpTime: number | null; +} + @Injectable() export class WeeklySummaryService { constructor(private mailService: MailService) {} @@ -102,9 +110,42 @@ export class WeeklySummaryService { professorCourse.courseId, lastWeek, ); + // Wrap async stats in try-catch to handle data issues + let asyncStats: AsyncQuestionStats; + try { + asyncStats = await this.getAsyncQuestionStats( + professorCourse.courseId, + lastWeek, + } catch (error) { + console.error(`Failed to get async stats for course ${professorCourse.courseId}:`, error.message); + asyncStats = { + total: 0, + aiResolved: 0, + humanAnswered: 0, + stillNeedHelp: 0, + withNewComments: 0, + avgResponseTime: 0, + }; + } + + let queueStats: QueueStats; + try { + queueStats = await this.getQueueStats( + professorCourse.courseId, + lastWeek, + ); + } catch (error) { + console.error(`Failed to get queue stats for course ${professorCourse.courseId}:`, error.message); + queueStats = { + totalQuestions: 0, + uniqueStudents: 0, + avgWaitTime: null, + avgHelpTime: null, + }; + } const hasActivity = - chatbotStats.totalQuestions > 0 || asyncStats.total > 0; + chatbotStats.totalQuestions > 0 || asyncStats.total > 0 || queueStats.totalQuestions > 0; // If no activity, check if should suggest archiving if (!hasActivity) { @@ -115,6 +156,7 @@ export class WeeklySummaryService { course: professorCourse.course, chatbotStats, asyncStats, + queueStats, suggestArchive: shouldSuggestArchive, }); } else { @@ -122,6 +164,7 @@ export class WeeklySummaryService { course: professorCourse.course, chatbotStats, asyncStats, + queueStats, suggestArchive: false, }); } @@ -245,7 +288,14 @@ export class WeeklySummaryService { (sum, q) => sum + (q.closedAt.getTime() - new Date(q.createdAt).getTime()) / - (1000 * 60 * 60), + (sum, q) => { + if (!q.createdAt) { + console.warn(`Question ${q.id} has no createdAt timestamp`); + return sum; + } + const closedTime = q.closedAt ? q.closedAt.getTime() : Date.now(); + const createdTime = new Date(q.createdAt).getTime(); + return sum + (closedTime - createdTime) / (1000 * 60 * 60); 0, ) / answeredQuestions.length : null; @@ -260,6 +310,38 @@ export class WeeklySummaryService { }; } + private async getQueueStats( + courseId: number, + since: Date, + ): Promise { + const questions = await QuestionModel.createQueryBuilder('q') + .innerJoin('q.queue', 'queue') + .innerJoin('q.creator', 'creator') + .where('queue.courseId = :courseId', { courseId }) + .andWhere('q.createdAt >= :since', { since }) + .getMany(); + + const totalQuestions = questions.length; + const uniqueStudents = new Set(questions.map(q => q.creatorId)).size; + + const questionsWithWait = questions.filter(q => q.waitTime > 0); + const avgWaitTime = questionsWithWait.length > 0 + ? questionsWithWait.reduce((sum, q) => sum + q.waitTime, 0) / questionsWithWait.length / 60 + : null; + + const questionsWithHelp = questions.filter(q => q.helpTime > 0); + const avgHelpTime = questionsWithHelp.length > 0 + ? questionsWithHelp.reduce((sum, q) => sum + q.helpTime, 0) / questionsWithHelp.length / 60 + : null; + + return { + totalQuestions, + uniqueStudents, + avgWaitTime, + avgHelpTime, + }; + } + private groupByDayOfWeek( interactions: InteractionModel[], ): { day: string; count: number }[] { @@ -313,6 +395,13 @@ export class WeeklySummaryService { }); return recentInteractions === 0 && recentAsyncQuestions === 0; + const recentQueueQuestions = await QuestionModel.createQueryBuilder('q') + .innerJoin('q.queue', 'queue') + .where('queue.courseId = :courseId', { courseId: course.id }) + .andWhere('q.createdAt >= :since', { since: fourWeeksAgo }) + .getCount(); + + return recentInteractions === 0 && recentAsyncQuestions === 0 && recentQueueQuestions === 0; } private buildConsolidatedWeeklySummaryEmail( @@ -320,6 +409,7 @@ export class WeeklySummaryService { course: CourseModel; chatbotStats: ChatbotStats; asyncStats: AsyncQuestionStats; + queueStats: QueueStats; suggestArchive: boolean; }>, weekStartDate: Date, @@ -342,6 +432,7 @@ export class WeeklySummaryService { // Process each course for (const courseData of courseStatsArray) { const { course, chatbotStats, asyncStats, suggestArchive } = courseData; + const { course, chatbotStats, asyncStats, queueStats, suggestArchive } = courseData; html += `

    @@ -361,6 +452,7 @@ export class WeeklySummaryService { } const hasActivity = chatbotStats.totalQuestions > 0 || asyncStats.total > 0; + const hasActivity = chatbotStats.totalQuestions > 0 || asyncStats.total > 0 || queueStats.totalQuestions > 0; if (!hasActivity) { html += ` @@ -441,6 +533,29 @@ export class WeeklySummaryService {

    No async questions this week.

    `; } + if (queueStats.totalQuestions > 0) { + html += ` +

    Office Hours Queue

    +
      +
    • ${queueStats.totalQuestions} questions from ${queueStats.uniqueStudents} unique student${queueStats.uniqueStudents !== 1 ? 's' : ''}
    • + `; + + if (queueStats.avgWaitTime !== null) { + html += ` +
    • Average wait time: ${queueStats.avgWaitTime.toFixed(1)} minutes
    • + `; + } + + if (queueStats.avgHelpTime !== null) { + html += ` +
    • Average help time: ${queueStats.avgHelpTime.toFixed(1)} minutes
    • + `; + } + + html += ` +
    + `; + } } html += ` From 6c7d30ab100bb8c7a21c367e95f5dd35251381f5 Mon Sep 17 00:00:00 2001 From: MithishR Date: Sun, 1 Feb 2026 15:49:39 -0800 Subject: [PATCH 06/13] remove redundant+async fixes --- .../src/asyncQuestion/asyncQuestion.entity.ts | 3 +- .../server/src/mail/weekly-summary.service.ts | 202 +++--------------- 2 files changed, 37 insertions(+), 168 deletions(-) diff --git a/packages/server/src/asyncQuestion/asyncQuestion.entity.ts b/packages/server/src/asyncQuestion/asyncQuestion.entity.ts index ac0636d4a..30c4f5c6a 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.entity.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.entity.ts @@ -106,6 +106,7 @@ export class AsyncQuestionModel extends BaseEntity { @AfterLoad() sumVotes() { - this.votesSum = this.votes.reduce((acc, vote) => acc + vote.vote, 0); + // Prevent crash when question upvote/downvote data isn't loaded with the query + this.votesSum = this.votes?.reduce((acc, vote) => acc + vote.vote, 0) ?? 0; } } diff --git a/packages/server/src/mail/weekly-summary.service.ts b/packages/server/src/mail/weekly-summary.service.ts index db646003c..64b4194bf 100644 --- a/packages/server/src/mail/weekly-summary.service.ts +++ b/packages/server/src/mail/weekly-summary.service.ts @@ -59,10 +59,14 @@ export class WeeklySummaryService { .andWhere('course.enabled = :enabled', { enabled: true }) .getMany(); + + // TO REMOVE console.log( `Found ${professorCourses.length} professor-course relationships`, ); + console.log('Courses found:', professorCourses.map(pc => `${pc.course.name} (ID: ${pc.courseId}, enabled: ${pc.course.enabled})`).join(', ')); + // Group courses by professor const professorMap = new Map(); for (const pc of professorCourses) { if (!professorMap.has(pc.user.id)) { @@ -71,7 +75,12 @@ export class WeeklySummaryService { professorMap.get(pc.user.id).push(pc); } - console.log(`Grouped into ${professorMap.size} unique professors`); + + console.log(`Grouped into ${professorMap.size} unique professors`); // TO REMOVE + for (const [profId, courses] of professorMap.entries()) { + const prof = courses[0].user; + console.log(` Professor ${prof.email}: ${courses.map(c => c.course.name).join(', ')}`); // TO REMOVE + } let emailsSent = 0; let emailsFailed = 0; @@ -102,22 +111,25 @@ export class WeeklySummaryService { // Gather statistics for all courses const courseStatsArray = []; for (const professorCourse of courses) { + console.log(`Processing course: ${professorCourse.course.name} (ID: ${professorCourse.courseId})`); // TO REMOVE + const chatbotStats = await this.getChatbotStats( professorCourse.courseId, lastWeek, ); - const asyncStats = await this.getAsyncQuestionStats( - professorCourse.courseId, - lastWeek, - ); + // Wrap async stats in try-catch to handle data issues let asyncStats: AsyncQuestionStats; try { asyncStats = await this.getAsyncQuestionStats( professorCourse.courseId, lastWeek, + ); } catch (error) { - console.error(`Failed to get async stats for course ${professorCourse.courseId}:`, error.message); + //TODO: Remove logging after debugging + console.error(`Failed to get async stats for course ${professorCourse.courseId}:`, error.message); + console.error('Stack trace:', error.stack); + // Return empty stats if there's an error asyncStats = { total: 0, aiResolved: 0, @@ -146,6 +158,12 @@ export class WeeklySummaryService { const hasActivity = chatbotStats.totalQuestions > 0 || asyncStats.total > 0 || queueStats.totalQuestions > 0; + + // REMOVE + console.log(` Chatbot: ${chatbotStats.totalQuestions} questions, ${chatbotStats.uniqueStudents} students`); + console.log(` Async: ${asyncStats.total} questions`); + console.log(` Queue: ${queueStats.totalQuestions} questions, ${queueStats.uniqueStudents} students`); + console.log(` Has activity: ${hasActivity}`); // If no activity, check if should suggest archiving if (!hasActivity) { @@ -262,11 +280,11 @@ export class WeeklySummaryService { courseId: number, since: Date, ): Promise { - const questions = await AsyncQuestionModel.createQueryBuilder('aq') + const questions = (await AsyncQuestionModel.createQueryBuilder('aq') .leftJoinAndSelect('aq.comments', 'comments') .where('aq.courseId = :courseId', { courseId }) .andWhere('aq.createdAt >= :since', { since }) - .getMany(); + .getMany()) || []; const total = questions.length; const aiResolved = questions.filter( @@ -281,21 +299,15 @@ export class WeeklySummaryService { ).length; // Calculate average response time for answered questions - const answeredQuestions = questions.filter((q) => q.closedAt); + const answeredQuestions = questions.filter((q) => q.closedAt && q.createdAt) || []; const avgResponseTime = answeredQuestions.length > 0 ? answeredQuestions.reduce( - (sum, q) => - sum + - (q.closedAt.getTime() - new Date(q.createdAt).getTime()) / (sum, q) => { - if (!q.createdAt) { - console.warn(`Question ${q.id} has no createdAt timestamp`); - return sum; - } - const closedTime = q.closedAt ? q.closedAt.getTime() : Date.now(); + const closedTime = q.closedAt.getTime(); const createdTime = new Date(q.createdAt).getTime(); return sum + (closedTime - createdTime) / (1000 * 60 * 60); + }, 0, ) / answeredQuestions.length : null; @@ -372,6 +384,7 @@ export class WeeklySummaryService { if (course.semester?.endDate) { const semesterEndDate = new Date(course.semester.endDate); if (semesterEndDate < new Date()) { + console.log(` ${course.name}: Semester ended on ${semesterEndDate}`); return true; } } @@ -394,13 +407,15 @@ export class WeeklySummaryService { }, }); - return recentInteractions === 0 && recentAsyncQuestions === 0; + // Check for recent queue questions const recentQueueQuestions = await QuestionModel.createQueryBuilder('q') .innerJoin('q.queue', 'queue') .where('queue.courseId = :courseId', { courseId: course.id }) .andWhere('q.createdAt >= :since', { since: fourWeeksAgo }) .getCount(); + // console.log(` ${course.name} activity check (last 4 weeks): Chatbot=${recentInteractions}, Async=${recentAsyncQuestions}, Queue=${recentQueueQuestions}`); + return recentInteractions === 0 && recentAsyncQuestions === 0 && recentQueueQuestions === 0; } @@ -431,7 +446,6 @@ export class WeeklySummaryService { // Process each course for (const courseData of courseStatsArray) { - const { course, chatbotStats, asyncStats, suggestArchive } = courseData; const { course, chatbotStats, asyncStats, queueStats, suggestArchive } = courseData; html += ` @@ -451,7 +465,6 @@ export class WeeklySummaryService { continue; // Skip stats for archived courses } - const hasActivity = chatbotStats.totalQuestions > 0 || asyncStats.total > 0; const hasActivity = chatbotStats.totalQuestions > 0 || asyncStats.total > 0 || queueStats.totalQuestions > 0; if (!hasActivity) { @@ -533,6 +546,8 @@ export class WeeklySummaryService {

    No async questions this week.

    `; } + + // Queue Questions Section if (queueStats.totalQuestions > 0) { html += `

    Office Hours Queue

    @@ -576,153 +591,6 @@ export class WeeklySummaryService { return html; } - private buildWeeklySummaryEmail( - course: CourseModel, - chatbotStats: ChatbotStats, - asyncStats: AsyncQuestionStats, - ): string { - const lastWeek = new Date(); - lastWeek.setDate(lastWeek.getDate() - 7); - - let html = ` -
    -

    Weekly Summary: ${course.name}

    -

    Week of ${this.formatDate(lastWeek)} - ${this.formatDate(new Date())}

    -
    - `; - - // Chatbot Activity Section - if (chatbotStats.totalQuestions > 0) { - html += ` -

    Chatbot Activity

    -
      -
    • ${chatbotStats.totalQuestions} questions asked by ${chatbotStats.uniqueStudents} unique student${chatbotStats.uniqueStudents !== 1 ? 's' : ''}
    • -
    • Average: ${chatbotStats.avgQuestionsPerStudent.toFixed(1)} questions per student
    • -
    • Most active day: ${chatbotStats.mostActiveDay}
    • -
    - -

    Daily Breakdown:

    - - `; - - chatbotStats.byDayOfWeek.forEach((dayData) => { - if (dayData.count > 0) { - const barWidth = Math.max( - (dayData.count / chatbotStats.totalQuestions) * 100, - 5, - ); - html += ` - - - - - `; - } - }); - - html += ` -
    ${dayData.day}: -
    - ${dayData.count} -
    - `; - } else { - html += ` -

    Chatbot Activity

    -

    No chatbot questions this week.

    - `; - } - - // Async Questions Section - if (asyncStats.total > 0) { - html += ` -
    -

    Anytime Questions

    -
      -
    • ${asyncStats.total} new question${asyncStats.total !== 1 ? 's' : ''} posted
    • -
    • ${asyncStats.aiResolved} resolved via AI
    • -
    • ${asyncStats.humanAnswered} answered by staff
    • -
    • ${asyncStats.stillNeedHelp} still need help
    • - `; - - if (asyncStats.withNewComments > 0) { - html += `
    • ${asyncStats.withNewComments} question${asyncStats.withNewComments !== 1 ? 's' : ''} received new comments
    • `; - } - - if (asyncStats.avgResponseTime !== null) { - html += `
    • Average response time: ${asyncStats.avgResponseTime.toFixed(1)} hours
    • `; - } - - html += ` -
    - `; - - if (asyncStats.stillNeedHelp > 0) { - html += ` -
    - Action Needed: ${asyncStats.stillNeedHelp} question${asyncStats.stillNeedHelp !== 1 ? 's' : ''} still need${asyncStats.stillNeedHelp === 1 ? 's' : ''} your attention. -
    - `; - } - } else { - html += ` -
    -

    Anytime Questions

    -

    No async questions this week.

    - `; - } - - html += ` -
    -

    - - View Course Dashboard - -

    -
    - `; - - return html; - } - - private async sendArchiveSuggestionEmail(professorCourse: any): Promise { - const course = professorCourse.course; - - const html = ` -
    -

    Course Activity Update: ${course.name}

    - -
    -

    ${course.name} has had no activity in the last 4 weeks.

    -
    - -

    Possible reasons:

    -
      - ${course.semester?.endDate && new Date(course.semester.endDate) < new Date() ? '
    • The semester has ended
    • ' : ''} - ${!course.enabled ? '
    • The course is currently disabled
    • ' : ''} -
    • The course may no longer be active
    • -
    - -

    Consider archiving this course to keep your course list organized.

    - -

    - - Manage Course Settings - -

    -
    - `; - - await this.mailService.sendEmail({ - receiverOrReceivers: professorCourse.user.email, - type: MailServiceType.WEEKLY_COURSE_SUMMARY, - subject: `HelpMe - Consider Archiving: ${course.name}`, - content: html, - }); - } - private formatDate(date: Date): string { return date.toLocaleDateString('en-US', { month: 'short', From 51e435fb0b37170cf4eead646829867c6c5490fc Mon Sep 17 00:00:00 2001 From: MithishR Date: Sun, 8 Feb 2026 12:04:50 -0800 Subject: [PATCH 07/13] add check for new students --- .../server/src/mail/weekly-summary.service.ts | 74 ++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/server/src/mail/weekly-summary.service.ts b/packages/server/src/mail/weekly-summary.service.ts index 64b4194bf..628b4526e 100644 --- a/packages/server/src/mail/weekly-summary.service.ts +++ b/packages/server/src/mail/weekly-summary.service.ts @@ -35,6 +35,14 @@ interface QueueStats { avgHelpTime: number | null; } +interface NewStudentData { + id: number; + firstName: string; + lastName: string; + email: string; + joinedAt: Date; +} + @Injectable() export class WeeklySummaryService { constructor(private mailService: MailService) {} @@ -112,12 +120,18 @@ export class WeeklySummaryService { const courseStatsArray = []; for (const professorCourse of courses) { console.log(`Processing course: ${professorCourse.course.name} (ID: ${professorCourse.courseId})`); // TO REMOVE + console.log(`Looking for students who joined after: ${lastWeek.toISOString()}`); // DEBUG const chatbotStats = await this.getChatbotStats( professorCourse.courseId, lastWeek, ); + const newStudents = await this.getNewStudents( + professorCourse.courseId, + lastWeek, + ); + // Wrap async stats in try-catch to handle data issues let asyncStats: AsyncQuestionStats; try { @@ -175,6 +189,7 @@ export class WeeklySummaryService { chatbotStats, asyncStats, queueStats, + newStudents, suggestArchive: shouldSuggestArchive, }); } else { @@ -183,6 +198,7 @@ export class WeeklySummaryService { chatbotStats, asyncStats, queueStats, + newStudents, suggestArchive: false, }); } @@ -419,12 +435,40 @@ export class WeeklySummaryService { return recentInteractions === 0 && recentAsyncQuestions === 0 && recentQueueQuestions === 0; } + private async getNewStudents( + courseId: number, + since: Date, + ): Promise { + const newStudentRecords = await UserCourseModel.createQueryBuilder('uc') + .innerJoinAndSelect('uc.user', 'user') + .where('uc.courseId = :courseId', { courseId }) + .andWhere('uc.role = :role', { role: Role.STUDENT }) + .andWhere('uc.createdAt >= :since', { since }) + .orderBy('user.lastName', 'ASC') + .addOrderBy('user.firstName', 'ASC') + .getMany(); + + console.log(`New students for course ${courseId} since ${since}:`, newStudentRecords.length); // DEBUG + newStudentRecords.forEach(uc => { + console.log(` - ${uc.user.firstName} ${uc.user.lastName} (${uc.user.email}) joined at ${uc.createdAt}`); // DEBUG + }); + + return newStudentRecords.map((uc) => ({ + id: uc.user.id, + firstName: uc.user.firstName, + lastName: uc.user.lastName, + email: uc.user.email, + joinedAt: uc.createdAt, + })); + } + private buildConsolidatedWeeklySummaryEmail( courseStatsArray: Array<{ course: CourseModel; chatbotStats: ChatbotStats; asyncStats: AsyncQuestionStats; queueStats: QueueStats; + newStudents: NewStudentData[]; suggestArchive: boolean; }>, weekStartDate: Date, @@ -446,13 +490,38 @@ export class WeeklySummaryService { // Process each course for (const courseData of courseStatsArray) { - const { course, chatbotStats, asyncStats, queueStats, suggestArchive } = courseData; + const { course, chatbotStats, asyncStats, queueStats, newStudents, suggestArchive } = courseData; html += `

    ${course.name}

    `; + if (newStudents.length > 0) { + html += ` +
    +

    New Students This Week

    +

    + ${newStudents.length} new student${newStudents.length !== 1 ? 's' : ''} joined this course: +

    +
      + `; + + newStudents.forEach((student) => { + html += ` +
    • ${student.firstName} ${student.lastName} (${student.email})
    • + `; + }); + + html += ` +
    +

    + If any of these students should not be in the course, please remove them from the course under Course Roster and either disable or change the course invite link under Course Settings. +

    +
    + `; + } + if (suggestArchive) { html += `
    @@ -462,6 +531,9 @@ export class WeeklySummaryService {

    `; + html += ` +
    + `; continue; // Skip stats for archived courses } From 19c238c32bae97a7aab2b1fe8dab6afae1337af0 Mon Sep 17 00:00:00 2001 From: MithishR Date: Sun, 8 Feb 2026 13:42:03 -0800 Subject: [PATCH 08/13] active students and staff --- .../server/src/mail/weekly-summary.service.ts | 180 +++++++++++++++++- 1 file changed, 179 insertions(+), 1 deletion(-) diff --git a/packages/server/src/mail/weekly-summary.service.ts b/packages/server/src/mail/weekly-summary.service.ts index 628b4526e..6f96c3806 100644 --- a/packages/server/src/mail/weekly-summary.service.ts +++ b/packages/server/src/mail/weekly-summary.service.ts @@ -10,6 +10,7 @@ import { MailServiceType, Role } from '@koh/common'; import { MoreThanOrEqual } from 'typeorm'; import * as Sentry from '@sentry/nestjs'; import { UserSubscriptionModel } from './user-subscriptions.entity'; +import { UserModel } from '../profile/user.entity'; interface ChatbotStats { totalQuestions: number; @@ -43,6 +44,21 @@ interface NewStudentData { joinedAt: Date; } +interface TopStudentData { + id: number; + name: string; + email: string; + questionsAsked: number; +} + +interface StaffPerformanceData { + id: number; + name: string; + questionsHelped: number; + asyncQuestionsHelped: number; + avgHelpTime: number | null; // in minutes +} + @Injectable() export class WeeklySummaryService { constructor(private mailService: MailService) {} @@ -132,6 +148,16 @@ export class WeeklySummaryService { lastWeek, ); + const topStudents = await this.getTopActiveStudents( + professorCourse.courseId, + lastWeek, + ); + + const staffPerformance = await this.getStaffPerformance( + professorCourse.courseId, + lastWeek, + ); + // Wrap async stats in try-catch to handle data issues let asyncStats: AsyncQuestionStats; try { @@ -190,6 +216,8 @@ export class WeeklySummaryService { asyncStats, queueStats, newStudents, + topStudents, + staffPerformance, suggestArchive: shouldSuggestArchive, }); } else { @@ -199,6 +227,8 @@ export class WeeklySummaryService { asyncStats, queueStats, newStudents, + topStudents, + staffPerformance, suggestArchive: false, }); } @@ -462,6 +492,96 @@ export class WeeklySummaryService { })); } + + private async getStaffPerformance( + courseId: number, + since: Date, + ): Promise { + // Get queue questions helped by each staff member + const queueHelped = await QuestionModel.createQueryBuilder('q') + .select('q.taHelpedId', 'staffId') + .addSelect('COUNT(q.id)', 'count') + .addSelect('AVG(q.helpTime)', 'avgHelpTime') + .innerJoin('q.queue', 'queue') + .where('queue.courseId = :courseId', { courseId }) + .andWhere('q.taHelpedId IS NOT NULL') + .andWhere('q.createdAt >= :since', { since }) + .andWhere('q.status = :status', { status: 'Resolved' }) + .groupBy('q.taHelpedId') + .getRawMany(); + + // Get async questions helped by each staff member + const asyncHelped = await AsyncQuestionModel.createQueryBuilder('aq') + .select('aq.taHelpedId', 'staffId') + .addSelect('COUNT(aq.id)', 'count') + .where('aq.courseId = :courseId', { courseId }) + .andWhere('aq.taHelpedId IS NOT NULL') + .andWhere('aq.createdAt >= :since', { since }) + .andWhere('aq.status = :status', { status: 'HumanAnswered' }) + .groupBy('aq.taHelpedId') + .getRawMany(); + + const staffIds = [...new Set([ + ...queueHelped.map(q => q.staffId), + ...asyncHelped.map(a => a.staffId), + ])]; + + if (staffIds.length === 0) { + return []; + } + + const staffNames = await UserModel.createQueryBuilder('u') + .select('u.id', 'id') + .addSelect("u.firstName || ' ' || u.lastName", 'name') + .where('u.id IN (:...ids)', { ids: staffIds }) + .getRawMany(); + + return staffIds.map(staffId => { + const queueData = queueHelped.find(q => q.staffId === staffId); + const asyncData = asyncHelped.find(a => a.staffId === staffId); + const staff = staffNames.find(s => s.id === staffId); + + return { + id: staffId, + name: staff?.name || `ID ${staffId}`, + questionsHelped: parseInt(queueData?.count || '0'), + asyncQuestionsHelped: parseInt(asyncData?.count || '0'), + avgHelpTime: queueData?.avgHelpTime ? parseFloat(queueData.avgHelpTime) / 60 : null, // Convert seconds to minutes + }; + }).sort((a, b) => (b.questionsHelped + b.asyncQuestionsHelped) - (a.questionsHelped + a.asyncQuestionsHelped)); + } + + private async getTopActiveStudents( + courseId: number, + since: Date, + ): Promise { + // Get top 5 students by queue questions asked + const results = await QuestionModel.createQueryBuilder('q') + .select('q.creatorId', 'id') + .addSelect("u.firstName || ' ' || u.lastName", 'name') + .addSelect('u.email', 'email') + .addSelect('COUNT(*)', 'questionsAsked') + .innerJoin('q.queue', 'queue') + .innerJoin('q.creator', 'u') + .where('queue.courseId = :courseId', { courseId }) + .andWhere('q.createdAt >= :since', { since }) + .groupBy('q.creatorId') + .addGroupBy('u.firstName') + .addGroupBy('u.lastName') + .addGroupBy('u.email') + .orderBy('COUNT(*)', 'DESC') + .limit(5) + .getRawMany(); + + return results.map((r) => ({ + id: r.id, + name: r.name, + email: r.email, + questionsAsked: parseInt(r.questionsAsked), + })); + } + + private buildConsolidatedWeeklySummaryEmail( courseStatsArray: Array<{ course: CourseModel; @@ -469,6 +589,8 @@ export class WeeklySummaryService { asyncStats: AsyncQuestionStats; queueStats: QueueStats; newStudents: NewStudentData[]; + topStudents: TopStudentData[]; + staffPerformance: StaffPerformanceData[]; suggestArchive: boolean; }>, weekStartDate: Date, @@ -490,13 +612,16 @@ export class WeeklySummaryService { // Process each course for (const courseData of courseStatsArray) { - const { course, chatbotStats, asyncStats, queueStats, newStudents, suggestArchive } = courseData; + const { course, chatbotStats, asyncStats, queueStats, newStudents, topStudents, staffPerformance, suggestArchive } = courseData; + + console.log(`Building email for ${course.name}: ${newStudents.length} new students`); // DEBUG html += `

    ${course.name}

    `; + // New Students Section (show for all courses, even inactive ones) if (newStudents.length > 0) { html += `
    @@ -645,6 +770,59 @@ export class WeeklySummaryService { } } + // Top Active Students Section + if (topStudents.length > 0) { + html += ` +

    ⭐ Most Active Students

    +

    Top students by questions asked this week:

    +
      + `; + + topStudents.forEach((student) => { + html += ` +
    1. ${student.name} - ${student.questionsAsked} question${student.questionsAsked !== 1 ? 's' : ''}
    2. + `; + }); + + html += ` +
    + `; + } + + // Staff Performance Section + if (staffPerformance.length > 0) { + html += ` +

    👥 Staff Performance

    + + + + + + + + + + + `; + + staffPerformance.forEach((staff) => { + const totalHelped = staff.questionsHelped + staff.asyncQuestionsHelped; + html += ` + + + + + + + `; + }); + + html += ` + +
    Staff MemberQueue QuestionsAsync QuestionsAvg Help Time
    ${staff.name}${staff.questionsHelped}${staff.asyncQuestionsHelped}${staff.avgHelpTime !== null ? staff.avgHelpTime.toFixed(1) + ' min' : 'N/A'}
    + `; + } + html += `
    `; From 3b6435bbb2415253c35d8038d4ccd926b36eaa77 Mon Sep 17 00:00:00 2001 From: MithishR Date: Sat, 14 Feb 2026 13:26:12 -0800 Subject: [PATCH 09/13] add peak hours and recommendations and remove debugs --- .../server/src/mail/weekly-summary.service.ts | 294 ++++++++++++++++-- 1 file changed, 273 insertions(+), 21 deletions(-) diff --git a/packages/server/src/mail/weekly-summary.service.ts b/packages/server/src/mail/weekly-summary.service.ts index 6f96c3806..f153f1ec5 100644 --- a/packages/server/src/mail/weekly-summary.service.ts +++ b/packages/server/src/mail/weekly-summary.service.ts @@ -59,12 +59,27 @@ interface StaffPerformanceData { avgHelpTime: number | null; // in minutes } +interface MostActiveDaysData { + byDayOfWeek: { day: string; count: number }[]; + mostActiveDay: string; +} + +interface PeakHoursData { + peakHours: string[]; + quietHours: string[]; +} + +interface RecommendationData { + type: 'warning' | 'info' | 'success'; + message: string; +} + @Injectable() export class WeeklySummaryService { constructor(private mailService: MailService) {} // Run every week - @Cron(CronExpression.EVERY_WEEK) + @Cron(CronExpression.EVERY_MINUTE) async sendWeeklySummaries() { console.log('Starting weekly summary email job...'); const startTime = Date.now(); @@ -158,7 +173,16 @@ export class WeeklySummaryService { lastWeek, ); - // Wrap async stats in try-catch to handle data issues + const mostActiveDays = await this.getMostActiveDays( + professorCourse.courseId, + lastWeek, + ); + + const peakHours = await this.getPeakHours( + professorCourse.courseId, + lastWeek, + ); + let asyncStats: AsyncQuestionStats; try { asyncStats = await this.getAsyncQuestionStats( @@ -166,9 +190,6 @@ export class WeeklySummaryService { lastWeek, ); } catch (error) { - //TODO: Remove logging after debugging - console.error(`Failed to get async stats for course ${professorCourse.courseId}:`, error.message); - console.error('Stack trace:', error.stack); // Return empty stats if there's an error asyncStats = { total: 0, @@ -198,13 +219,6 @@ export class WeeklySummaryService { const hasActivity = chatbotStats.totalQuestions > 0 || asyncStats.total > 0 || queueStats.totalQuestions > 0; - - // REMOVE - console.log(` Chatbot: ${chatbotStats.totalQuestions} questions, ${chatbotStats.uniqueStudents} students`); - console.log(` Async: ${asyncStats.total} questions`); - console.log(` Queue: ${queueStats.totalQuestions} questions, ${queueStats.uniqueStudents} students`); - console.log(` Has activity: ${hasActivity}`); - // If no activity, check if should suggest archiving if (!hasActivity) { const shouldSuggestArchive = await this.shouldSuggestArchiving( @@ -218,9 +232,19 @@ export class WeeklySummaryService { newStudents, topStudents, staffPerformance, + mostActiveDays, + peakHours, + recommendations: [], suggestArchive: shouldSuggestArchive, }); } else { + const recommendations = this.generateRecommendations( + queueStats, + asyncStats, + peakHours, + mostActiveDays, + ); + courseStatsArray.push({ course: professorCourse.course, chatbotStats, @@ -229,6 +253,9 @@ export class WeeklySummaryService { newStudents, topStudents, staffPerformance, + mostActiveDays, + peakHours, + recommendations, suggestArchive: false, }); } @@ -435,7 +462,7 @@ export class WeeklySummaryService { } } - // Check for recent activity (4 weeks) + // Check if a course has had any activity in the past 4 weeks. If not, suggest archiving the course. const fourWeeksAgo = new Date(); fourWeeksAgo.setDate(fourWeeksAgo.getDate() - 28); @@ -460,7 +487,6 @@ export class WeeklySummaryService { .andWhere('q.createdAt >= :since', { since: fourWeeksAgo }) .getCount(); - // console.log(` ${course.name} activity check (last 4 weeks): Chatbot=${recentInteractions}, Async=${recentAsyncQuestions}, Queue=${recentQueueQuestions}`); return recentInteractions === 0 && recentAsyncQuestions === 0 && recentQueueQuestions === 0; } @@ -529,7 +555,6 @@ export class WeeklySummaryService { if (staffIds.length === 0) { return []; } - const staffNames = await UserModel.createQueryBuilder('u') .select('u.id', 'id') .addSelect("u.firstName || ' ' || u.lastName", 'name') @@ -558,7 +583,7 @@ export class WeeklySummaryService { // Get top 5 students by queue questions asked const results = await QuestionModel.createQueryBuilder('q') .select('q.creatorId', 'id') - .addSelect("u.firstName || ' ' || u.lastName", 'name') + .addSelect('u.name', 'name') .addSelect('u.email', 'email') .addSelect('COUNT(*)', 'questionsAsked') .innerJoin('q.queue', 'queue') @@ -566,8 +591,7 @@ export class WeeklySummaryService { .where('queue.courseId = :courseId', { courseId }) .andWhere('q.createdAt >= :since', { since }) .groupBy('q.creatorId') - .addGroupBy('u.firstName') - .addGroupBy('u.lastName') + .addGroupBy('u.name') .addGroupBy('u.email') .orderBy('COUNT(*)', 'DESC') .limit(5) @@ -575,12 +599,152 @@ export class WeeklySummaryService { return results.map((r) => ({ id: r.id, - name: r.name, + name: r.name || 'Unknown Student', email: r.email, questionsAsked: parseInt(r.questionsAsked), })); } + private async getMostActiveDays( + courseId: number, + since: Date, + ): Promise { + //get question counts by day of week for queue questions + const results = await QuestionModel.createQueryBuilder('q') + .select("EXTRACT(DOW FROM q.createdAt)", 'dayOfWeek') + .addSelect('COUNT(*)', 'count') + .innerJoin('q.queue', 'queue') + .where('queue.courseId = :courseId', { courseId }) + .andWhere('q.createdAt >= :since', { since }) + .groupBy("EXTRACT(DOW FROM q.createdAt)") + .getRawMany(); + + const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + const byDayOfWeek = dayNames.map(day => ({ day, count: 0 })); + + results.forEach((result) => { + const dayIndex = parseInt(result.dayOfWeek); + byDayOfWeek[dayIndex].count = parseInt(result.count); + }); + let mostActiveDay = 'No activity'; + let maxCount = 0; + //find most active day + byDayOfWeek.forEach((dayData) => { + if (dayData.count > maxCount) { + maxCount = dayData.count; + mostActiveDay = dayData.day; + } + }); + + return { + byDayOfWeek, + mostActiveDay, + }; + } + + private async getPeakHours( + courseId: number, + since: Date, + ): Promise { + //similar logic to most active days but group by hours + const results = await QuestionModel.createQueryBuilder('q') + .select("EXTRACT(HOUR FROM q.createdAt)", 'hour') + .addSelect('COUNT(*)', 'count') + .innerJoin('q.queue', 'queue') + .where('queue.courseId = :courseId', { courseId }) + .andWhere('q.createdAt >= :since', { since }) + .groupBy("EXTRACT(HOUR FROM q.createdAt)") + .getRawMany(); + + if (results.length === 0) { + return { peakHours: [], quietHours: [] }; + } + + const totalCount = results.reduce((sum, r) => sum + parseInt(r.count), 0); + const avgCount = totalCount / results.length; + const peakHours: string[] = []; + const quietHours: string[] = []; + + results.forEach((result) => { + const hour = parseInt(result.hour); + const count = parseInt(result.count); + + const formatHour = (h: number): string => { + if (h === 0) return '12am'; + if (h < 12) return `${h}am`; + if (h === 12) return '12pm'; + return `${h - 12}pm`; + }; + + if (count > avgCount * 1.2) { + peakHours.push(formatHour(hour)); + } else if (count < avgCount * 0.5 && hour >= 8 && hour <= 22) { + quietHours.push(formatHour(hour)); + } + }); + + return { peakHours, quietHours }; + } + + private generateRecommendations( + queueStats: QueueStats, + asyncStats: AsyncQuestionStats, + peakHours: PeakHoursData, + mostActiveDays: MostActiveDaysData, + ): RecommendationData[] { + const recommendations: RecommendationData[] = []; + + // Check for unanswered async questions + if (asyncStats.stillNeedHelp > 0) { + recommendations.push({ + type: 'warning', + message: `${asyncStats.stillNeedHelp} async question${asyncStats.stillNeedHelp !== 1 ? 's' : ''} still need${asyncStats.stillNeedHelp === 1 ? 's' : ''} attention from staff.`, + }); + } + + // Check for high wait times + if (queueStats.avgWaitTime !== null && queueStats.avgWaitTime > 30) { + recommendations.push({ + type: 'warning', + message: `Average wait time is ${queueStats.avgWaitTime.toFixed(1)} minutes. Consider adding more office hours${peakHours.peakHours.length > 0 ? ` during peak times (${peakHours.peakHours.slice(0, 3).join(', ')})` : ''}.`, + }); + } + + // Check for low engagement + if (queueStats.totalQuestions > 0 && queueStats.totalQuestions < 5) { + recommendations.push({ + type: 'info', + message: 'Queue usage is low. Consider reminding students about office hours availability.', + }); + } + + // Check for good performance + if (queueStats.avgWaitTime !== null && queueStats.avgWaitTime < 10 && queueStats.totalQuestions > 10) { + recommendations.push({ + type: 'success', + message: 'Response time is excellent. No recommendations needed.', + }); + } + + // Suggest best times for office hours based on activity + if (mostActiveDays.mostActiveDay !== 'No activity' && mostActiveDays.byDayOfWeek.some(d => d.count > 0)) { + const activeDays = mostActiveDays.byDayOfWeek + .filter(d => d.count > 0) + .sort((a, b) => b.count - a.count) + .slice(0, 3) + .map(d => d.day); + + if (activeDays.length > 0) { + recommendations.push({ + type: 'info', + message: `Most active day${activeDays.length > 1 ? 's' : ''}: ${activeDays.join(', ')}. Consider adding more office hours on these days.`, + }); + } + } + + return recommendations; + } + private buildConsolidatedWeeklySummaryEmail( courseStatsArray: Array<{ @@ -591,6 +755,9 @@ export class WeeklySummaryService { newStudents: NewStudentData[]; topStudents: TopStudentData[]; staffPerformance: StaffPerformanceData[]; + mostActiveDays: MostActiveDaysData; + peakHours: PeakHoursData; + recommendations: RecommendationData[]; suggestArchive: boolean; }>, weekStartDate: Date, @@ -612,9 +779,8 @@ export class WeeklySummaryService { // Process each course for (const courseData of courseStatsArray) { - const { course, chatbotStats, asyncStats, queueStats, newStudents, topStudents, staffPerformance, suggestArchive } = courseData; + const { course, chatbotStats, asyncStats, queueStats, newStudents, topStudents, staffPerformance, mostActiveDays, peakHours, recommendations, suggestArchive } = courseData; - console.log(`Building email for ${course.name}: ${newStudents.length} new students`); // DEBUG html += `
    @@ -768,6 +934,62 @@ export class WeeklySummaryService { `; } + + // Most Active Days Section - show if there's any queue activity + if (queueStats.totalQuestions > 0 && mostActiveDays.byDayOfWeek.some(d => d.count > 0)) { + const totalQuestions = mostActiveDays.byDayOfWeek.reduce((sum, d) => sum + d.count, 0); + html += ` +

    📅 Most Active Days

    +

    Queue activity by day of the week:

    + + `; + + mostActiveDays.byDayOfWeek.forEach((dayData) => { + if (dayData.count > 0) { + const barWidth = Math.max( + (dayData.count / totalQuestions) * 100, + 5, + ); + html += ` + + + + + `; + } + }); + + html += ` +
    ${dayData.day}: +
    + ${dayData.count} +
    +

    Busiest day: ${mostActiveDays.mostActiveDay}

    + `; + } + + // Peak Hours Section - show if there's queue activity and peak hours identified + if (queueStats.totalQuestions > 0 && (peakHours.peakHours.length > 0 || peakHours.quietHours.length > 0)) { + html += ` +

    🕐 Peak Hours

    + `; + + if (peakHours.peakHours.length > 0) { + html += ` +

    + Busiest times: ${peakHours.peakHours.join(', ')} +

    + `; + } + + if (peakHours.quietHours.length > 0) { + html += ` +

    + Quieter times: ${peakHours.quietHours.join(', ')} +

    + `; + } + } } // Top Active Students Section @@ -823,6 +1045,36 @@ export class WeeklySummaryService { `; } + if (recommendations.length > 0) { + html += ` +

    💡 Recommendations

    + `; + + recommendations.forEach((rec) => { + let bgColor, borderColor, icon; + + if (rec.type === 'warning') { + bgColor = '#fff3cd'; + borderColor = '#ffc107'; + icon = '⚠️'; + } else if (rec.type === 'success') { + bgColor = '#d4edda'; + borderColor = '#28a745'; + icon = '✅'; + } else { + bgColor = '#d1ecf1'; + borderColor = '#17a2b8'; + icon = 'ℹ️'; + } + + html += ` +
    +

    ${icon} ${rec.message}

    +
    + `; + }); + } + html += `
    `; From c0322df8fe3e67053c77e4d7894b3e312ba35c3e Mon Sep 17 00:00:00 2001 From: MithishR Date: Sat, 14 Feb 2026 13:43:43 -0800 Subject: [PATCH 10/13] remove all debugigng stuff --- .../server/src/mail/weekly-summary.service.ts | 72 ++++--------------- 1 file changed, 14 insertions(+), 58 deletions(-) diff --git a/packages/server/src/mail/weekly-summary.service.ts b/packages/server/src/mail/weekly-summary.service.ts index f153f1ec5..bee353641 100644 --- a/packages/server/src/mail/weekly-summary.service.ts +++ b/packages/server/src/mail/weekly-summary.service.ts @@ -9,7 +9,6 @@ import { CourseModel } from '../course/course.entity'; import { MailServiceType, Role } from '@koh/common'; import { MoreThanOrEqual } from 'typeorm'; import * as Sentry from '@sentry/nestjs'; -import { UserSubscriptionModel } from './user-subscriptions.entity'; import { UserModel } from '../profile/user.entity'; interface ChatbotStats { @@ -79,7 +78,7 @@ export class WeeklySummaryService { constructor(private mailService: MailService) {} // Run every week - @Cron(CronExpression.EVERY_MINUTE) + @Cron(CronExpression.EVERY_WEEK) async sendWeeklySummaries() { console.log('Starting weekly summary email job...'); const startTime = Date.now(); @@ -98,13 +97,6 @@ export class WeeklySummaryService { .andWhere('course.enabled = :enabled', { enabled: true }) .getMany(); - - // TO REMOVE - console.log( - `Found ${professorCourses.length} professor-course relationships`, - ); - console.log('Courses found:', professorCourses.map(pc => `${pc.course.name} (ID: ${pc.courseId}, enabled: ${pc.course.enabled})`).join(', ')); - // Group courses by professor const professorMap = new Map(); for (const pc of professorCourses) { @@ -114,13 +106,6 @@ export class WeeklySummaryService { professorMap.get(pc.user.id).push(pc); } - - console.log(`Grouped into ${professorMap.size} unique professors`); // TO REMOVE - for (const [profId, courses] of professorMap.entries()) { - const prof = courses[0].user; - console.log(` Professor ${prof.email}: ${courses.map(c => c.course.name).join(', ')}`); // TO REMOVE - } - let emailsSent = 0; let emailsFailed = 0; @@ -129,30 +114,9 @@ export class WeeklySummaryService { const professor = courses[0].user; try { - // const subscription = await UserSubscriptionModel.findOne({ - // where: { - // userId: professorId, - // isSubscribed: true, - // service: { - // serviceType: MailServiceType.WEEKLY_COURSE_SUMMARY, - // }, - // }, - // relations: ['service'], - // }); - - // if (!subscription) { - // console.log( - // `Professor ${professor.email} unsubscribed from weekly summaries`, - // ); - // continue; - // } - // Gather statistics for all courses const courseStatsArray = []; for (const professorCourse of courses) { - console.log(`Processing course: ${professorCourse.course.name} (ID: ${professorCourse.courseId})`); // TO REMOVE - console.log(`Looking for students who joined after: ${lastWeek.toISOString()}`); // DEBUG - const chatbotStats = await this.getChatbotStats( professorCourse.courseId, lastWeek, @@ -190,7 +154,7 @@ export class WeeklySummaryService { lastWeek, ); } catch (error) { - // Return empty stats if there's an error + //Return empty stats if there's an error asyncStats = { total: 0, aiResolved: 0, @@ -219,7 +183,7 @@ export class WeeklySummaryService { const hasActivity = chatbotStats.totalQuestions > 0 || asyncStats.total > 0 || queueStats.totalQuestions > 0; - // If no activity, check if should suggest archiving + //If no activity, should suggest archiving if (!hasActivity) { const shouldSuggestArchive = await this.shouldSuggestArchiving( professorCourse.course, @@ -261,7 +225,7 @@ export class WeeklySummaryService { } } - // Build consolidated email with all courses + //Build consolidated email with all courses const emailHtml = this.buildConsolidatedWeeklySummaryEmail( courseStatsArray, lastWeek, @@ -504,11 +468,6 @@ export class WeeklySummaryService { .addOrderBy('user.firstName', 'ASC') .getMany(); - console.log(`New students for course ${courseId} since ${since}:`, newStudentRecords.length); // DEBUG - newStudentRecords.forEach(uc => { - console.log(` - ${uc.user.firstName} ${uc.user.lastName} (${uc.user.email}) joined at ${uc.createdAt}`); // DEBUG - }); - return newStudentRecords.map((uc) => ({ id: uc.user.id, firstName: uc.user.firstName, @@ -536,7 +495,7 @@ export class WeeklySummaryService { .groupBy('q.taHelpedId') .getRawMany(); - // Get async questions helped by each staff member + // Get async questions helped const asyncHelped = await AsyncQuestionModel.createQueryBuilder('aq') .select('aq.taHelpedId', 'staffId') .addSelect('COUNT(aq.id)', 'count') @@ -571,7 +530,7 @@ export class WeeklySummaryService { name: staff?.name || `ID ${staffId}`, questionsHelped: parseInt(queueData?.count || '0'), asyncQuestionsHelped: parseInt(asyncData?.count || '0'), - avgHelpTime: queueData?.avgHelpTime ? parseFloat(queueData.avgHelpTime) / 60 : null, // Convert seconds to minutes + avgHelpTime: queueData?.avgHelpTime ? parseFloat(queueData.avgHelpTime) / 60 : null, }; }).sort((a, b) => (b.questionsHelped + b.asyncQuestionsHelped) - (a.questionsHelped + a.asyncQuestionsHelped)); } @@ -646,7 +605,7 @@ export class WeeklySummaryService { courseId: number, since: Date, ): Promise { - //similar logic to most active days but group by hours + //Similar logic to most active days but group by hours const results = await QuestionModel.createQueryBuilder('q') .select("EXTRACT(HOUR FROM q.createdAt)", 'hour') .addSelect('COUNT(*)', 'count') @@ -939,7 +898,7 @@ export class WeeklySummaryService { if (queueStats.totalQuestions > 0 && mostActiveDays.byDayOfWeek.some(d => d.count > 0)) { const totalQuestions = mostActiveDays.byDayOfWeek.reduce((sum, d) => sum + d.count, 0); html += ` -

    📅 Most Active Days

    +

    Most Active Days

    Queue activity by day of the week:

    `; @@ -971,7 +930,7 @@ export class WeeklySummaryService { // Peak Hours Section - show if there's queue activity and peak hours identified if (queueStats.totalQuestions > 0 && (peakHours.peakHours.length > 0 || peakHours.quietHours.length > 0)) { html += ` -

    🕐 Peak Hours

    +

    Peak Hours

    `; if (peakHours.peakHours.length > 0) { @@ -995,7 +954,7 @@ export class WeeklySummaryService { // Top Active Students Section if (topStudents.length > 0) { html += ` -

    ⭐ Most Active Students

    +

    Most Active Students

    Top students by questions asked this week:

      `; @@ -1014,7 +973,7 @@ export class WeeklySummaryService { // Staff Performance Section if (staffPerformance.length > 0) { html += ` -

      👥 Staff Performance

      +

      Staff Performance

    @@ -1047,29 +1006,26 @@ export class WeeklySummaryService { if (recommendations.length > 0) { html += ` -

    💡 Recommendations

    +

    Recommendations

    `; recommendations.forEach((rec) => { - let bgColor, borderColor, icon; + let bgColor, borderColor; if (rec.type === 'warning') { bgColor = '#fff3cd'; borderColor = '#ffc107'; - icon = '⚠️'; } else if (rec.type === 'success') { bgColor = '#d4edda'; borderColor = '#28a745'; - icon = '✅'; } else { bgColor = '#d1ecf1'; borderColor = '#17a2b8'; - icon = 'ℹ️'; } html += `
    -

    ${icon} ${rec.message}

    +

    ${rec.message}

    `; }); From 6db5a7ef045dbfb5bc5ef00b692c3d2e9ca15105 Mon Sep 17 00:00:00 2001 From: MithishR Date: Sat, 14 Feb 2026 13:47:03 -0800 Subject: [PATCH 11/13] remove --- packages/server/src/asyncQuestion/asyncQuestion.entity.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/server/src/asyncQuestion/asyncQuestion.entity.ts b/packages/server/src/asyncQuestion/asyncQuestion.entity.ts index 30c4f5c6a..c6aebbcaa 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.entity.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.entity.ts @@ -106,7 +106,6 @@ export class AsyncQuestionModel extends BaseEntity { @AfterLoad() sumVotes() { - // Prevent crash when question upvote/downvote data isn't loaded with the query this.votesSum = this.votes?.reduce((acc, vote) => acc + vote.vote, 0) ?? 0; } } From e7632c2e2d5516b92df7a5dc23efb7efd8112d0d Mon Sep 17 00:00:00 2001 From: MithishR Date: Sat, 14 Feb 2026 13:48:00 -0800 Subject: [PATCH 12/13] remove --- packages/server/src/asyncQuestion/asyncQuestion.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/asyncQuestion/asyncQuestion.entity.ts b/packages/server/src/asyncQuestion/asyncQuestion.entity.ts index c6aebbcaa..4737b5f28 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.entity.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.entity.ts @@ -106,6 +106,6 @@ export class AsyncQuestionModel extends BaseEntity { @AfterLoad() sumVotes() { - this.votesSum = this.votes?.reduce((acc, vote) => acc + vote.vote, 0) ?? 0; + this.votesSum = this.votes?.reduce((acc, vote) => acc + vote.vote, 0); } } From 57f7cb38d6eafb7e7220a520c8776e0d548c0295 Mon Sep 17 00:00:00 2001 From: MithishR Date: Sat, 14 Feb 2026 13:48:21 -0800 Subject: [PATCH 13/13] stray ? --- packages/server/src/asyncQuestion/asyncQuestion.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/asyncQuestion/asyncQuestion.entity.ts b/packages/server/src/asyncQuestion/asyncQuestion.entity.ts index 4737b5f28..ac0636d4a 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.entity.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.entity.ts @@ -106,6 +106,6 @@ export class AsyncQuestionModel extends BaseEntity { @AfterLoad() sumVotes() { - this.votesSum = this.votes?.reduce((acc, vote) => acc + vote.vote, 0); + this.votesSum = this.votes.reduce((acc, vote) => acc + vote.vote, 0); } }