From 085605f85c5fe9b218fffe8da97fbe97b53dfddc Mon Sep 17 00:00:00 2001 From: Roland Schneider Date: Thu, 20 Nov 2025 22:08:17 +0100 Subject: [PATCH] add calendar module --- server/api.http | 113 ++++++++++- server/package-lock.json | 18 ++ server/package.json | 2 + server/src/app.module.ts | 16 +- server/src/calendar/calendar.controller.ts | 86 ++++++++ server/src/calendar/calendar.module.ts | 14 ++ server/src/calendar/calendar.service.ts | 188 ++++++++++++++++++ server/src/calendar/dto/create-event.dto.ts | 74 +++++++ .../src/calendar/dto/create-exception.dto.ts | 31 +++ server/src/calendar/dto/get-calendar.dto.ts | 14 ++ server/src/data-source.ts | 19 +- server/src/entity/booking.entity.ts | 68 +++++++ server/src/entity/event-exception.entity.ts | 75 ++++--- server/src/entity/event-type.entity.ts | 11 +- server/src/entity/event.entity.ts | 92 +++++---- server/src/entity/recurrence-rule.entity.ts | 69 ++++--- .../1763106308123-add_booking_table.ts | 31 +++ .../recurrence-rules.service.ts | 5 +- 18 files changed, 789 insertions(+), 137 deletions(-) create mode 100644 server/src/calendar/calendar.controller.ts create mode 100644 server/src/calendar/calendar.module.ts create mode 100644 server/src/calendar/calendar.service.ts create mode 100644 server/src/calendar/dto/create-event.dto.ts create mode 100644 server/src/calendar/dto/create-exception.dto.ts create mode 100644 server/src/calendar/dto/get-calendar.dto.ts create mode 100644 server/src/entity/booking.entity.ts create mode 100644 server/src/migration/1763106308123-add_booking_table.ts diff --git a/server/api.http b/server/api.http index 665dc2d..9e252c9 100644 --- a/server/api.http +++ b/server/api.http @@ -39,9 +39,114 @@ Content-Type: application/json } -### GET +### GET request with parameter +GET {{apiBaseUrl}}/calendar/test?startDate=2011-10-05T14:48:00.000Z&endDate=2025-11-20T17:00:09.234Z +Accept: application/json +Authorization: Bearer {{auth_token}} -# curl -i http://httpbin.org/ip -GET http://httpbin.org/ip -### \ No newline at end of file +### GET request with parameter +GET {{apiBaseUrl}}/calendar/events/1 +Accept: application/json +Authorization: Bearer {{auth_token}} + + +### GET request with parameter +GET {{apiBaseUrl}}/calendar?startDate=2011-10-05T14:48:00.000Z&endDate=2025-12-20T17:00:09.234Z +Accept: application/json +Authorization: Bearer {{auth_token}} + + +### Post Creating a Single, Non-Recurring Event +POST {{apiBaseUrl}}/calendar/events +Accept: application/json +Content-Type: application/json +Authorization: Bearer {{auth_token}} + + +{ + "title": "Project Deadline Discussion", + "description": "Final review of the Q4 project deliverables.", + "startTime": "2025-11-28T14:00:00Z", + "endTime": "2025-11-28T15:30:00Z", + "timezone": "Europe/Berlin", + "isRecurring": false, + "eventTypeId": 1 +} + + +### Creating a Recurring Event +POST {{apiBaseUrl}}/calendar/events +Accept: application/json +Content-Type: application/json +Authorization: Bearer {{auth_token}} + + +{ + "title": "Daily Team Stand-up", + "startTime": "2025-12-01T09:00:00Z", + "endTime": "2025-12-01T09:15:00Z", + "timezone": "Europe/Budapest", + "isRecurring": true, + "recurrenceRule": { + "frequency": "WEEKLY", + "interval": 1, + "byDay": "MO,TU,WE,TH,FR", + "count": 10 + } +} + + +### Cancelling a Single Occurrence of a Recurring Event +POST {{apiBaseUrl}}/calendar/events/1/exceptions +Accept: application/json +Content-Type: application/json +Authorization: Bearer {{auth_token}} + +{ + "originalStartTime": "2025-12-03T09:00:00Z", + "isCancelled": true +} + +### Rescheduling/Modifying a Single Occurrence +POST {{apiBaseUrl}}/calendar/events/1/exceptions +Accept: application/json +Content-Type: application/json +Authorization: Bearer {{auth_token}} + +{ + "originalStartTime": "2025-12-05T09:00:00Z", + "title": "Daily Stand-up - Special Demo", + "newStartTime": "2025-12-05T15:00:00Z", + "newEndTime": "2025-12-05T15:30:00Z" +} + +### Rescheduling/Modifying a Single Occurrence +POST {{apiBaseUrl}}/calendar/events/1/exceptions +Accept: application/json +Content-Type: application/json +Authorization: Bearer {{auth_token}} + +{ + "originalStartTime": "2025-12-05T09:00:00Z", + "title": "Daily Stand-up - Special Demo", + "newStartTime": "2025-12-05T15:00:00Z", + "newEndTime": "2025-12-05T15:30:00Z" +} + + +### Updating an Entire Event Series +PATCH {{apiBaseUrl}}/calendar/events/1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer {{auth_token}} + +{ + "title": "Agile Team Sync" +} + +### Updating an Entire Event Series +DELETE {{apiBaseUrl}}/calendar/events/1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer {{auth_token}} diff --git a/server/package-lock.json b/server/package-lock.json index 92f420b..46af3b7 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -25,6 +25,7 @@ "passport-jwt": "^4.0.1", "pg": "^8.16.3", "reflect-metadata": "^0.2.2", + "rrule": "^2.8.1", "rxjs": "^7.8.1", "typeorm": "^0.3.27" }, @@ -39,6 +40,7 @@ "@types/jest": "^30.0.0", "@types/node": "^22.10.7", "@types/passport-jwt": "^4.0.0", + "@types/rrule": "^2.1.7", "@types/supertest": "^6.0.2", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", @@ -3168,6 +3170,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/rrule": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@types/rrule/-/rrule-2.1.7.tgz", + "integrity": "sha512-GoyBTiltOwZjHHIW3UoT2pZ+OWzIw/WcQB7bnu08/NKdoQgTWdVpM1hOUqmUmHERuOFkm8QCxpQlz/Mli7VewA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -9528,6 +9537,15 @@ "node": ">= 18" } }, + "node_modules/rrule": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz", + "integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", diff --git a/server/package.json b/server/package.json index ee36287..cbb8e45 100644 --- a/server/package.json +++ b/server/package.json @@ -39,6 +39,7 @@ "passport-jwt": "^4.0.1", "pg": "^8.16.3", "reflect-metadata": "^0.2.2", + "rrule": "^2.8.1", "rxjs": "^7.8.1", "typeorm": "^0.3.27" }, @@ -53,6 +54,7 @@ "@types/jest": "^30.0.0", "@types/node": "^22.10.7", "@types/passport-jwt": "^4.0.0", + "@types/rrule": "^2.1.7", "@types/supertest": "^6.0.2", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", diff --git a/server/src/app.module.ts b/server/src/app.module.ts index c4f1b20..4433da7 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -19,8 +19,10 @@ import { UserGroupsModule } from './user-group/user-group.module'; import { UserRolesModule } from './user-role/user-role.module'; import { RecurrenceRule } from './entity/recurrence-rule.entity'; import { RecurrenceRulesModule } from './recurrence-rule/recurrence-rules.module'; -import { EventException } from "./entity/event-exception.entity"; -import { EventExceptionsModule } from "./event-exception/event-exceptions.module"; +import { EventException } from './entity/event-exception.entity'; +import { EventExceptionsModule } from './event-exception/event-exceptions.module'; +import { CalendarModule } from './calendar/calendar.module'; +import { Booking } from './entity/booking.entity'; const moduleTypeOrm = TypeOrmModule.forRootAsync({ imports: [ConfigModule], @@ -41,8 +43,9 @@ const moduleTypeOrm = TypeOrmModule.forRootAsync({ Product, Event, RecurrenceRule, - EventException - ], + EventException, + Booking + ], logging: true, // synchronize: true, }; @@ -62,8 +65,9 @@ const moduleTypeOrm = TypeOrmModule.forRootAsync({ UserGroupsModule, UserRolesModule, RecurrenceRulesModule, - EventExceptionsModule - ], + EventExceptionsModule, + CalendarModule, + ], controllers: [AppController], providers: [AppService], }) diff --git a/server/src/calendar/calendar.controller.ts b/server/src/calendar/calendar.controller.ts new file mode 100644 index 0000000..316e481 --- /dev/null +++ b/server/src/calendar/calendar.controller.ts @@ -0,0 +1,86 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Patch, + Delete, + Query, + ParseIntPipe, + UseGuards, +} from '@nestjs/common'; +import { CalendarService } from './calendar.service'; +import { GetCalendarDto } from './dto/get-calendar.dto'; +import { CreateEventDto } from './dto/create-event.dto'; +import { CreateExceptionDto } from './dto/create-exception.dto'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RolesGuard } from '../auth/roles.guard'; +import { Roles } from '../auth/roles.decorator'; +import { Role } from '../auth/role.enum'; + +@Controller('calendar') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(Role.Admin) +export class CalendarController { + constructor(private readonly calendarService: CalendarService) {} + + @Get('test') + getCalendarTest( + @Query('startDate') startDate: string, + @Query('endDate') endDate: string, + ) { + console.log('--- TEST ENDPOINT ---'); + console.log('startDate received:', startDate, '| Type:', typeof startDate); + console.log('endDate received:', endDate, '| Type:', typeof endDate); + return { + message: 'Test successful. Check your server console logs.', + received: { + startDate, + endDate, + }, + }; + } + + // The primary endpoint to get event occurrences + @Get() + getCalendarEvents(@Query() getCalendarDto: GetCalendarDto) { + return this.calendarService.getEventsInRange( + getCalendarDto.startDate, + getCalendarDto.endDate, + ); + } + + // Standard CRUD endpoints for managing event entities + @Post('events') + createEvent(@Body() createEventDto: CreateEventDto) { + return this.calendarService.createEvent(createEventDto); + } + + @Get('events/:id') + getEventById(@Param('id', ParseIntPipe) id: number) { + return this.calendarService.getEventById(id); + } + + @Patch('events/:id') + updateEvent( + @Param('id', ParseIntPipe) id: number, + @Body() updateEventDto: CreateEventDto, + ) { + return this.calendarService.updateEvent(id, updateEventDto); + } + + @Delete('events/:id') + deleteEvent(@Param('id', ParseIntPipe) id: number) { + return this.calendarService.deleteEvent(id); + } + + // Endpoint for creating exceptions to a recurring event + @Post('events/:id/exceptions') + createException( + @Param('id', ParseIntPipe) eventId: number, + @Body() createExceptionDto: CreateExceptionDto, + ) { + return this.calendarService.createException(eventId, createExceptionDto); + } +} diff --git a/server/src/calendar/calendar.module.ts b/server/src/calendar/calendar.module.ts new file mode 100644 index 0000000..d3dddcb --- /dev/null +++ b/server/src/calendar/calendar.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CalendarService } from './calendar.service'; +import { CalendarController } from './calendar.controller'; +import { RecurrenceRule } from '../entity/recurrence-rule.entity'; +import { EventException } from '../entity/event-exception.entity'; +import { Event } from '../entity/event.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([RecurrenceRule, EventException, Event])], + controllers: [CalendarController], + providers: [CalendarService], +}) +export class CalendarModule {} diff --git a/server/src/calendar/calendar.service.ts b/server/src/calendar/calendar.service.ts new file mode 100644 index 0000000..269e9aa --- /dev/null +++ b/server/src/calendar/calendar.service.ts @@ -0,0 +1,188 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between } from 'typeorm'; +import { RRule, Weekday } from 'rrule'; // Corrected import + +import { Event } from '../entity/event.entity'; +import { EventException } from '../entity/event-exception.entity'; +import { RecurrenceRule } from '../entity/recurrence-rule.entity'; +import { CreateEventDto } from './dto/create-event.dto'; +import { CreateExceptionDto } from './dto/create-exception.dto'; + +type CalendarEventDto = Event & { + isModified?: boolean; +}; + +// --- Type-Safe Maps --- +const frequencyMap: Record = { + YEARLY: RRule.YEARLY, + MONTHLY: RRule.MONTHLY, + WEEKLY: RRule.WEEKLY, + DAILY: RRule.DAILY, +}; + +const weekdayMap: Record = { + SU: RRule.SU, + MO: RRule.MO, + TU: RRule.TU, + WE: RRule.WE, + TH: RRule.TH, + FR: RRule.FR, + SA: RRule.SA, +}; + +@Injectable() +export class CalendarService { + constructor( + @InjectRepository(Event) + private readonly eventRepository: Repository, + @InjectRepository(RecurrenceRule) + private readonly recurrenceRuleRepository: Repository, + @InjectRepository(EventException) + private readonly eventExceptionRepository: Repository, + ) {} + + async getEventsInRange(startDate: Date, endDate: Date): Promise { + // 1. Fetch single events + const singleEvents = await this.eventRepository.find({ + where: { + isRecurring: false, + startTime: Between(startDate, endDate), + }, + relations: ['eventType'], + }); + + // 2. Fetch recurring event templates + const recurringTemplates = await this.eventRepository.find({ + where: { isRecurring: true }, + relations: ['recurrenceRule', 'exceptions', 'eventType'], + }); + + const recurringOccurrences: CalendarEventDto[] = []; + + // 3. Expand recurring events + for (const event of recurringTemplates) { + if (!event.recurrenceRule) continue; + + const duration = event.endTime.getTime() - event.startTime.getTime(); + + const freq = frequencyMap[event.recurrenceRule.frequency]; + const byweekday = event.recurrenceRule.byDay + ?.split(',') + .map((day) => weekdayMap[day]) + .filter((day): day is Weekday => !!day); + + if (freq === undefined) { + console.error( + `Invalid frequency for event ID ${event.id}: ${event.recurrenceRule.frequency}`, + ); + continue; + } + + const rrule = new RRule({ + freq: freq, + interval: event.recurrenceRule.interval, + dtstart: event.startTime, + until: event.recurrenceRule.endDate, + count: event.recurrenceRule.count, + byweekday: byweekday?.length > 0 ? byweekday : undefined, + }); + + const occurrences = rrule.between(startDate, endDate); + + for (const occurrenceDate of occurrences) { + const exception = event.exceptions.find( + (ex) => ex.originalStartTime.getTime() === occurrenceDate.getTime(), + ); + + if (exception) { + if (exception.isCancelled) { + continue; + } + recurringOccurrences.push({ + ...event, + id: event.id, + title: exception.title || event.title, + description: exception.description || event.description, + startTime: exception.newStartTime, + endTime: exception.newEndTime, + isModified: true, + }); + } else { + recurringOccurrences.push({ + ...event, + startTime: occurrenceDate, + endTime: new Date(occurrenceDate.getTime() + duration), + }); + } + } + } + + const allEvents = [...singleEvents, ...recurringOccurrences]; + return allEvents.sort( + (a, b) => a.startTime.getTime() - b.startTime.getTime(), + ); + } + + // --- Other service methods (createEvent, etc.) remain unchanged --- + + async createEvent(createEventDto: CreateEventDto): Promise { + const { recurrenceRule, ...eventData } = createEventDto; + + const event = this.eventRepository.create(eventData); + const savedEvent = await this.eventRepository.save(event); + + if (event.isRecurring && recurrenceRule) { + const rule = this.recurrenceRuleRepository.create({ + ...recurrenceRule, + event: savedEvent, + }); + await this.recurrenceRuleRepository.save(rule); + } + + return this.getEventById(savedEvent.id); + } + + async createException( + eventId: number, + createExceptionDto: CreateExceptionDto, + ): Promise { + const event = await this.eventRepository.findOneBy({ id: eventId }); + if (!event) { + throw new NotFoundException(`Event with ID ${eventId} not found`); + } + + const exception = this.eventExceptionRepository.create({ + ...createExceptionDto, + event: event, + }); + + return this.eventExceptionRepository.save(exception); + } + + async getEventById(id: number): Promise { + const event = await this.eventRepository.findOne({ + where: { id }, + relations: ['recurrenceRule', 'exceptions', 'eventType'], + }); + if (!event) { + throw new NotFoundException(`Event with ID ${id} not found`); + } + return event; + } + + async updateEvent( + id: number, + updateEventDto: CreateEventDto, + ): Promise { + await this.eventRepository.update(id, updateEventDto); + return this.getEventById(id); + } + + async deleteEvent(id: number): Promise { + const result = await this.eventRepository.delete(id); + if (result.affected === 0) { + throw new NotFoundException(`Event with ID ${id} not found.`); + } + } +} diff --git a/server/src/calendar/dto/create-event.dto.ts b/server/src/calendar/dto/create-event.dto.ts new file mode 100644 index 0000000..42f2bc8 --- /dev/null +++ b/server/src/calendar/dto/create-event.dto.ts @@ -0,0 +1,74 @@ +import { + IsString, + IsNotEmpty, + IsOptional, + IsBoolean, + IsDate, + ValidateNested, + IsInt, + IsIn, + Min, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +// DTO for the recurrence rule part +export class RecurrenceRuleDto { + @IsNotEmpty() + @IsIn(['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY']) + frequency: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY'; + + @IsInt() + @Min(1) + interval: number; + + @IsOptional() + @IsString() + byDay?: string; // e.g., 'MO,TU,WE,TH,FR' + + @IsOptional() + @IsDate() + @Type(() => Date) + endDate?: Date; + + @IsOptional() + @IsInt() + @Min(1) + count?: number; +} + +export class CreateEventDto { + @IsNotEmpty() + @IsString() + title: string; + + @IsOptional() + @IsString() + description?: string; + + @IsNotEmpty() + @IsDate() + @Type(() => Date) + startTime: Date; + + @IsNotEmpty() + @IsDate() + @Type(() => Date) + endTime: Date; + + @IsNotEmpty() + @IsString() + timezone: string; // e.g., 'Europe/Berlin' + + @IsOptional() + @IsInt() + eventTypeId?: number; + + @IsOptional() + @IsBoolean() + isRecurring?: boolean; + + @IsOptional() + @ValidateNested() + @Type(() => RecurrenceRuleDto) + recurrenceRule?: RecurrenceRuleDto; +} \ No newline at end of file diff --git a/server/src/calendar/dto/create-exception.dto.ts b/server/src/calendar/dto/create-exception.dto.ts new file mode 100644 index 0000000..ae03334 --- /dev/null +++ b/server/src/calendar/dto/create-exception.dto.ts @@ -0,0 +1,31 @@ +import { IsDate, IsNotEmpty, IsOptional, IsString, IsBoolean } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class CreateExceptionDto { + @IsNotEmpty() + @IsDate() + @Type(() => Date) + originalStartTime: Date; // The start time of the instance to modify/cancel + + @IsOptional() + @IsBoolean() + isCancelled?: boolean; + + @IsOptional() + @IsDate() + @Type(() => Date) + newStartTime?: Date; + + @IsOptional() + @IsDate() + @Type(() => Date) + newEndTime?: Date; + + @IsOptional() + @IsString() + title?: string; + + @IsOptional() + @IsString() + description?: string; +} \ No newline at end of file diff --git a/server/src/calendar/dto/get-calendar.dto.ts b/server/src/calendar/dto/get-calendar.dto.ts new file mode 100644 index 0000000..bc229d9 --- /dev/null +++ b/server/src/calendar/dto/get-calendar.dto.ts @@ -0,0 +1,14 @@ +import { IsDate, IsNotEmpty } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class GetCalendarDto { + @IsNotEmpty() + @Type(() => Date) + @IsDate() + startDate: Date; + + @IsNotEmpty() + @IsDate() + @Type(() => Date) + endDate: Date; +} \ No newline at end of file diff --git a/server/src/data-source.ts b/server/src/data-source.ts index aa87ee0..bf734ac 100644 --- a/server/src/data-source.ts +++ b/server/src/data-source.ts @@ -6,6 +6,11 @@ import * as dotenv from 'dotenv'; import { UserGroup } from './entity/user-group'; import { UserRole } from './entity/user-role'; import { EventType } from './entity/event-type.entity'; +import { Event } from './entity/event.entity'; +import { EventException } from './entity/event-exception.entity'; +import { RecurrenceRule } from './entity/recurrence-rule.entity'; +import { Product } from './entity/product.entity'; +import { Booking } from './entity/booking.entity'; dotenv.config(); @@ -18,9 +23,17 @@ export const AppDataSource = new DataSource({ database: process.env.DATABASE_NAME, synchronize: false, logging: false, - entities: [User, UserGroup, UserRole, EventType], - migrations: [ - 'src/migration/**/*.ts' + entities: [ + User, + UserGroup, + UserRole, + EventType, + Event, + EventException, + RecurrenceRule, + Product, + Booking ], + migrations: ['src/migration/**/*.ts'], subscribers: [], }); diff --git a/server/src/entity/booking.entity.ts b/server/src/entity/booking.entity.ts new file mode 100644 index 0000000..5f4845d --- /dev/null +++ b/server/src/entity/booking.entity.ts @@ -0,0 +1,68 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; +import { Event } from './event.entity'; +import { User } from './user'; + +@Entity('bookings') +@Index(['eventId', 'occurrenceStartTime']) // Index for fast lookups +export class Booking { + @PrimaryGeneratedColumn({ type: 'bigint' }) + id: number; + + @Column({ name: 'occurrence_start_time', type: 'timestamptz' }) + occurrenceStartTime: Date; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'reserved_seats_count', type: 'integer', default: 1 }) + reservedSeatsCount: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp' }) + updatedAt: Date; + + // --- Cancellation Fields --- + @Column({ name: 'canceled_at', type: 'timestamp', nullable: true }) + canceledAt: Date | null; + + @Column({ + name: 'canceled_reason', + type: 'varchar', + length: 50, + nullable: true, + }) + canceledReason: string | null; + + // --- Relationships --- + @Column({ name: 'event_id', type: 'bigint', nullable: true }) + eventId: number | null; + + @Column({ name: 'user_id', type: 'bigint', nullable: true }) + userId: number | null; + + @Column({ name: 'canceled_by_user_id', type: 'bigint', nullable: true }) + canceledByUserId: number | null; + + @ManyToOne(() => Event, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'event_id' }) + event: Event; + + @ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'canceled_by_user_id' }) + canceledByUser: User; +} diff --git a/server/src/entity/event-exception.entity.ts b/server/src/entity/event-exception.entity.ts index 5a45ea3..02cf44f 100644 --- a/server/src/entity/event-exception.entity.ts +++ b/server/src/entity/event-exception.entity.ts @@ -1,52 +1,47 @@ -// dvbooking-cli/src/templates/nestjs/entity.ts.tpl - -import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; import { - IsString, - IsNumber, - IsBoolean, - IsDate, - IsOptional, -} from 'class-validator'; + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Event } from './event.entity'; -@Entity({ name: 'event_exceptions' }) +@Entity('event_exceptions') export class EventException { - @PrimaryGeneratedColumn() + @PrimaryGeneratedColumn({ type: 'bigint' }) id: number; - @Column() - @IsNumber() - event_id: number; + @Column({ name: 'original_start_time', type: 'timestamptz' }) + originalStartTime: Date; - @Column() - @IsDate() - original_start_time: Date; + @Column({ name: 'is_cancelled', type: 'boolean', default: false }) + isCancelled: boolean; - @Column({ default: false }) - @IsBoolean() - is_cancelled: boolean = false; + @Column({ name: 'new_start_time', type: 'timestamptz', nullable: true }) + newStartTime: Date; - @Column({ type: 'timestamp with time zone', nullable: true }) - @IsOptional() - @IsDate() - new_start_time: Date | null; + @Column({ name: 'new_end_time', type: 'timestamptz', nullable: true }) + newEndTime: Date; - @Column({ type: 'timestamp with time zone', nullable: true }) - @IsOptional() - @IsDate() - new_end_time: Date | null; + @Column({ name: 'title', type: 'varchar', length: 255, nullable: true }) + title: string; - @Column({ type: 'character varying', nullable: true }) - @IsOptional() - @IsString() - title: string | null; + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; - @Column({ type: 'text', nullable: true }) - @IsOptional() - @IsString() - description: string | null; + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; - @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) - @IsDate() - created_at: Date; -} + // --- Relationships --- + + @Column({ name: 'event_id', type: 'bigint' }) + eventId: number; + + @ManyToOne(() => Event, (event) => event.exceptions, { + onDelete: 'CASCADE', // If the parent event is deleted, delete its exceptions + }) + @JoinColumn({ name: 'event_id' }) + event: Event; +} \ No newline at end of file diff --git a/server/src/entity/event-type.entity.ts b/server/src/entity/event-type.entity.ts index ad419b0..166e2b6 100644 --- a/server/src/entity/event-type.entity.ts +++ b/server/src/entity/event-type.entity.ts @@ -1,10 +1,8 @@ // dvbooking-cli/src/templates/nestjs/entity.ts.tpl -import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; -import { - IsString, - IsOptional, -} from 'class-validator'; +import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm'; +import { Event } from './event.entity'; +import { IsString, IsOptional } from 'class-validator'; @Entity({ name: 'event_type' }) export class EventType { @@ -24,4 +22,7 @@ export class EventType { @IsOptional() @IsString() color: string | null; + + @OneToMany(() => Event, (event) => event.eventType) + events: Event[]; } diff --git a/server/src/entity/event.entity.ts b/server/src/entity/event.entity.ts index cc8e4f6..1d822f4 100644 --- a/server/src/entity/event.entity.ts +++ b/server/src/entity/event.entity.ts @@ -1,54 +1,66 @@ -// dvbooking-cli/src/templates/nestjs/entity.ts.tpl - -import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; import { - IsString, - IsNumber, - IsBoolean, - IsDate, - IsOptional, -} from 'class-validator'; + Column, + CreateDateColumn, + Entity, + ManyToOne, + OneToMany, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, + JoinColumn, +} from 'typeorm'; +import { EventType } from './event-type.entity'; +import { RecurrenceRule } from './recurrence-rule.entity'; +import { EventException } from './event-exception.entity'; -@Entity({ name: 'events' }) +@Entity('events') export class Event { - @PrimaryGeneratedColumn() + @PrimaryGeneratedColumn({ type: 'bigint' }) id: number; - @Column({ type: 'bigint', nullable: true }) - @IsOptional() - @IsNumber() - event_type_id: number | null; - - @Column() - @IsString() + @Column({ name: 'title', type: 'varchar', length: 255 }) title: string; - @Column({ type: 'text', nullable: true }) - @IsOptional() - @IsString() - description: string | null; + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; - @Column() - @IsDate() - start_time: Date; + @Column({ name: 'start_time', type: 'timestamptz' }) + startTime: Date; - @Column() - @IsDate() - end_time: Date; + @Column({ name: 'end_time', type: 'timestamptz' }) + endTime: Date; - @Column() - @IsString() + @Column({ name: 'timezone', type: 'varchar', length: 50 }) timezone: string; - @Column({ default: false }) - @IsBoolean() - is_recurring: boolean = false; + @Column({ name: 'is_recurring', type: 'boolean', default: false }) + isRecurring: boolean; - @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) - @IsDate() - created_at: Date; + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; - @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) - @IsDate() - updated_at: Date; -} + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp' }) + updatedAt: Date; + + // --- Relationships --- + + @Column({ name: 'event_type_id', type: 'bigint', nullable: true }) + eventTypeId: number; + + @ManyToOne(() => EventType, (eventType) => eventType.events, { + nullable: true, + onDelete: 'SET NULL', // As requested for optional relationship + }) + @JoinColumn({ name: 'event_type_id' }) + eventType: EventType; + + @OneToOne(() => RecurrenceRule, (rule) => rule.event, { + cascade: true, // Automatically save/update recurrence rule when event is saved + }) + recurrenceRule: RecurrenceRule; + + @OneToMany(() => EventException, (exception) => exception.event, { + cascade: true, // Automatically save/update exceptions when event is saved + }) + exceptions: EventException[]; +} \ No newline at end of file diff --git a/server/src/entity/recurrence-rule.entity.ts b/server/src/entity/recurrence-rule.entity.ts index 7ecbb57..5251863 100644 --- a/server/src/entity/recurrence-rule.entity.ts +++ b/server/src/entity/recurrence-rule.entity.ts @@ -1,47 +1,46 @@ -// dvbooking-cli/src/templates/nestjs/entity.ts.tpl +import { + Column, + Entity, + JoinColumn, + OneToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Event } from './event.entity'; -import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; -import { IsString, IsNumber, IsDate, IsOptional } from 'class-validator'; - -@Entity({ name: 'recurrence_rules' }) +@Entity('recurrence_rules') export class RecurrenceRule { - @PrimaryGeneratedColumn() + @PrimaryGeneratedColumn({ type: 'bigint' }) id: number; - @Column() - @IsNumber() - event_id: number; + @Column({ name: 'frequency', type: 'varchar', length: 10 }) + frequency: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY'; - @Column() - @IsString() - frequency: string; + @Column({ name: 'interval', type: 'integer', default: 1 }) + interval: number; - @Column({ default: '1' }) - @IsNumber() - interval: number = 1; + @Column({ name: 'end_date', type: 'date', nullable: true }) + endDate: Date; - @Column({ type: 'date', nullable: true }) - @IsOptional() - @IsDate() - end_date: Date | null; + @Column({ name: 'count', type: 'integer', nullable: true }) + count: number; - @Column({ type: 'integer', nullable: true }) - @IsOptional() - @IsNumber() - count: number | null; + @Column({ name: 'by_day', type: 'varchar', length: 20, nullable: true }) + byDay: string; // e.g., 'MO,TU,WE' - @Column({ type: 'character varying', nullable: true }) - @IsOptional() - @IsString() - by_day: string | null; + @Column({ name: 'by_month_day', type: 'integer', nullable: true }) + byMonthDay: number; - @Column({ type: 'integer', nullable: true }) - @IsOptional() - @IsNumber() - by_month_day: number | null; + @Column({ name: 'by_month', type: 'integer', nullable: true }) + byMonth: number; - @Column({ type: 'integer', nullable: true }) - @IsOptional() - @IsNumber() - by_month: number | null; + // --- Relationships --- + + @Column({ name: 'event_id', type: 'bigint' }) + eventId: number; + + @OneToOne(() => Event, (event) => event.recurrenceRule, { + onDelete: 'CASCADE', // If the parent event is deleted, delete the rule too + }) + @JoinColumn({ name: 'event_id' }) + event: Event; } diff --git a/server/src/migration/1763106308123-add_booking_table.ts b/server/src/migration/1763106308123-add_booking_table.ts new file mode 100644 index 0000000..ed665e2 --- /dev/null +++ b/server/src/migration/1763106308123-add_booking_table.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBookingTable1763106308123 implements MigrationInterface { + name = 'AddBookingTable1763106308123'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE bookings ( + id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + event_id BIGINT, + occurrence_start_time TIMESTAMP WITH TIME ZONE NOT NULL, + user_id BIGINT, + notes TEXT, + reserved_seats_count INTEGER NOT NULL default 1, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + canceled_at TIMESTAMP NULL, + canceled_reason varchar(50) NULL, + canceled_by_user_id BIGINT NULL, + constraint fk_booking_event FOREIGN KEY (event_id) REFERENCES events (id) ON DELETE SET NULL, + constraint fk_booking_user FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE SET NULL, + constraint fk_booking_cancel_user FOREIGN KEY (canceled_by_user_id) REFERENCES "user" (id) ON DELETE SET NULL + );`, + ); + + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "bookings"`); + } +} diff --git a/server/src/recurrence-rule/recurrence-rules.service.ts b/server/src/recurrence-rule/recurrence-rules.service.ts index 17b6f2b..e0aad5c 100644 --- a/server/src/recurrence-rule/recurrence-rules.service.ts +++ b/server/src/recurrence-rule/recurrence-rules.service.ts @@ -22,10 +22,7 @@ export class RecurrenceRulesService { private readonly recurrenceRuleRepository: Repository, ) {} - private readonly searchableFields: (keyof RecurrenceRule)[] = [ - 'frequency', - 'by_day', - ]; + private readonly searchableFields: (keyof RecurrenceRule)[] = [ ]; create(createRecurrenceRuleDto: CreateRecurrenceRuleDto) { const newRecord = this.recurrenceRuleRepository.create(