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