From 0d01a45079253cd61ac199c52cf2ed5a898b052f Mon Sep 17 00:00:00 2001 From: Dasa122 Date: Tue, 23 Jun 2026 22:09:12 +0000 Subject: [PATCH] feat(timetable): add cleanup for orphaned cohorts --- apps/chronos/src/routes/timetable/_router.ts | 5 ++ apps/chronos/src/routes/timetable/index.ts | 53 +++++++++++++- apps/chronos/src/utils/timetable/cleanup.ts | 74 ++++++++++++++++++++ 3 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 apps/chronos/src/utils/timetable/cleanup.ts diff --git a/apps/chronos/src/routes/timetable/_router.ts b/apps/chronos/src/routes/timetable/_router.ts index 8d5f6b5..fa853ff 100644 --- a/apps/chronos/src/routes/timetable/_router.ts +++ b/apps/chronos/src/routes/timetable/_router.ts @@ -2,6 +2,7 @@ import { timetableFactory } from '#routes/timetable/_factory'; import { getCohortsForTimetable } from '#routes/timetable/cohort'; import { importRoute } from '#routes/timetable/import'; import { + cleanupOrphanedCohortsHandler, deleteTimetable, getAllTimetables, getAllValidTimetables, @@ -47,6 +48,10 @@ export const timetableRouter = timetableFactory .patch('/timetables/:id', ...updateTimetable) .get('/timetables/:id/preview-delete', ...previewDeleteTimetable) .delete('/timetables/:id', ...deleteTimetable) + .post( + '/timetables/cleanup-orphaned-cohorts', + ...cleanupOrphanedCohortsHandler + ) .post('/import', ...importRoute) // Substitution routes .get('/substitutions', ...getAllSubstitutions) diff --git a/apps/chronos/src/routes/timetable/index.ts b/apps/chronos/src/routes/timetable/index.ts index c82df00..a11b695 100644 --- a/apps/chronos/src/routes/timetable/index.ts +++ b/apps/chronos/src/routes/timetable/index.ts @@ -22,6 +22,7 @@ import { requireAuthentication, requireAuthorization } from '#middleware/auth'; import { dispatchImmediateNotification } from '#utils/notifications/engine'; import { filcExt } from '#utils/openapi'; import { getActiveTimetableId } from '#utils/timetable/active'; +import { cleanupOrphanedCohorts } from '#utils/timetable/cleanup'; import { dateToYYYYMMDD } from '#utils/timetable/date'; import { createSelectSchema } from '#utils/zod'; import { timetableFactory } from './_factory'; @@ -247,7 +248,7 @@ export const deleteTimetable = timetableFactory.createHandlers( describeRoute({ ...filcExt('Timetable', '@unit Timetable', true), description: - 'Delete a timetable and all its related data. Cohorts survive.', + 'Delete a timetable and all its related data, including orphaned cohorts.', responses: { 200: { content: { @@ -330,6 +331,9 @@ export const deleteTimetable = timetableFactory.createHandlers( .where(inArray(user.id, userIds)); notifiedUserIds.push(...userIds); } + + // Delete orphaned cohorts that are no longer linked to any timetable + await tx.delete(cohort).where(inArray(cohort.id, orphanedCohortIds)); } await tx.delete(timetable).where(eq(timetable.id, id)); @@ -374,7 +378,8 @@ const previewDeleteResponseSchema = z.object({ export const previewDeleteTimetable = timetableFactory.createHandlers( describeRoute({ ...filcExt('Timetable', '@unit Timetable', true), - description: 'Preview the impact of deleting a timetable.', + description: + 'Preview the impact of deleting a timetable. Orphaned cohorts will be deleted along with the timetable.', responses: { 200: { content: { @@ -513,3 +518,47 @@ export const previewDeleteTimetable = timetableFactory.createHandlers( }); } ); + +const cleanupOrphanedCohortsResponseSchema = z.object({ + data: z.object({ + affectedUserCount: z.number().int(), + deletedCohortIds: z.array(z.string()), + }), + success: z.literal(true), +}); + +export const cleanupOrphanedCohortsHandler = timetableFactory.createHandlers( + describeRoute({ + ...filcExt('Timetable', '@unit Timetable', true), + description: + 'Delete all cohorts that are no longer linked to any timetable (orphaned). Users referencing those cohorts will have their cohortId nullified.', + responses: { + 200: { + content: { + 'application/json': { + schema: resolver(cleanupOrphanedCohortsResponseSchema), + }, + }, + description: 'Cleanup summary', + }, + }, + tags: ['Timetable'], + }), + requireAuthentication, + requireAuthorization('import:timetable'), + async (c) => { + try { + const summary = await cleanupOrphanedCohorts(); + + return c.json>({ + data: summary, + success: true, + }); + } catch (error) { + logger.error('Failed to cleanup orphaned cohorts: ', { error }); + throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { + message: 'Failed to cleanup orphaned cohorts', + }); + } + } +); diff --git a/apps/chronos/src/utils/timetable/cleanup.ts b/apps/chronos/src/utils/timetable/cleanup.ts new file mode 100644 index 0000000..01aff8b --- /dev/null +++ b/apps/chronos/src/utils/timetable/cleanup.ts @@ -0,0 +1,74 @@ +import { getLogger } from '@logtape/logtape'; +import { inArray } from 'drizzle-orm'; +import { db } from '#database'; +import { user } from '#database/schema/authentication'; +import { cohort, cohortTimetableMtm } from '#database/schema/timetable'; + +const logger = getLogger(['chronos', 'timetable', 'cleanup']); + +export type OrphanCleanupSummary = { + /** IDs of the deleted cohort rows. */ + deletedCohortIds: string[]; + /** Number of users whose cohortId was nullified. */ + affectedUserCount: number; +}; + +/** + * Find and delete cohorts that are not linked to any timetable via the + * `cohort_timetable_mtm` table. For each orphan, any user referencing that + * cohort has their `cohortId` set to NULL before the cohort row is removed. + * + * The destructive part of the operation (user nullification + cohort deletion) + * runs inside a single database transaction. + */ +export async function cleanupOrphanedCohorts(): Promise { + // Find all cohort IDs that are referenced in the MTM table + const linkedRows = await db + .selectDistinct({ cohortId: cohortTimetableMtm.cohortId }) + .from(cohortTimetableMtm); + + const linkedSet = new Set(linkedRows.map((r) => r.cohortId)); + + // Find all cohort IDs + const allCohortRows = await db.select({ id: cohort.id }).from(cohort); + + // Cohorts whose ID does not appear in the MTM table are orphaned + const orphanedCohortIds = allCohortRows + .map((r) => r.id) + .filter((id) => !linkedSet.has(id)); + + if (orphanedCohortIds.length === 0) { + logger.info('No orphaned cohorts found.'); + return { affectedUserCount: 0, deletedCohortIds: [] }; + } + + logger.info('Found orphaned cohorts', { count: orphanedCohortIds.length }); + + let affectedUserCount = 0; + + await db.transaction(async (tx) => { + // Nullify cohortId on users referencing orphaned cohorts + const affectedUsers = await tx + .select({ id: user.id }) + .from(user) + .where(inArray(user.cohortId, orphanedCohortIds)); + + if (affectedUsers.length > 0) { + const userIds = affectedUsers.map((u) => u.id); + await tx + .update(user) + .set({ cohortId: null }) + .where(inArray(user.id, userIds)); + affectedUserCount = userIds.length; + logger.info('Nullified cohortId for users', { count: userIds.length }); + } + + // Delete the orphaned cohort rows + await tx.delete(cohort).where(inArray(cohort.id, orphanedCohortIds)); + logger.info('Deleted orphaned cohorts', { + count: orphanedCohortIds.length, + }); + }); + + return { affectedUserCount, deletedCohortIds: orphanedCohortIds }; +}