Skip to content

feat(timetable): add cleanup for orphaned cohorts#243

Open
Dasa122 wants to merge 1 commit into
mainfrom
class-delete
Open

feat(timetable): add cleanup for orphaned cohorts#243
Dasa122 wants to merge 1 commit into
mainfrom
class-delete

Conversation

@Dasa122

@Dasa122 Dasa122 commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

This pull request introduces a new feature to clean up orphaned cohorts—cohorts that are no longer linked to any timetable—and ensures users referencing these cohorts are updated accordingly. It also updates the timetable deletion process to remove orphaned cohorts automatically and adds a dedicated API endpoint for manual cleanup. The documentation for affected endpoints has been improved to reflect these changes.

Cohort Cleanup Functionality:

  • Added a new utility function cleanupOrphanedCohorts in cleanup.ts that finds cohorts not linked to any timetable, nullifies cohortId for users referencing them, and deletes the orphaned cohorts in a single transaction.
  • Introduced a new API endpoint /timetables/cleanup-orphaned-cohorts with handler cleanupOrphanedCohortsHandler to trigger this cleanup manually; it returns a summary of affected users and deleted cohorts. [1] [2] [3]

Timetable Deletion Improvements:

  • Updated the timetable deletion logic to also delete any orphaned cohorts as part of the process, ensuring no unused cohorts are left behind.
  • Changed the description for timetable deletion and preview endpoints to clarify that orphaned cohorts will be deleted when a timetable is removed. [1] [2]

closes #200

Summary by CodeRabbit

  • New Features

    • New endpoint available for cleaning up orphaned cohorts.
  • Improvements

    • Timetable deletion now automatically removes orphaned cohorts and nullifies affected user cohort assignments.
    • Timetable deletion preview now reflects orphaned cohort cleanup behavior.

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a cleanupOrphanedCohorts() utility that finds cohorts unreferenced by any cohortTimetableMtm row, nullifies affected users' cohortId, and deletes those cohorts in a single transaction. This utility is integrated into the deleteTimetable transaction and exposed as a new POST endpoint /timetables/cleanup-orphaned-cohorts.

Changes

Orphaned Cohort Cleanup

Layer / File(s) Summary
OrphanCleanupSummary type and cleanupOrphanedCohorts utility
apps/chronos/src/utils/timetable/cleanup.ts
Defines the OrphanCleanupSummary type (deletedCohortIds, affectedUserCount). Implements discovery by diffing all cohort IDs against those referenced in cohortTimetableMtm, then runs a single transaction that nullifies user.cohortId for affected users and deletes the orphaned cohort rows. Returns early with an empty summary when no orphans are found.
Handler integration: deleteTimetable and new cleanupOrphanedCohortsHandler
apps/chronos/src/routes/timetable/index.ts
Imports cleanupOrphanedCohorts. Adds an explicit tx.delete(cohort) step inside the deleteTimetable transaction to remove orphaned cohorts after nullifying users. Updates descriptions for deleteTimetable and previewDeleteTimetable. Defines cleanupOrphanedCohortsResponseSchema and exports cleanupOrphanedCohortsHandler, which calls the utility and returns the summary or a 500 on failure.
Router registration
apps/chronos/src/routes/timetable/_router.ts
Adds cleanupOrphanedCohortsHandler to the router's import spread and registers a POST route at /timetables/cleanup-orphaned-cohorts.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Router as _router.ts
  participant Handler as cleanupOrphanedCohortsHandler
  participant Utility as cleanupOrphanedCohorts
  participant DB as Database

  Client->>Router: POST /timetables/cleanup-orphaned-cohorts
  Router->>Handler: delegate
  Handler->>Utility: cleanupOrphanedCohorts()
  Utility->>DB: SELECT DISTINCT cohortId FROM cohortTimetableMtm
  Utility->>DB: SELECT id FROM cohort
  Utility->>DB: UPDATE user SET cohortId=NULL WHERE cohortId IN orphaned
  Utility->>DB: DELETE FROM cohort WHERE id IN orphaned
  Utility-->>Handler: OrphanCleanupSummary
  Handler-->>Client: 200 { deletedCohortIds, affectedUserCount }
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately summarizes the main change: adding cleanup functionality for orphaned cohorts across the codebase.
Linked Issues check ✅ Passed The PR directly addresses issue #200 by implementing automatic cleanup during timetable deletion and providing a manual cleanup endpoint for retrospective cleanup of existing orphaned cohorts.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing orphaned cohort cleanup functionality as required by the linked issue, with no extraneous modifications.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

📋 Issue Planner

Built with CodeRabbit's Coding Plans for faster development and fewer bugs.

View plan used: #200


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@nemvince

Copy link
Copy Markdown
Member

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/chronos/src/routes/timetable/index.ts (1)

322-333: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use a single conditional update to prevent clobbering concurrent cohort changes.

Line 322–333 has the same select-then-update-by-ID pattern. A user reassigned to a valid cohort between those statements can be incorrectly nulled, and then incorrectly notified.

Proposed fix
-        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));
-          notifiedUserIds.push(...userIds);
-        }
+        const updatedUsers = await tx
+          .update(user)
+          .set({ cohortId: null })
+          .where(inArray(user.cohortId, orphanedCohortIds))
+          .returning({ id: user.id });
+        if (updatedUsers.length > 0) {
+          notifiedUserIds.push(...updatedUsers.map((u) => u.id));
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/chronos/src/routes/timetable/index.ts` around lines 322 - 333, There is
a race condition in the select-then-update pattern where users with orphaned
cohorts are first queried and then updated by ID. Between the select and update,
another transaction could reassign a user to a valid cohort, causing the update
to incorrectly null out that valid cohort assignment. Refactor the code to
combine the select and update into a single conditional update statement by
including the original orphaned cohort condition (inArray(user.cohortId,
orphanedCohortIds)) directly in the WHERE clause of the update operation, and
capture the affected row count to determine which users were actually updated
and should be notified.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/chronos/src/utils/timetable/cleanup.ts`:
- Around line 26-38: The orphan discovery logic that computes linkedSet,
allCohortRows, and orphanedCohortIds is executed outside the database
transaction that begins at line 49, creating a race condition where concurrent
link operations can make the orphanedCohortIds set stale before the destructive
writes execute. Move the entire orphan discovery block starting with the
selectDistinct call for linkedRows through the filter that computes
orphanedCohortIds into the same transaction that contains the subsequent nullify
and delete operations, ensuring the discovery and writes happen atomically and
safely.
- Around line 51-63: Remove the separate select query that collects affected
user IDs first. Instead, directly update the user table where cohortId is in
orphanedCohortIds, and use the returning() method to collect the affected
userIds from the update result. This eliminates the race condition where a
user's cohortId could be assigned a valid value between the select and update
operations, ensuring only users currently having orphaned cohortIds get
nullified.

---

Outside diff comments:
In `@apps/chronos/src/routes/timetable/index.ts`:
- Around line 322-333: There is a race condition in the select-then-update
pattern where users with orphaned cohorts are first queried and then updated by
ID. Between the select and update, another transaction could reassign a user to
a valid cohort, causing the update to incorrectly null out that valid cohort
assignment. Refactor the code to combine the select and update into a single
conditional update statement by including the original orphaned cohort condition
(inArray(user.cohortId, orphanedCohortIds)) directly in the WHERE clause of the
update operation, and capture the affected row count to determine which users
were actually updated and should be notified.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c727da05-eee7-4d5a-8363-af38af9193a1

📥 Commits

Reviewing files that changed from the base of the PR and between 1200ddc and 0d01a45.

📒 Files selected for processing (3)
  • apps/chronos/src/routes/timetable/_router.ts
  • apps/chronos/src/routes/timetable/index.ts
  • apps/chronos/src/utils/timetable/cleanup.ts
📜 Review details
🧰 Additional context used
🪛 OpenGrep (1.23.0)
apps/chronos/src/routes/timetable/index.ts

[WARNING] 527-527: Sequelize.literal() with dynamic input can lead to SQL injection. Use parameterized queries or model methods instead.

(coderabbit.sql-injection.sequelize-literal)

🔇 Additional comments (2)
apps/chronos/src/routes/timetable/_router.ts (1)

5-5: LGTM!

Also applies to: 51-54

apps/chronos/src/routes/timetable/index.ts (1)

336-339: 🗄️ Data Integrity & Integration

No action needed — FK delete order is correct.

The cohortTimetableMtm table has onDelete: 'cascade' on both cohortId (line 158) and timetableId (line 161) foreign keys. Deleting cohort before timetable is entirely safe because:

  1. Deleting cohort cascades deletion of matching cohortTimetableMtm entries via the cohortId FK.
  2. Deleting timetable cascades deletion of remaining cohortTimetableMtm entries via the timetableId FK.
  3. No reverse constraint exists (timetable does not reference cohort).

The explicit deletion of orphaned cohorts is intentional and does not violate any constraints.

			> Likely an incorrect or invalid review comment.

Comment on lines +26 to +38
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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Move orphan discovery into the same transaction as the destructive writes.

Line 26–38 computes orphanedCohortIds before Line 49 starts the transaction. A concurrent link insert/delete can stale that set and make the subsequent nullify/delete act on outdated membership.

Proposed fix
 export async function cleanupOrphanedCohorts(): Promise<OrphanCleanupSummary> {
-  // 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;
+  let deletedCohortIds: string[] = [];
 
   await db.transaction(async (tx) => {
+    const linkedRows = await tx
+      .selectDistinct({ cohortId: cohortTimetableMtm.cohortId })
+      .from(cohortTimetableMtm);
+    const linkedSet = new Set(linkedRows.map((r) => r.cohortId));
+
+    const allCohortRows = await tx.select({ id: cohort.id }).from(cohort);
+    const orphanedCohortIds = allCohortRows
+      .map((r) => r.id)
+      .filter((id) => !linkedSet.has(id));
+
+    if (orphanedCohortIds.length === 0) {
+      logger.info('No orphaned cohorts found.');
+      return;
+    }
+
+    deletedCohortIds = orphanedCohortIds;
+
     // Nullify cohortId on users referencing orphaned cohorts
     const affectedUsers = await tx
       .select({ id: user.id })
@@
     await tx.delete(cohort).where(inArray(cohort.id, orphanedCohortIds));
     logger.info('Deleted orphaned cohorts', {
       count: orphanedCohortIds.length,
     });
   });
 
-  return { affectedUserCount, deletedCohortIds: orphanedCohortIds };
+  return { affectedUserCount, deletedCohortIds };
 }

Also applies to: 49-71

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/chronos/src/utils/timetable/cleanup.ts` around lines 26 - 38, The orphan
discovery logic that computes linkedSet, allCohortRows, and orphanedCohortIds is
executed outside the database transaction that begins at line 49, creating a
race condition where concurrent link operations can make the orphanedCohortIds
set stale before the destructive writes execute. Move the entire orphan
discovery block starting with the selectDistinct call for linkedRows through the
filter that computes orphanedCohortIds into the same transaction that contains
the subsequent nullify and delete operations, ensuring the discovery and writes
happen atomically and safely.

Comment on lines +51 to +63
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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Avoid stale-ID updates when nullifying user cohort assignments.

Line 51–63 selects users, then updates by user.id. If a user’s cohortId changes between those statements, this can nullify a newly assigned valid cohort. Update directly with the cohortId IN orphanedCohortIds predicate and collect IDs from returning().

Proposed fix
-    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 });
-    }
+    const updatedUsers = await tx
+      .update(user)
+      .set({ cohortId: null })
+      .where(inArray(user.cohortId, orphanedCohortIds))
+      .returning({ id: user.id });
+
+    affectedUserCount = updatedUsers.length;
+    if (affectedUserCount > 0) {
+      logger.info('Nullified cohortId for users', { count: affectedUserCount });
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/chronos/src/utils/timetable/cleanup.ts` around lines 51 - 63, Remove the
separate select query that collects affected user IDs first. Instead, directly
update the user table where cohortId is in orphanedCohortIds, and use the
returning() method to collect the affected userIds from the update result. This
eliminates the race condition where a user's cohortId could be assigned a valid
value between the select and update operations, ensuring only users currently
having orphaned cohortIds get nullified.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

Classes are duplicated

2 participants