From 02cad3dbcda296c275bf77714f0fbe5f7167cb91 Mon Sep 17 00:00:00 2001 From: Roland Schneider Date: Thu, 20 Nov 2025 22:55:31 +0100 Subject: [PATCH] add booking --- admin/src/app/app.routes.ts | 46 ++- .../booking-details.component.html | 74 +++++ .../booking-details.component.ts | 34 +++ .../booking-filter.component.html | 15 + .../booking-filter.component.ts | 38 +++ .../booking-form/booking-form.component.html | 52 ++++ .../booking-form/booking-form.component.ts | 94 ++++++ .../booking-list/booking-list.component.html | 78 +++++ .../booking-list/booking-list.component.ts | 70 +++++ .../booking-data-provider.service.ts | 26 ++ .../booking-table.component.html | 11 + .../booking-table/booking-table.component.ts | 145 +++++++++ .../features/bookings/models/booking.model.ts | 16 + .../bookings/services/booking.service.ts | 85 ++++++ server/src/app.module.ts | 4 +- server/src/booking/bookings.controller.ts | 63 ++++ server/src/booking/bookings.module.ts | 12 + server/src/booking/bookings.service.ts | 128 ++++++++ server/src/booking/dto/create-booking.dto.ts | 4 + server/src/booking/dto/query-booking.dto.ts | 9 + server/src/booking/dto/update-booking.dto.ts | 4 + server/src/calendar/calendar.controller.ts | 20 ++ server/src/calendar/calendar.module.ts | 5 +- server/src/calendar/calendar.service.ts | 283 +++++++++++++++--- server/src/calendar/dto/cancel-booking.dto.ts | 18 ++ server/src/calendar/dto/create-booking.dto.ts | 29 ++ server/src/entity/event.entity.ts | 8 +- 27 files changed, 1330 insertions(+), 41 deletions(-) create mode 100644 admin/src/app/features/bookings/components/booking-details/booking-details.component.html create mode 100644 admin/src/app/features/bookings/components/booking-details/booking-details.component.ts create mode 100644 admin/src/app/features/bookings/components/booking-filter/booking-filter.component.html create mode 100644 admin/src/app/features/bookings/components/booking-filter/booking-filter.component.ts create mode 100644 admin/src/app/features/bookings/components/booking-form/booking-form.component.html create mode 100644 admin/src/app/features/bookings/components/booking-form/booking-form.component.ts create mode 100644 admin/src/app/features/bookings/components/booking-list/booking-list.component.html create mode 100644 admin/src/app/features/bookings/components/booking-list/booking-list.component.ts create mode 100644 admin/src/app/features/bookings/components/booking-table/booking-data-provider.service.ts create mode 100644 admin/src/app/features/bookings/components/booking-table/booking-table.component.html create mode 100644 admin/src/app/features/bookings/components/booking-table/booking-table.component.ts create mode 100644 admin/src/app/features/bookings/models/booking.model.ts create mode 100644 admin/src/app/features/bookings/services/booking.service.ts create mode 100644 server/src/booking/bookings.controller.ts create mode 100644 server/src/booking/bookings.module.ts create mode 100644 server/src/booking/bookings.service.ts create mode 100644 server/src/booking/dto/create-booking.dto.ts create mode 100644 server/src/booking/dto/query-booking.dto.ts create mode 100644 server/src/booking/dto/update-booking.dto.ts create mode 100644 server/src/calendar/dto/cancel-booking.dto.ts create mode 100644 server/src/calendar/dto/create-booking.dto.ts diff --git a/admin/src/app/app.routes.ts b/admin/src/app/app.routes.ts index 676c0fa..33aa054 100644 --- a/admin/src/app/app.routes.ts +++ b/admin/src/app/app.routes.ts @@ -33,9 +33,53 @@ import { } from './features/user-role/components/user-role-details/user-role-details.component'; import { UserRoleTableComponent } from './features/user-role/components/user-role-table/user-role-table.component'; import { UserRoleListComponent } from './features/user-role/components/user-role-list/user-role-list.component'; +import { BookingFormComponent } from "./features/bookings/components/booking-form/booking-form.component"; +import { BookingDetailsComponent } from "./features/bookings/components/booking-details/booking-details.component"; +import { BookingTableComponent } from "./features/bookings/components/booking-table/booking-table.component"; +import { BookingListComponent } from "./features/bookings/components/booking-list/booking-list.component"; export const routes: Routes = [ - { + { + path: 'bookings/new', + component: BookingFormComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'bookings', + component: BookingListComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'bookings/table', + component: BookingTableComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'bookings/:id', + component: BookingDetailsComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'bookings/:id/edit', + component: BookingFormComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { path: 'user-role/new', component: UserRoleFormComponent, canActivate: [AuthGuard], diff --git a/admin/src/app/features/bookings/components/booking-details/booking-details.component.html b/admin/src/app/features/bookings/components/booking-details/booking-details.component.html new file mode 100644 index 0000000..6f8bbf9 --- /dev/null +++ b/admin/src/app/features/bookings/components/booking-details/booking-details.component.html @@ -0,0 +1,74 @@ + + + +
+ +
+
+

Booking Details

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
id{{ booking.id }}
event_id{{ booking.event_id }}
occurrence_start_time{{ booking.occurrence_start_time }}
user_id{{ booking.user_id }}
notes{{ booking.notes }}
reserved_seats_count{{ booking.reserved_seats_count }}
created_at{{ booking.created_at }}
updated_at{{ booking.updated_at }}
canceled_at{{ booking.canceled_at }}
canceled_reason{{ booking.canceled_reason }}
canceled_by_user_id{{ booking.canceled_by_user_id }}
+
+ + +
+
+
+ + +
+ +
+
+
\ No newline at end of file diff --git a/admin/src/app/features/bookings/components/booking-details/booking-details.component.ts b/admin/src/app/features/bookings/components/booking-details/booking-details.component.ts new file mode 100644 index 0000000..8ab224d --- /dev/null +++ b/admin/src/app/features/bookings/components/booking-details/booking-details.component.ts @@ -0,0 +1,34 @@ +// dvbooking-cli/src/templates/angular/details.component.ts.tpl + +// Generated by the CLI +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, RouterModule } from '@angular/router'; +import { Observable } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { Booking } from '../../models/booking.model'; +import { BookingService } from '../../services/booking.service'; + +@Component({ + selector: 'app-booking-details', + templateUrl: './booking-details.component.html', + standalone: true, + imports: [CommonModule, RouterModule], +}) +export class BookingDetailsComponent implements OnInit { + booking$!: Observable; + + constructor( + private route: ActivatedRoute, + private bookingService: BookingService + ) {} + + ngOnInit(): void { + this.booking$ = this.route.params.pipe( + switchMap(params => { + const id = params['id']; + return this.bookingService.findOne(id); + }) + ); + } +} \ No newline at end of file diff --git a/admin/src/app/features/bookings/components/booking-filter/booking-filter.component.html b/admin/src/app/features/bookings/components/booking-filter/booking-filter.component.html new file mode 100644 index 0000000..7ed3952 --- /dev/null +++ b/admin/src/app/features/bookings/components/booking-filter/booking-filter.component.html @@ -0,0 +1,15 @@ + + +
+
+
+
+ +
+
+ +
+ +
+
+
\ No newline at end of file diff --git a/admin/src/app/features/bookings/components/booking-filter/booking-filter.component.ts b/admin/src/app/features/bookings/components/booking-filter/booking-filter.component.ts new file mode 100644 index 0000000..cbbdeec --- /dev/null +++ b/admin/src/app/features/bookings/components/booking-filter/booking-filter.component.ts @@ -0,0 +1,38 @@ +// dvbooking-cli/src/templates/angular/filter.component.ts.tpl + +// Generated by the CLI +import { Component, EventEmitter, Output } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; + +@Component({ + selector: 'app-booking-filter', + templateUrl: './booking-filter.component.html', + standalone: true, + imports: [ReactiveFormsModule] +}) +export class BookingFilterComponent { + @Output() filterChanged = new EventEmitter(); + filterForm: FormGroup; + + constructor(private fb: FormBuilder) { + this.filterForm = this.fb.group({ + notes: [''], + canceled_reason: [''] + }); + + this.filterForm.valueChanges.pipe( + debounceTime(300), + distinctUntilChanged() + ).subscribe(values => { + const cleanFilter = Object.fromEntries( + Object.entries(values).filter(([_, v]) => v != null && v !== '') + ); + this.filterChanged.emit(cleanFilter); + }); + } + + reset() { + this.filterForm.reset(); + } +} \ No newline at end of file diff --git a/admin/src/app/features/bookings/components/booking-form/booking-form.component.html b/admin/src/app/features/bookings/components/booking-form/booking-form.component.html new file mode 100644 index 0000000..debb669 --- /dev/null +++ b/admin/src/app/features/bookings/components/booking-form/booking-form.component.html @@ -0,0 +1,52 @@ + + + +
+
+
+

+ {{ isEditMode ? 'Edit' : 'Create' }} Booking +

+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ Cancel + +
+
+
+
+
\ No newline at end of file diff --git a/admin/src/app/features/bookings/components/booking-form/booking-form.component.ts b/admin/src/app/features/bookings/components/booking-form/booking-form.component.ts new file mode 100644 index 0000000..fbc2794 --- /dev/null +++ b/admin/src/app/features/bookings/components/booking-form/booking-form.component.ts @@ -0,0 +1,94 @@ +// dvbooking-cli/src/templates/angular/form.component.ts.tpl + +// Generated by the CLI +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { Observable, of } from 'rxjs'; +import { switchMap, tap } from 'rxjs/operators'; +import { Booking } from '../../models/booking.model'; +import { BookingService } from '../../services/booking.service'; + +@Component({ + selector: 'app-booking-form', + templateUrl: './booking-form.component.html', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, RouterModule], +}) +export class BookingFormComponent implements OnInit { + form: FormGroup; + isEditMode = false; + id: number | null = null; + + private numericFields = ["event_id","user_id","reserved_seats_count","canceled_by_user_id"]; + + constructor( + private fb: FormBuilder, + private route: ActivatedRoute, + private router: Router, + private bookingService: BookingService + ) { + this.form = this.fb.group({ + event_id: [null], + occurrence_start_time: [null], + user_id: [null], + notes: [null], + reserved_seats_count: [null], + created_at: [null], + updated_at: [null], + canceled_at: [null], + canceled_reason: [null], + canceled_by_user_id: [null] + }); + } + + ngOnInit(): void { + this.route.params.pipe( + tap(params => { + if (params['id']) { + this.isEditMode = true; + this.id = +params['id']; + } + }), + switchMap(() => { + if (this.isEditMode && this.id) { + return this.bookingService.findOne(this.id); + } + return of(null); + }) + ).subscribe(booking => { + if (booking) { + this.form.patchValue(booking); + } + }); + } + + onSubmit(): void { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + + const payload = { ...this.form.value }; + + for (const field of this.numericFields) { + if (payload[field] != null && payload[field] !== '') { + payload[field] = parseFloat(payload[field]); + } + } + + let action$: Observable; + + if (this.isEditMode && this.id) { + action$ = this.bookingService.update(this.id, payload); + } else { + action$ = this.bookingService.create(payload); + } + + action$.subscribe({ + next: () => this.router.navigate(['/bookings']), + error: (err) => console.error('Failed to save booking', err) + }); + } +} \ No newline at end of file diff --git a/admin/src/app/features/bookings/components/booking-list/booking-list.component.html b/admin/src/app/features/bookings/components/booking-list/booking-list.component.html new file mode 100644 index 0000000..b88b08a --- /dev/null +++ b/admin/src/app/features/bookings/components/booking-list/booking-list.component.html @@ -0,0 +1,78 @@ + + + +
+
+

Bookings

+ Create New +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
idevent_idoccurrence_start_timeuser_idnotesreserved_seats_countcreated_atupdated_atcanceled_atcanceled_reasoncanceled_by_user_idActions
{{ item.id }}{{ item.event_id }}{{ item.occurrence_start_time }}{{ item.user_id }}{{ item.notes }}{{ item.reserved_seats_count }}{{ item.created_at }}{{ item.updated_at }}{{ item.canceled_at }}{{ item.canceled_reason }}{{ item.canceled_by_user_id }} + View + Edit + +
No bookings found.
+
+ + +
+
+ + + +
+
+
+ + +
+ +
+
+
\ No newline at end of file diff --git a/admin/src/app/features/bookings/components/booking-list/booking-list.component.ts b/admin/src/app/features/bookings/components/booking-list/booking-list.component.ts new file mode 100644 index 0000000..4650eba --- /dev/null +++ b/admin/src/app/features/bookings/components/booking-list/booking-list.component.ts @@ -0,0 +1,70 @@ +// dvbooking-cli/src/templates/angular/list.component.ts.tpl + +// Generated by the CLI +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { BehaviorSubject, Observable, combineLatest } from 'rxjs'; +import { switchMap, startWith } from 'rxjs/operators'; +import { Booking } from '../../models/booking.model'; +import { BookingService } from '../../services/booking.service'; +import { BookingFilterComponent } from '../booking-filter/booking-filter.component'; +import { PaginatedResponse } from '../../../../../types'; + + +@Component({ + selector: 'app-booking-list', + templateUrl: './booking-list.component.html', + standalone: true, + imports: [CommonModule,RouterModule, BookingFilterComponent], +}) +export class BookingListComponent implements OnInit { + + private refresh$ = new BehaviorSubject(undefined); + private filter$ = new BehaviorSubject({}); + private page$ = new BehaviorSubject(1); + + paginatedResponse$!: Observable>; + + constructor(private bookingService: BookingService) { } + + ngOnInit(): void { + this.paginatedResponse$ = combineLatest([ + this.refresh$, + this.filter$.pipe(startWith({})), + this.page$.pipe(startWith(1)) + ]).pipe( + switchMap(([_, filter, page]) => { + const query = { ...filter, page, limit: 10 }; + return this.bookingService.find(query); + }) + ); + } + + onFilterChanged(filter: any): void { + this.page$.next(1); + this.filter$.next(filter); + } + + changePage(newPage: number): void { + if (newPage > 0) { + this.page$.next(newPage); + } + } + + deleteItem(id: number): void { + if (confirm('Are you sure you want to delete this item?')) { + this.bookingService.remove(id).subscribe({ + next: () => { + console.log(`Item with ID ${id} deleted successfully.`); + this.refresh$.next(); + }, + // --- THIS IS THE FIX --- + // Explicitly type 'err' to satisfy strict TypeScript rules. + error: (err: any) => { + console.error(`Error deleting item with ID ${id}:`, err); + } + }); + } + } +} \ No newline at end of file diff --git a/admin/src/app/features/bookings/components/booking-table/booking-data-provider.service.ts b/admin/src/app/features/bookings/components/booking-table/booking-data-provider.service.ts new file mode 100644 index 0000000..ac9b62e --- /dev/null +++ b/admin/src/app/features/bookings/components/booking-table/booking-data-provider.service.ts @@ -0,0 +1,26 @@ +// dvbooking-cli/src/templates/angular-generic/data-provider.service.ts.tpl + +// Generated by the CLI +import { inject, Injectable } from '@angular/core'; +import { DataProvider, GetDataOptions, GetDataResponse } from '../../../../components/generic-table/data-provider.interface'; +import { Booking } from '../../models/booking.model'; +import { map, Observable } from 'rxjs'; +import { BookingService } from '../../services/booking.service'; + +@Injectable({ + providedIn: 'root', +}) +export class BookingDataProvider implements DataProvider { + private bookingService = inject(BookingService); + + getData(options?: GetDataOptions): Observable> { + const {q,page,limit} = options?.params ?? {}; + // The generic table's params are compatible with our NestJS Query DTO + return this.bookingService.search(q ?? '',page,limit, ).pipe( + map((res) => { + // Adapt the paginated response to the GetDataResponse format + return { data: res }; + }) + ); + } +} \ No newline at end of file diff --git a/admin/src/app/features/bookings/components/booking-table/booking-table.component.html b/admin/src/app/features/bookings/components/booking-table/booking-table.component.html new file mode 100644 index 0000000..6376309 --- /dev/null +++ b/admin/src/app/features/bookings/components/booking-table/booking-table.component.html @@ -0,0 +1,11 @@ + + + +
+
+

Bookings (Generic Table)

+ Create New +
+ + +
\ No newline at end of file diff --git a/admin/src/app/features/bookings/components/booking-table/booking-table.component.ts b/admin/src/app/features/bookings/components/booking-table/booking-table.component.ts new file mode 100644 index 0000000..67e81f8 --- /dev/null +++ b/admin/src/app/features/bookings/components/booking-table/booking-table.component.ts @@ -0,0 +1,145 @@ +// dvbooking-cli/src/templates/angular-generic/table.component.ts.tpl + +// Generated by the CLI +import { Component, inject, OnInit } from '@angular/core'; +import { Router, RouterModule } from '@angular/router'; +import { Booking } from '../../models/booking.model'; +import { BookingDataProvider } from './booking-data-provider.service'; +import { ColumnDefinition } from '../../../../components/generic-table/column-definition.interface'; +import { GenericTable } from '../../../../components/generic-table/generic-table'; +import { GenericTableConfig } from '../../../../components/generic-table/generic-table.config'; +import { + ActionDefinition, + GenericActionColumn, +} from '../../../../components/generic-action-column/generic-action-column'; +import { BookingService } from '../../services/booking.service'; +import { BehaviorSubject } from 'rxjs'; + +@Component({ + selector: 'app-booking-table', + standalone: true, + imports: [GenericTable, RouterModule], + templateUrl: './booking-table.component.html', +}) +export class BookingTableComponent implements OnInit { + + private refresh$ = new BehaviorSubject(undefined); + private filter$ = new BehaviorSubject({}); + private page$ = new BehaviorSubject(1); + private limit$ = new BehaviorSubject(10); + + router = inject(Router); + tableConfig!: GenericTableConfig; + + bookingDataProvider = inject(BookingDataProvider); + bookingService = inject(BookingService); + + ngOnInit(): void { + const actionHandler = (action: ActionDefinition, item: Booking) => { + switch (action.action) { + case 'view': + this.router.navigate(['/bookings', item?.id]); + break; + case 'edit': + this.router.navigate(['/bookings', item?.id, 'edit']); + break; + case 'delete': + this.deleteItem(item.id); + break; + } + }; + + this.tableConfig = { + refresh$: this.refresh$, + filter$: this.filter$, + page$: this.page$, + limit$: this.limit$, + dataProvider: this.bookingDataProvider, + columns: [ + { + attribute: 'event_id', + headerCell: true, + valueCell: true, + }, + { + attribute: 'occurrence_start_time', + headerCell: true, + valueCell: true, + }, + { + attribute: 'user_id', + headerCell: true, + valueCell: true, + }, + { + attribute: 'notes', + headerCell: true, + valueCell: true, + }, + { + attribute: 'reserved_seats_count', + headerCell: true, + valueCell: true, + }, + { + attribute: 'created_at', + headerCell: true, + valueCell: true, + }, + { + attribute: 'updated_at', + headerCell: true, + valueCell: true, + }, + { + attribute: 'canceled_at', + headerCell: true, + valueCell: true, + }, + { + attribute: 'canceled_reason', + headerCell: true, + valueCell: true, + }, + { + attribute: 'canceled_by_user_id', + headerCell: true, + valueCell: true, + }, + { + attribute: 'actions', + headerCell: { value: 'Actions' }, + valueCell: { + component: GenericActionColumn, + componentInputs: item => ({ + item: item, + actions: [ + { action: 'view', handler: actionHandler }, + { action: 'edit', handler: actionHandler }, + { action: 'delete', handler: actionHandler }, + ] as ActionDefinition[], + }), + }, + }, + ] as ColumnDefinition[], + tableCssClass: 'booking-table-container', + }; + } + + deleteItem(id: number): void { + if (confirm('Are you sure you want to delete this item?')) { + this.bookingService.remove(id).subscribe({ + next: () => { + console.log(`Item with ID ${id} deleted successfully.`); + this.refresh$.next(); + }, + // --- THIS IS THE FIX --- + // Explicitly type 'err' to satisfy strict TypeScript rules. + error: (err: any) => { + console.error(`Error deleting item with ID ${id}:`, err); + } + }); + } + } + +} \ No newline at end of file diff --git a/admin/src/app/features/bookings/models/booking.model.ts b/admin/src/app/features/bookings/models/booking.model.ts new file mode 100644 index 0000000..47271b4 --- /dev/null +++ b/admin/src/app/features/bookings/models/booking.model.ts @@ -0,0 +1,16 @@ +// dvbooking-cli/src/templates/angular/model.ts.tpl + +// Generated by the CLI +export interface Booking { + id: number; + event_id: number; + occurrence_start_time: Date; + user_id: number; + notes: string; + reserved_seats_count: number; + created_at: Date; + updated_at: Date; + canceled_at: Date; + canceled_reason: string; + canceled_by_user_id: number; +} diff --git a/admin/src/app/features/bookings/services/booking.service.ts b/admin/src/app/features/bookings/services/booking.service.ts new file mode 100644 index 0000000..3b6e723 --- /dev/null +++ b/admin/src/app/features/bookings/services/booking.service.ts @@ -0,0 +1,85 @@ +// dvbooking-cli/src/templates/angular/service.ts.tpl + +// Generated by the CLI +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { Booking } from '../models/booking.model'; +import { ConfigurationService } from '../../../services/configuration.service'; +import { PaginatedResponse } from '../../../../types'; + + +export interface SearchResponse { + data: T[]; + total: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class BookingService { + private readonly apiUrl: string; + + constructor( + private http: HttpClient, + private configService: ConfigurationService + ) { + this.apiUrl = `${this.configService.getApiUrl()}/bookings`; + } + + /** + * Find records with pagination and filtering. + */ + public find(filter: Record): Observable> { + // --- THIS IS THE FIX --- + // The incorrect line: .filter(([_, v]) for v != null) + // is now correctly written with an arrow function. + const cleanFilter = Object.fromEntries( + Object.entries(filter).filter(([_, v]) => v != null) + ); + // --- END OF FIX --- + + const params = new HttpParams({ fromObject: cleanFilter }); + return this.http.get>(this.apiUrl, { params }); + } + + /** + * Search across multiple fields with a single term. + * @param term The search term (q). + */ + public search(term: string, page: number = 1, limit: number = 10): Observable> { + const params = new HttpParams() + .set('q', term) + .set('page', page.toString()) + .set('limit', limit.toString()); + return this.http.get>(`${this.apiUrl}/search`, { params }); + } + + /** + * Find a single record by its ID. + */ + public findOne(id: number): Observable { + return this.http.get(`${this.apiUrl}/${id}`); + } + + /** + * Create a new record. + */ + public create(data: Omit): Observable { + return this.http.post(this.apiUrl, data); + } + + /** + * Update an existing record. + */ + public update(id: number, data: Partial>): Observable { + return this.http.patch(`${this.apiUrl}/${id}`, data); + } + + /** + * Remove a record by its ID. + */ + public remove(id: number): Observable { + return this.http.delete(`${this.apiUrl}/${id}`); + } +} \ No newline at end of file diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 4433da7..05af4da 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -23,6 +23,7 @@ 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'; +import { BookingsModule } from './booking/bookings.module'; const moduleTypeOrm = TypeOrmModule.forRootAsync({ imports: [ConfigModule], @@ -44,7 +45,7 @@ const moduleTypeOrm = TypeOrmModule.forRootAsync({ Event, RecurrenceRule, EventException, - Booking + Booking, ], logging: true, // synchronize: true, @@ -67,6 +68,7 @@ const moduleTypeOrm = TypeOrmModule.forRootAsync({ RecurrenceRulesModule, EventExceptionsModule, CalendarModule, + BookingsModule, ], controllers: [AppController], providers: [AppService], diff --git a/server/src/booking/bookings.controller.ts b/server/src/booking/bookings.controller.ts new file mode 100644 index 0000000..9a4d1d1 --- /dev/null +++ b/server/src/booking/bookings.controller.ts @@ -0,0 +1,63 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + ParseIntPipe, + DefaultValuePipe, + UseGuards, +} from '@nestjs/common'; +import { BookingsService } from './bookings.service'; +import { CreateBookingDto } from './dto/create-booking.dto'; +import { UpdateBookingDto } from './dto/update-booking.dto'; +import { QueryBookingDto } from './dto/query-booking.dto'; + +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { Roles } from '../auth/roles.decorator'; +import { Role } from '../auth/role.enum'; +import { RolesGuard } from '../auth/roles.guard'; + +@Controller('bookings') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(Role.Admin) +export class BookingsController { + constructor(private readonly bookingsService: BookingsService) {} + + @Post() + create(@Body() createBookingDto: CreateBookingDto) { + return this.bookingsService.create(createBookingDto); + } + + @Get() + findAll(@Query() queryParams: QueryBookingDto) { + return this.bookingsService.findAll(queryParams); + } + + @Get('search') + search( + @Query('q') term: string, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number, + ) { + return this.bookingsService.search(term, { page, limit }); + } + + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.bookingsService.findOne(id); + } + + @Patch(':id') + update(@Param('id', ParseIntPipe) id: number, @Body() updateBookingDto: UpdateBookingDto) { + return this.bookingsService.update(id, updateBookingDto); + } + + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.bookingsService.remove(id); + } +} \ No newline at end of file diff --git a/server/src/booking/bookings.module.ts b/server/src/booking/bookings.module.ts new file mode 100644 index 0000000..86a93c7 --- /dev/null +++ b/server/src/booking/bookings.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BookingsService } from './bookings.service'; +import { BookingsController } from './bookings.controller'; +import { Booking } from '../entity/booking.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Booking])], + controllers: [BookingsController], + providers: [BookingsService], +}) +export class BookingsModule {} \ No newline at end of file diff --git a/server/src/booking/bookings.service.ts b/server/src/booking/bookings.service.ts new file mode 100644 index 0000000..0b33693 --- /dev/null +++ b/server/src/booking/bookings.service.ts @@ -0,0 +1,128 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, FindManyOptions, FindOptionsWhere, ILike } from 'typeorm'; +import { CreateBookingDto } from './dto/create-booking.dto'; +import { UpdateBookingDto } from './dto/update-booking.dto'; +import { QueryBookingDto } from './dto/query-booking.dto'; +import { Booking } from '../entity/booking.entity'; + +type QueryConfigItem = { + param: keyof Omit; + dbField: keyof Booking; + operator: 'equals' | 'like'; +}; + +@Injectable() +export class BookingsService { + constructor( + @InjectRepository(Booking) + private readonly bookingRepository: Repository, + ) {} + + private readonly searchableFields: (keyof Booking)[] = [ + 'notes', + 'canceledReason', + ]; + + create(createBookingDto: CreateBookingDto) { + const newRecord = this.bookingRepository.create(createBookingDto); + return this.bookingRepository.save(newRecord); + } + + async findAll(queryParams: QueryBookingDto) { + const { page = 1, limit = 0, sortBy, order, ...filters } = queryParams; + const queryConfig: QueryConfigItem[] = []; + const whereClause: { [key: string]: any } = {}; + for (const config of queryConfig) { + if (filters[config.param] !== undefined) { + if (config.operator === 'like') { + whereClause[config.dbField] = ILike(`%${filters[config.param]}%`); + } else { + whereClause[config.dbField] = filters[config.param]; + } + } + } + const findOptions: FindManyOptions = { + where: whereClause as FindOptionsWhere, + }; + const paginated = limit > 0; + if (paginated) { + findOptions.skip = (page - 1) * limit; + findOptions.take = limit; + } + if (sortBy && order) { + findOptions.order = { [sortBy]: order }; + } + const [data, totalItems] = + await this.bookingRepository.findAndCount(findOptions); + if (!paginated) { + return { data, total: data.length }; + } + return { + data, + meta: { + totalItems, + itemCount: data.length, + itemsPerPage: limit, + totalPages: Math.ceil(totalItems / limit), + currentPage: page, + }, + }; + } + + async search(term: string, options: { page: number; limit: number }) { + if (this.searchableFields.length === 0) { + console.warn('Search is not configured for this entity.'); + return { + data: [], + meta: { + totalItems: 0, + itemCount: 0, + itemsPerPage: options.limit, + totalPages: 0, + currentPage: options.page, + }, + }; + } + const whereConditions = this.searchableFields.map((field) => ({ + [field]: ILike(`%${term}%`), + })); + const [data, totalItems] = await this.bookingRepository.findAndCount({ + where: whereConditions, + skip: (options.page - 1) * options.limit, + take: options.limit, + }); + return { + data, + meta: { + totalItems, + itemCount: data.length, + itemsPerPage: options.limit, + totalPages: Math.ceil(totalItems / options.limit), + currentPage: options.page, + }, + }; + } + + async findOne(id: number) { + const record = await this.bookingRepository.findOneBy({ id: id }); + if (!record) { + throw new NotFoundException(`Booking with ID ${id} not found`); + } + return record; + } + + async update(id: number, updateBookingDto: UpdateBookingDto) { + const record = await this.findOne(id); + Object.assign(record, updateBookingDto); + return this.bookingRepository.save(record); + } + + async remove(id: number) { + const result = await this.bookingRepository.delete(id); + if (result.affected === 0) { + throw new NotFoundException(`Booking with ID ${id} not found`); + } + return { deleted: true, id }; + } +} diff --git a/server/src/booking/dto/create-booking.dto.ts b/server/src/booking/dto/create-booking.dto.ts new file mode 100644 index 0000000..3027de2 --- /dev/null +++ b/server/src/booking/dto/create-booking.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from '@nestjs/mapped-types'; +import { Booking } from '../../entity/booking.entity'; + +export class CreateBookingDto extends OmitType(Booking, ['id']) {} \ No newline at end of file diff --git a/server/src/booking/dto/query-booking.dto.ts b/server/src/booking/dto/query-booking.dto.ts new file mode 100644 index 0000000..dfbce6c --- /dev/null +++ b/server/src/booking/dto/query-booking.dto.ts @@ -0,0 +1,9 @@ +import { IsOptional, IsString, IsNumber, IsIn, IsBoolean } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class QueryBookingDto { + @IsOptional() @Type(() => Number) @IsNumber() page?: number; + @IsOptional() @Type(() => Number) @IsNumber() limit?: number; + @IsOptional() @IsString() sortBy?: string; + @IsOptional() @IsIn(['ASC', 'DESC']) order?: 'ASC' | 'DESC'; +} \ No newline at end of file diff --git a/server/src/booking/dto/update-booking.dto.ts b/server/src/booking/dto/update-booking.dto.ts new file mode 100644 index 0000000..ab44976 --- /dev/null +++ b/server/src/booking/dto/update-booking.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateBookingDto } from './create-booking.dto'; + +export class UpdateBookingDto extends PartialType(CreateBookingDto) {} \ No newline at end of file diff --git a/server/src/calendar/calendar.controller.ts b/server/src/calendar/calendar.controller.ts index 316e481..aef7b93 100644 --- a/server/src/calendar/calendar.controller.ts +++ b/server/src/calendar/calendar.controller.ts @@ -18,6 +18,8 @@ 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'; +import { CreateBookingDto } from './dto/create-booking.dto'; +import { CancelBookingDto } from './dto/cancel-booking.dto'; @Controller('calendar') @UseGuards(JwtAuthGuard, RolesGuard) @@ -83,4 +85,22 @@ export class CalendarController { ) { return this.calendarService.createException(eventId, createExceptionDto); } + + // Create a booking for a specific event occurrence + @Post('events/:id/bookings') + createBooking( + @Param('id', ParseIntPipe) eventId: number, + @Body() createBookingDto: CreateBookingDto, + ) { + return this.calendarService.createBooking(eventId, createBookingDto); + } + + // Cancel a specific booking (soft delete) + @Patch('bookings/:bookingId/cancel') + cancelBooking( + @Param('bookingId', ParseIntPipe) bookingId: number, + @Body() cancelBookingDto: CancelBookingDto, + ) { + return this.calendarService.cancelBooking(bookingId, cancelBookingDto); + } } diff --git a/server/src/calendar/calendar.module.ts b/server/src/calendar/calendar.module.ts index d3dddcb..15e3221 100644 --- a/server/src/calendar/calendar.module.ts +++ b/server/src/calendar/calendar.module.ts @@ -5,9 +5,12 @@ 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'; +import { Booking } from '../entity/booking.entity'; @Module({ - imports: [TypeOrmModule.forFeature([RecurrenceRule, EventException, Event])], + imports: [ + TypeOrmModule.forFeature([RecurrenceRule, EventException, Event, Booking]), + ], controllers: [CalendarController], providers: [CalendarService], }) diff --git a/server/src/calendar/calendar.service.ts b/server/src/calendar/calendar.service.ts index 269e9aa..ced1fd5 100644 --- a/server/src/calendar/calendar.service.ts +++ b/server/src/calendar/calendar.service.ts @@ -1,17 +1,20 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, Between } from 'typeorm'; -import { RRule, Weekday } from 'rrule'; // Corrected import +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between, IsNull, DataSource, In } from 'typeorm'; +import { RRule } 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; -}; +import { Booking } from '../entity/booking.entity'; +import { CancelBookingDto } from './dto/cancel-booking.dto'; +import { CreateBookingDto } from './dto/create-booking.dto'; // --- Type-Safe Maps --- const frequencyMap: Record = { @@ -31,60 +34,123 @@ const weekdayMap: Record = { SA: RRule.SA, }; +// This is a minimal representation of the User entity for the DTO +type BookingUserDto = { + id: number; + name: string; // Assuming user has a 'name' property + email: string; // Assuming user has a 'name' property +}; + +// This represents a booking with nested user info +type BookingWithUserDto = { + user: BookingUserDto | null; + id: number; +}; + +// The final shape of a calendar event occurrence +export type CalendarEventDto = Omit & { + isModified?: boolean; + eventBookings: BookingWithUserDto[]; +}; + @Injectable() export class CalendarService { constructor( + @InjectDataSource() + private readonly dataSource: DataSource, @InjectRepository(Event) private readonly eventRepository: Repository, @InjectRepository(RecurrenceRule) private readonly recurrenceRuleRepository: Repository, @InjectRepository(EventException) private readonly eventExceptionRepository: Repository, + @InjectRepository(Booking) + private readonly bookingRepository: Repository, ) {} - async getEventsInRange(startDate: Date, endDate: Date): Promise { - // 1. Fetch single events + async getEventsInRange( + startDate: Date, + endDate: Date, + ): Promise { + // 1. Fetch active single events const singleEvents = await this.eventRepository.find({ where: { isRecurring: false, startTime: Between(startDate, endDate), + // status: EventStatus.ACTIVE, }, relations: ['eventType'], }); - // 2. Fetch recurring event templates + // 2. Fetch active recurring event templates const recurringTemplates = await this.eventRepository.find({ - where: { isRecurring: true }, + where: { + isRecurring: true, + // status: EventStatus.ACTIVE, + }, relations: ['recurrenceRule', 'exceptions', 'eventType'], }); - const recurringOccurrences: CalendarEventDto[] = []; + if (recurringTemplates.length === 0 && singleEvents.length === 0) { + return []; + } - // 3. Expand recurring events + // 3. Fetch all ACTIVE bookings for the relevant events and date range in a single query + const eventIds = [ + ...singleEvents.map((e) => e.id), + ...recurringTemplates.map((e) => e.id), + ]; + const allBookings = await this.bookingRepository.find({ + where: { + eventId: In(eventIds), + occurrenceStartTime: Between(startDate, endDate), + canceledAt: IsNull(), // CRITICAL: Only fetch active bookings + }, + relations: ['user'], // Eagerly load the related user data + }); + + // 4. Create a Map for efficient lookup of bookings + const bookingMap = new Map(); + for (const booking of allBookings) { + const key = `${booking.eventId}-${booking.occurrenceStartTime.getTime()}`; + if (!bookingMap.has(key)) { + bookingMap.set(key, []); + } + // Assuming user entity has name/email. Adjust as needed. + const userDto: BookingUserDto | null = booking.user + ? { id: booking.user.id, name: 'User Name', email: 'user@email.com' } + : null; + bookingMap.get(key)!.push({ ...booking, user: userDto }); + } + + // 5. Process single events and attach their bookings + const singleOccurrences: CalendarEventDto[] = singleEvents.map((event) => { + const key = `${event.id}-${event.startTime.getTime()}`; + return { + ...event, + eventBookings: bookingMap.get(key) || [], + }; + }); + + // 6. Expand recurring events and attach their bookings + const recurringOccurrences: CalendarEventDto[] = []; 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); + .filter(Boolean); - if (freq === undefined) { - console.error( - `Invalid frequency for event ID ${event.id}: ${event.recurrenceRule.frequency}`, - ); - continue; - } + if (!freq) continue; const rrule = new RRule({ - freq: freq, - interval: event.recurrenceRule.interval, + freq, dtstart: event.startTime, until: event.recurrenceRule.endDate, count: event.recurrenceRule.count, + interval: event.recurrenceRule.interval, byweekday: byweekday?.length > 0 ? byweekday : undefined, }); @@ -96,29 +162,32 @@ export class CalendarService { ); if (exception) { - if (exception.isCancelled) { - continue; - } + if (exception.isCancelled) continue; + + // This is a MODIFIED occurrence + const key = `${event.id}-${exception.newStartTime.getTime()}`; recurringOccurrences.push({ ...event, - id: event.id, - title: exception.title || event.title, - description: exception.description || event.description, startTime: exception.newStartTime, endTime: exception.newEndTime, isModified: true, + eventBookings: bookingMap.get(key) || [], }); } else { + // This is a REGULAR occurrence + const key = `${event.id}-${occurrenceDate.getTime()}`; recurringOccurrences.push({ ...event, startTime: occurrenceDate, endTime: new Date(occurrenceDate.getTime() + duration), + eventBookings: bookingMap.get(key) || [], }); } } } - const allEvents = [...singleEvents, ...recurringOccurrences]; + // 7. Combine and sort all results + const allEvents = [...singleOccurrences, ...recurringOccurrences]; return allEvents.sort( (a, b) => a.startTime.getTime() - b.startTime.getTime(), ); @@ -147,17 +216,52 @@ export class CalendarService { eventId: number, createExceptionDto: CreateExceptionDto, ): Promise { + const { originalStartTime, newStartTime } = createExceptionDto; + 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, - }); + // This logic now requires a transaction + return this.dataSource.manager.transaction( + async (transactionalEntityManager) => { + // Step 1: Create and save the new exception + const exception = transactionalEntityManager.create(EventException, { + ...createExceptionDto, + event: event, + }); + const savedException = await transactionalEntityManager.save(exception); - return this.eventExceptionRepository.save(exception); + // Step 2: Check if this is a RESCHEDULE operation + // A reschedule happens when there is a new start time. + if (newStartTime) { + // Step 3: Find all existing bookings for the ORIGINAL time + const bookingsToMigrate = await transactionalEntityManager.find( + Booking, + { + where: { + eventId: eventId, + occurrenceStartTime: originalStartTime, + }, + }, + ); + + // Step 4: If we found any bookings, update their start time to the NEW time + if (bookingsToMigrate.length > 0) { + const bookingIds = bookingsToMigrate.map((b) => b.id); + + await transactionalEntityManager.update( + Booking, + bookingIds, // Update these specific bookings + { occurrenceStartTime: newStartTime }, // Set their time to the new value + ); + } + } + + return savedException; + }, + ); } async getEventById(id: number): Promise { @@ -185,4 +289,109 @@ export class CalendarService { throw new NotFoundException(`Event with ID ${id} not found.`); } } + + async createBooking( + eventId: number, + createBookingDto: CreateBookingDto, + ): Promise { + const { occurrenceStartTime, userId } = createBookingDto; + + const event = await this.eventRepository.findOneBy({ id: eventId }); + if (!event) + throw new NotFoundException(`Event with ID ${eventId} not found.`); + + // Business Rule: Prevent user from double-booking an active slot + const existingBooking = await this.bookingRepository.findOne({ + where: { + eventId, + occurrenceStartTime, + userId, + canceledAt: IsNull(), // Only check for active bookings + }, + }); + + if (existingBooking) { + throw new BadRequestException( + 'This user already has an active booking for this time slot.', + ); + } + + // Validate that the occurrence is valid (same logic as before) + const isOccurrenceValid = this.isValidOccurrence( + event, + occurrenceStartTime, + ); + if (!isOccurrenceValid) { + throw new BadRequestException( + `The provided time is not a valid occurrence for this event.`, + ); + } + + // Create and save the new booking entity + const newBooking = this.bookingRepository.create({ + ...createBookingDto, + eventId: event.id, + }); + + return this.bookingRepository.save(newBooking); + } + + async cancelBooking( + bookingId: number, + cancelBookingDto: CancelBookingDto, + ): Promise { + const booking = await this.bookingRepository.findOneBy({ id: bookingId }); + if (!booking) { + throw new NotFoundException(`Booking with ID ${bookingId} not found.`); + } + if (booking.canceledAt) { + throw new BadRequestException('This booking has already been cancelled.'); + } + + // Update the booking with cancellation details + booking.canceledAt = new Date(); + booking.canceledReason = cancelBookingDto.canceledReason || null; + booking.canceledByUserId = cancelBookingDto.canceledByUserId; + + return this.bookingRepository.save(booking); + } + + private isValidOccurrence(event: Event, occurrenceTime: Date): boolean { + // Check if the occurrence has been explicitly cancelled + const exception = event.exceptions.find( + (ex) => ex.originalStartTime.getTime() === occurrenceTime.getTime(), + ); + if (exception && exception.isCancelled) { + return false; // It was cancelled + } + + // If it's a single, non-recurring event + if (!event.isRecurring) { + return event.startTime.getTime() === occurrenceTime.getTime(); + } + + // If it's a recurring event + if (!event.recurrenceRule) return false; // Should not happen with good data + + const freq = frequencyMap[event.recurrenceRule.frequency]; + // ... (weekdayMap logic from previous discussion) + const byweekday = event.recurrenceRule.byDay + ?.split(',') + .map((day) => weekdayMap[day]) + .filter(Boolean); + + const rule = new RRule({ + freq, + interval: event.recurrenceRule.interval, + dtstart: event.startTime, + until: event.recurrenceRule.endDate, + count: event.recurrenceRule.count, + byweekday: byweekday?.length > 0 ? byweekday : undefined, + }); + + // Generate occurrences ONLY around the specific time to be efficient + const nextOccurrence = rule.after(occurrenceTime, true); // `true` includes the date if it matches + + return nextOccurrence?.getTime() === occurrenceTime.getTime(); + } } diff --git a/server/src/calendar/dto/cancel-booking.dto.ts b/server/src/calendar/dto/cancel-booking.dto.ts new file mode 100644 index 0000000..3a51783 --- /dev/null +++ b/server/src/calendar/dto/cancel-booking.dto.ts @@ -0,0 +1,18 @@ +import { + IsInt, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; + +export class CancelBookingDto { + @IsNotEmpty() + @IsInt() + canceledByUserId: number; + + @IsOptional() + @IsString() + @MaxLength(50) + canceledReason?: string; +} diff --git a/server/src/calendar/dto/create-booking.dto.ts b/server/src/calendar/dto/create-booking.dto.ts new file mode 100644 index 0000000..e68ee4b --- /dev/null +++ b/server/src/calendar/dto/create-booking.dto.ts @@ -0,0 +1,29 @@ +import { + IsDate, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Min, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class CreateBookingDto { + @IsNotEmpty() + @Type(() => Date) + @IsDate() + occurrenceStartTime: Date; + + @IsNotEmpty() + @IsInt() + userId: number; // Replaces userName/userEmail + + @IsOptional() + @IsInt() + @Min(1) + reservedSeatsCount?: number = 1; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/server/src/entity/event.entity.ts b/server/src/entity/event.entity.ts index 1d822f4..16313d3 100644 --- a/server/src/entity/event.entity.ts +++ b/server/src/entity/event.entity.ts @@ -12,6 +12,7 @@ import { import { EventType } from './event-type.entity'; import { RecurrenceRule } from './recurrence-rule.entity'; import { EventException } from './event-exception.entity'; +import { Booking } from './booking.entity'; @Entity('events') export class Event { @@ -63,4 +64,9 @@ export class Event { cascade: true, // Automatically save/update exceptions when event is saved }) exceptions: EventException[]; -} \ No newline at end of file + + @OneToMany(() => Booking, (booking) => booking.event, { + cascade: true, // Automatically save/update exceptions when event is saved + }) + bookings: Booking[]; +}