My App
CourseKitExamples

NestJS REST API

Complete NestJS backend example with all CourseKit endpoints

Overview

This example shows a complete NestJS REST API using CourseKit with Prisma adapters, covering schedule queries, event management, conflict checking, and availability.

Module Setup

// app.module.ts
import { Module } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import {
    CourseKitModule,
    PrismaTimetableEventAdapter,
    PrismaRoomAdapter,
    PrismaInstructorAdapter,
    PrismaGroupAdapter,
    PrismaAvailabilityAdapter,
    PrismaAcademicPeriodAdapter,
    PrismaCourseAdapter,
} from '@hfu.digital/coursekit-nestjs';
import { ScheduleController } from './schedule.controller';
import { EventController } from './event.controller';
import { ConflictController } from './conflict.controller';
import { NotificationService } from './notification.service';

const prisma = new PrismaClient();

@Module({
    imports: [
        CourseKitModule.register({
            eventStorage: new PrismaTimetableEventAdapter(
                prisma.timetableEvent,
                prisma.eventException,
                prisma.eventInstructor,
                prisma.eventGroup,
            ),
            roomStorage: new PrismaRoomAdapter(prisma.room),
            instructorStorage: new PrismaInstructorAdapter(prisma.instructor),
            groupStorage: new PrismaGroupAdapter(prisma.group, prisma.studentGroup),
            availabilityStorage: new PrismaAvailabilityAdapter(prisma.availability),
            periodStorage: new PrismaAcademicPeriodAdapter(prisma.academicPeriod),
            courseStorage: new PrismaCourseAdapter(prisma.course),
        }),
    ],
    controllers: [ScheduleController, EventController, ConflictController],
    providers: [NotificationService],
})
export class AppModule {}

Schedule Controller

// schedule.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { QueryService, AvailabilityService } from '@hfu.digital/coursekit-nestjs';

@Controller('coursekit')
export class ScheduleController {
    constructor(
        private readonly query: QueryService,
        private readonly availability: AvailabilityService,
    ) {}

    @Get('schedule')
    async getSchedule(
        @Query('startDate') startDate: string,
        @Query('endDate') endDate: string,
        @Query('instructorIds') instructorIds?: string,
        @Query('roomIds') roomIds?: string,
        @Query('groupIds') groupIds?: string,
        @Query('courseIds') courseIds?: string,
        @Query('periodId') periodId?: string,
    ) {
        return this.query.getSchedule({
            dateRange: {
                start: new Date(startDate),
                end: new Date(endDate),
            },
            instructorIds: instructorIds?.split(','),
            roomIds: roomIds?.split(','),
            groupIds: groupIds?.split(','),
            courseIds: courseIds?.split(','),
            periodId,
        });
    }

    @Get('availability')
    async getAvailability(
        @Query('entityType') entityType: 'instructor' | 'room',
        @Query('entityId') entityId: string,
        @Query('startDate') startDate: string,
        @Query('endDate') endDate: string,
    ) {
        const dateRange = {
            start: new Date(startDate),
            end: new Date(endDate),
        };

        const [isAvailable, freeSlots] = await Promise.all([
            this.availability.isAvailable(entityType, entityId, dateRange.start, 60),
            this.availability.findFreeSlots(entityType, entityId, dateRange, 30),
        ]);

        return { slots: [], freeSlots, isAvailable: isAvailable.available };
    }

    @Get('free-slots')
    async findFreeSlots(
        @Query('startDate') startDate: string,
        @Query('endDate') endDate: string,
        @Query('durationMin') durationMin: string,
        @Query('instructorIds') instructorIds?: string,
        @Query('roomIds') roomIds?: string,
    ) {
        const entityIds = [
            ...(instructorIds?.split(',').map(id => ({ type: 'instructor' as const, id })) ?? []),
            ...(roomIds?.split(',').map(id => ({ type: 'room' as const, id })) ?? []),
        ];

        return this.query.findFreeSlots({
            dateRange: { start: new Date(startDate), end: new Date(endDate) },
            durationMin: parseInt(durationMin, 10),
            entityIds,
        });
    }
}

Event Controller

// event.controller.ts
import { Controller, Get, Post, Patch, Delete, Body, Param } from '@nestjs/common';
import {
    TimetableEventStorage,
    RecurrenceService,
    type TimetableEvent,
} from '@hfu.digital/coursekit-nestjs';

@Controller('coursekit/events')
export class EventController {
    constructor(
        private readonly eventStorage: TimetableEventStorage,
        private readonly recurrence: RecurrenceService,
    ) {}

    @Post()
    async createEvent(@Body() body: {
        title: string;
        startTime: string;
        durationMin: number;
        recurrenceRule?: string | null;
        roomId?: string | null;
        courseId?: string | null;
        periodId?: string | null;
        metadata?: Record<string, unknown> | null;
    }) {
        // Validate RRULE if provided
        if (body.recurrenceRule) {
            const error = this.recurrence.validateRule(body.recurrenceRule);
            if (error) {
                throw new Error(`Invalid RRULE: ${error}`);
            }
        }

        return this.eventStorage.create({
            title: body.title,
            startTime: new Date(body.startTime),
            durationMin: body.durationMin,
            recurrenceRule: body.recurrenceRule ?? null,
            roomId: body.roomId ?? null,
            courseId: body.courseId ?? null,
            periodId: body.periodId ?? null,
            metadata: body.metadata ?? null,
        });
    }

    @Patch(':id')
    async updateEvent(@Param('id') id: string, @Body() body: Partial<TimetableEvent>) {
        return this.eventStorage.update(id, body);
    }

    @Delete(':id')
    async deleteEvent(@Param('id') id: string) {
        await this.eventStorage.delete(id);
    }

    @Post(':id/exceptions')
    async createException(
        @Param('id') eventId: string,
        @Body() body: {
            originalDate: string;
            type: 'cancelled' | 'modified' | 'added';
            newStartTime?: string | null;
            newDurationMin?: number | null;
            newRoomId?: string | null;
            metadata?: Record<string, unknown> | null;
        },
    ) {
        return this.eventStorage.createException({
            eventId,
            originalDate: new Date(body.originalDate),
            type: body.type,
            newStartTime: body.newStartTime ? new Date(body.newStartTime) : null,
            newDurationMin: body.newDurationMin ?? null,
            newRoomId: body.newRoomId ?? null,
            metadata: body.metadata ?? null,
        });
    }
}

Conflict Controller

// conflict.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { ConflictService } from '@hfu.digital/coursekit-nestjs';

@Controller('coursekit/conflicts')
export class ConflictController {
    constructor(private readonly conflicts: ConflictService) {}

    @Post('check')
    async checkConflicts(@Body() body: {
        event: {
            title: string;
            startTime: string;
            durationMin: number;
            recurrenceRule?: string | null;
            roomId?: string | null;
            courseId?: string | null;
            periodId?: string | null;
            metadata?: Record<string, unknown> | null;
        };
        dateRange: { start: string; end: string };
    }) {
        return this.conflicts.dryRun(
            {
                title: body.event.title,
                startTime: new Date(body.event.startTime),
                durationMin: body.event.durationMin,
                recurrenceRule: body.event.recurrenceRule ?? null,
                roomId: body.event.roomId ?? null,
                courseId: body.event.courseId ?? null,
                periodId: body.event.periodId ?? null,
                metadata: body.event.metadata ?? null,
            },
            {
                start: new Date(body.dateRange.start),
                end: new Date(body.dateRange.end),
            },
        );
    }
}

Domain Event Listener

// notification.service.ts
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import {
    DOMAIN_EVENTS,
    type EventCreatedPayload,
    type ConflictDetectedPayload,
} from '@hfu.digital/coursekit-nestjs';

@Injectable()
export class NotificationService {
    @OnEvent(DOMAIN_EVENTS.EVENT_CREATED)
    handleEventCreated(payload: EventCreatedPayload) {
        console.log('New event scheduled:', payload.event.title);
    }

    @OnEvent(DOMAIN_EVENTS.CONFLICT_DETECTED)
    handleConflict(payload: ConflictDetectedPayload) {
        const errors = payload.result.conflicts.filter(c => c.severity === 'error');
        if (errors.length > 0) {
            console.warn(`${errors.length} blocking conflicts for event ${payload.triggeringEventId}`);
        }
    }
}

On this page