From 6460032be01a98440ef10cbb67d9ec52f2932024 Mon Sep 17 00:00:00 2001 From: Anmol Sharma Date: Fri, 3 Apr 2026 22:14:01 +0530 Subject: [PATCH] feat: add scheduledDate to cohort weeks for session postponement Add a non-nullable scheduledDate column to CohortWeek, replacing the implicit startDate + week*7 computation. Admins can now postpone individual sessions via PATCH /cohorts/weeks/:id with a scheduledDate field. Rescheduling updates reminder tasks and sends calendar updates. Co-Authored-By: Claude Opus 4.6 (1M context) --- migrations/1775347200000-migrations.ts | 28 ++++ .../cohort-calendar.service.ts | 3 +- src/cohorts/cohort-reminder.service.ts | 3 +- src/cohorts/cohorts.request.dto.ts | 4 + src/cohorts/cohorts.response.dto.ts | 3 + src/cohorts/cohorts.service.ts | 128 ++++++++++++++---- src/entities/cohort-week.entity.ts | 3 + 7 files changed, 143 insertions(+), 29 deletions(-) create mode 100644 migrations/1775347200000-migrations.ts diff --git a/migrations/1775347200000-migrations.ts b/migrations/1775347200000-migrations.ts new file mode 100644 index 0000000..dc6b7fa --- /dev/null +++ b/migrations/1775347200000-migrations.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class Migrations1775347200000 implements MigrationInterface { + name = 'Migrations1775347200000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "cohort_week" ADD "scheduledDate" TIMESTAMP WITH TIME ZONE`, + ); + + await queryRunner.query(` + UPDATE "cohort_week" + SET "scheduledDate" = c."startDate" + ("cohort_week"."week" * 7) * INTERVAL '1 day' + FROM "cohort" c + WHERE c."id" = "cohort_week"."cohortId" + `); + + await queryRunner.query( + `ALTER TABLE "cohort_week" ALTER COLUMN "scheduledDate" SET NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "cohort_week" DROP COLUMN "scheduledDate"`, + ); + } +} diff --git a/src/cohort-calendar/cohort-calendar.service.ts b/src/cohort-calendar/cohort-calendar.service.ts index 143ccf3..744214e 100644 --- a/src/cohort-calendar/cohort-calendar.service.ts +++ b/src/cohort-calendar/cohort-calendar.service.ts @@ -116,8 +116,7 @@ export class CohortCalendarService { const sortedWeeks = [...cohort.weeks].sort((a, b) => a.week - b.week); for (const week of sortedWeeks) { - const eventDate = new Date(cohort.startDate); - eventDate.setUTCDate(eventDate.getUTCDate() + week.week * 7); + const eventDate = new Date(week.scheduledDate); eventDate.setUTCHours(14, 30, 0, 0); const endDate = new Date(eventDate); diff --git a/src/cohorts/cohort-reminder.service.ts b/src/cohorts/cohort-reminder.service.ts index 3f645a9..17183f6 100644 --- a/src/cohorts/cohort-reminder.service.ts +++ b/src/cohorts/cohort-reminder.service.ts @@ -57,8 +57,7 @@ export class CohortReminderService { ); } - const sessionDate = new Date(cohort.startDate); - sessionDate.setUTCDate(sessionDate.getUTCDate() + cohortWeek.week * 7); + const sessionDate = new Date(cohortWeek.scheduledDate); const usersWithEmail = cohort.users.filter((u) => u.email); diff --git a/src/cohorts/cohorts.request.dto.ts b/src/cohorts/cohorts.request.dto.ts index f6245be..606cebd 100644 --- a/src/cohorts/cohorts.request.dto.ts +++ b/src/cohorts/cohorts.request.dto.ts @@ -47,6 +47,10 @@ export class UpdateCohortWeekRequestDto { }) @IsNotEmpty() classroomAssignmentId!: string | undefined; + + @IsOptional() + @IsDateString({ strict: true }) + scheduledDate!: string | undefined; } export class JoinWaitlistRequestDto { diff --git a/src/cohorts/cohorts.response.dto.ts b/src/cohorts/cohorts.response.dto.ts index fb17757..b082dca 100644 --- a/src/cohorts/cohorts.response.dto.ts +++ b/src/cohorts/cohorts.response.dto.ts @@ -10,6 +10,7 @@ export class GetCohortWeekResponseDto { bonusQuestion!: string[]; classroomInviteLink!: string | null; classroomAssignmentUrl!: string | null; + scheduledDate!: string; constructor(obj: GetCohortWeekResponseDto) { this.id = obj.id; @@ -20,6 +21,7 @@ export class GetCohortWeekResponseDto { this.bonusQuestion = obj.bonusQuestion; this.classroomInviteLink = obj.classroomInviteLink; this.classroomAssignmentUrl = obj.classroomAssignmentUrl; + this.scheduledDate = obj.scheduledDate; } } @@ -66,6 +68,7 @@ export class GetCohortResponseDto { bonusQuestion: week.bonusQuestion || [], classroomInviteLink: week.classroomInviteLink || null, classroomAssignmentUrl: week.classroomAssignmentUrl ?? null, + scheduledDate: week.scheduledDate.toISOString(), })), }); } diff --git a/src/cohorts/cohorts.service.ts b/src/cohorts/cohorts.service.ts index 598f866..d5eb47a 100644 --- a/src/cohorts/cohorts.service.ts +++ b/src/cohorts/cohorts.service.ts @@ -27,7 +27,7 @@ import { ConfigService } from '@nestjs/config'; import { CohortType, CohortWeekType } from '@/common/enum'; import { CohortWaitlist } from '@/entities/cohort-waitlist.entity'; import { APITask } from '@/entities/api-task.entity'; -import { TaskType } from '@/task-processor/task.enums'; +import { APITaskStatus, TaskType } from '@/task-processor/task.enums'; import { MailService } from '@/mail/mail.service'; import { CohortsConfigService } from '@/cohorts/cohorts.config.service'; import { CohortCalendarService } from '@/cohort-calendar/cohort-calendar.service'; @@ -269,6 +269,12 @@ export class CohortsService { week.week = weekNumber; week.cohort = cohort; + const scheduledDate = new Date(startDate); + scheduledDate.setUTCDate( + scheduledDate.getUTCDate() + weekNumber * 7, + ); + week.scheduledDate = scheduledDate; + if (weekNumber === 0) { week.type = CohortWeekType.ORIENTATION; week.hasExercise = false; @@ -298,29 +304,10 @@ export class CohortsService { apiTask.data = { cohortId: cohort.id }; await manager.save(apiTask); - // Schedule reminder email tasks for each week (except graduation) - const reminderTasks: APITask[] = - cohort.weeks.map((week) => { - const executeOnTime = new Date(startDate); - executeOnTime.setUTCDate( - executeOnTime.getUTCDate() + week.week * 7, - ); - // 12:00 PM IST = 06:30 UTC - executeOnTime.setUTCHours(6, 30, 0, 0); - - const reminderTask = - new APITask(); - reminderTask.type = - TaskType.SEND_COHORT_REMINDER_EMAILS; - reminderTask.data = { - cohortId: cohort.id, - cohortWeekId: week.id, - }; - reminderTask.executeOnTime = executeOnTime; - - return reminderTask; - }); - + // Schedule reminder email tasks for each week + const reminderTasks = cohort.weeks.map((week) => + this.createReminderTask(cohort.id, week), + ); await manager.save(reminderTasks); // Schedule feedback reminder emails (day after 4th GD session) @@ -348,6 +335,7 @@ export class CohortsService { ): Promise { const cohort: Cohort | null = await this.cohortRepository.findOne({ where: { id: cohortId }, + relations: { weeks: true }, }); if (!cohort) { @@ -384,6 +372,36 @@ export class CohortsService { cohortData.startDate && cohort.startDate.getTime() !== originalStartDate.getTime() ) { + // Shift all week scheduledDates by the same offset + const offsetMs = + cohort.startDate.getTime() - originalStartDate.getTime(); + + for (const week of cohort.weeks) { + week.scheduledDate = new Date( + week.scheduledDate.getTime() + offsetMs, + ); + } + await manager.save(CohortWeek, cohort.weeks); + + // Cancel all unprocessed reminder tasks and recreate with new dates + await manager + .createQueryBuilder() + .update(APITask) + .set({ status: APITaskStatus.CANCELLED }) + .where('type = :type', { + type: TaskType.SEND_COHORT_REMINDER_EMAILS, + }) + .andWhere('status = :status', { + status: APITaskStatus.UNPROCESSED, + }) + .andWhere("data->>'cohortId' = :cohortId", { cohortId }) + .execute(); + + const reminderTasks = cohort.weeks.map((week) => + this.createReminderTask(cohort.id, week), + ); + await manager.save(APITask, reminderTasks); + const apiTask = new APITask(); apiTask.type = TaskType.SEND_CALENDAR_UPDATE_EMAILS; @@ -400,6 +418,7 @@ export class CohortsService { const cohortWeek: CohortWeek | null = await this.cohortWeekRepository.findOne({ where: { id: cohortWeekId }, + relations: { cohort: true }, }); if (!cohortWeek) { @@ -419,7 +438,66 @@ export class CohortsService { cohortWeekData.classroomAssignmentId; } - await this.cohortWeekRepository.save(cohortWeek); + let scheduledDateChanged = false; + + if (cohortWeekData.scheduledDate) { + const scheduledDate = new Date(cohortWeekData.scheduledDate); + scheduledDate.setUTCHours(0, 0, 0, 0); + scheduledDateChanged = + scheduledDate.getTime() !== cohortWeek.scheduledDate.getTime(); + cohortWeek.scheduledDate = scheduledDate; + } + + await this.dbTransactionService.execute(async (manager) => { + await manager.save(CohortWeek, cohortWeek); + + if (scheduledDateChanged) { + // Cancel existing unprocessed reminder task for this week + await manager + .createQueryBuilder() + .update(APITask) + .set({ status: APITaskStatus.CANCELLED }) + .where('type = :type', { + type: TaskType.SEND_COHORT_REMINDER_EMAILS, + }) + .andWhere('status = :status', { + status: APITaskStatus.UNPROCESSED, + }) + .andWhere("data->>'cohortWeekId' = :cohortWeekId", { + cohortWeekId, + }) + .execute(); + + // Create new reminder task with updated date + const reminderTask = this.createReminderTask( + cohortWeek.cohort.id, + cohortWeek, + ); + await manager.save(APITask, reminderTask); + + // Send calendar update emails + const calendarTask = + new APITask(); + calendarTask.type = TaskType.SEND_CALENDAR_UPDATE_EMAILS; + calendarTask.data = { cohortId: cohortWeek.cohort.id }; + await manager.save(APITask, calendarTask); + } + }); + } + + private createReminderTask( + cohortId: string, + week: CohortWeek, + ): APITask { + const executeOnTime = new Date(week.scheduledDate); + // 12:00 PM IST = 06:30 UTC + executeOnTime.setUTCHours(6, 30, 0, 0); + + const task = new APITask(); + task.type = TaskType.SEND_COHORT_REMINDER_EMAILS; + task.data = { cohortId, cohortWeekId: week.id }; + task.executeOnTime = executeOnTime; + return task; } async assignDiscordRole(userId: string, cohortType: CohortType) { diff --git a/src/entities/cohort-week.entity.ts b/src/entities/cohort-week.entity.ts index 96182fb..8f8ee16 100644 --- a/src/entities/cohort-week.entity.ts +++ b/src/entities/cohort-week.entity.ts @@ -45,6 +45,9 @@ export class CohortWeek extends BaseEntity { @Column('text', { nullable: true }) classroomAssignmentId!: string | null; + @Column({ type: 'timestamp with time zone' }) + scheduledDate!: Date; + @ManyToOne(() => Cohort, (c) => c.weeks) cohort!: Cohort;