My App
CourseKitGuides

Custom Constraints

Create custom schedule constraints for the conflict detection pipeline

Overview

CourseKit's conflict detection runs a pipeline of ScheduleConstraint implementations. You can add custom constraints alongside (or instead of) the built-in ones.

Step 1: Extend ScheduleConstraint

import { ScheduleConstraint, type ConstraintContext } from '@hfu.digital/coursekit-nestjs';
import type { MaterializedOccurrence, Conflict } from '@hfu.digital/coursekit-nestjs';

export class MinBreakConstraint extends ScheduleConstraint {
    readonly type = 'min-break';
    readonly description = 'Ensures minimum break between consecutive events for instructors';

    constructor(private readonly minBreakMinutes: number = 15) {
        super();
    }

    async evaluate(
        occurrences: MaterializedOccurrence[],
        context: ConstraintContext,
    ): Promise<Conflict[]> {
        const conflicts: Conflict[] = [];

        // Group occurrences by instructor
        const instructorOccurrences = new Map<string, MaterializedOccurrence[]>();

        for (const occ of occurrences) {
            const instructorIds = await context.getInstructorsForEvent(occ.eventId);
            for (const instructorId of instructorIds) {
                if (!instructorOccurrences.has(instructorId)) {
                    instructorOccurrences.set(instructorId, []);
                }
                instructorOccurrences.get(instructorId)!.push(occ);
            }
        }

        // Check consecutive events for each instructor
        for (const [instructorId, occs] of instructorOccurrences) {
            const sorted = [...occs].sort(
                (a, b) => a.startTime.getTime() - b.startTime.getTime(),
            );

            for (let i = 0; i < sorted.length - 1; i++) {
                const current = sorted[i];
                const next = sorted[i + 1];
                const currentEnd = new Date(
                    current.startTime.getTime() + current.durationMin * 60_000,
                );
                const gap = (next.startTime.getTime() - currentEnd.getTime()) / 60_000;

                if (gap >= 0 && gap < this.minBreakMinutes) {
                    conflicts.push({
                        id: `min-break-${instructorId}-${current.eventId}-${next.eventId}`,
                        type: 'insufficient-break',
                        severity: 'warning',
                        message: `Instructor ${instructorId} has only ${gap}min break between events (minimum: ${this.minBreakMinutes}min)`,
                        involvedEventIds: [current.eventId, next.eventId],
                        involvedEntityIds: [instructorId],
                        metadata: {
                            gapMinutes: gap,
                            requiredMinutes: this.minBreakMinutes,
                        },
                    });
                }
            }
        }

        return conflicts;
    }
}

Step 2: Register the Constraint

Add your custom constraint to the module options:

CourseKitModule.register({
    // ...storage adapters
    constraints: [new MinBreakConstraint(15)],
})

Custom constraints run after built-in constraints. To replace the built-ins entirely:

CourseKitModule.register({
    // ...storage adapters
    enableBuiltInConstraints: false,
    constraints: [
        new MinBreakConstraint(15),
        new MyCustomOverlapConstraint(),
    ],
})

ConstraintContext Helpers

Every constraint receives a ConstraintContext with these lookup functions:

HelperReturnsDescription
getInstructorsForEvent(eventId)string[]Instructor IDs assigned to an event
getGroupsForEvent(eventId)string[]Group IDs assigned to an event
getRoomById(roomId)Room | nullRoom details
getGroupStudentCount(groupId)numberTotal students in a group
isEntityAvailable(...){ available, conflicts }Check entity availability

These helpers are injected by ConflictService so your constraint doesn't need to depend on storage classes directly.

More Examples

MaxDailyHoursConstraint

export class MaxDailyHoursConstraint extends ScheduleConstraint {
    readonly type = 'max-daily-hours';
    readonly description = 'Limits instructor teaching hours per day';

    constructor(private readonly maxHours: number = 6) {
        super();
    }

    async evaluate(occurrences: MaterializedOccurrence[], context: ConstraintContext) {
        const conflicts: Conflict[] = [];
        const instructorDayMinutes = new Map<string, number>();

        for (const occ of occurrences) {
            const instructorIds = await context.getInstructorsForEvent(occ.eventId);
            const dayKey = occ.occurrenceDate.toISOString().split('T')[0];

            for (const id of instructorIds) {
                const key = `${id}-${dayKey}`;
                const current = instructorDayMinutes.get(key) ?? 0;
                instructorDayMinutes.set(key, current + occ.durationMin);
            }
        }

        for (const [key, totalMin] of instructorDayMinutes) {
            if (totalMin > this.maxHours * 60) {
                const [instructorId, day] = key.split('-');
                conflicts.push({
                    id: `max-daily-${key}`,
                    type: 'max-daily-hours-exceeded',
                    severity: 'warning',
                    message: `Instructor ${instructorId} has ${(totalMin / 60).toFixed(1)}h on ${day} (max: ${this.maxHours}h)`,
                    involvedEventIds: [],
                    involvedEntityIds: [instructorId],
                    metadata: { totalMinutes: totalMin, maxMinutes: this.maxHours * 60 },
                });
            }
        }

        return conflicts;
    }
}

CrossCampusTravelConstraint

export class CrossCampusTravelConstraint extends ScheduleConstraint {
    readonly type = 'cross-campus-travel';
    readonly description = 'Checks travel time between consecutive events on different campuses';

    // Implementation would use getRoomById() to check campus,
    // then compare gap time against LocationDistance records
}

On this page