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
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | id |
+ event_id |
+ occurrence_start_time |
+ user_id |
+ notes |
+ reserved_seats_count |
+ created_at |
+ updated_at |
+ canceled_at |
+ canceled_reason |
+ canceled_by_user_id |
+ Actions |
+
+
+
+
+ | {{ 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. |
+
+
+
+
+
+
+ 1" class="flex justify-center mt-4">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
\ 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[];
+}