add calendar module

This commit is contained in:
Roland Schneider 2025-11-20 22:08:17 +01:00
parent c28431e80c
commit 085605f85c
18 changed files with 789 additions and 137 deletions

View File

@ -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}}

View File

@ -25,6 +25,7 @@
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.16.3", "pg": "^8.16.3",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rrule": "^2.8.1",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^0.3.27" "typeorm": "^0.3.27"
}, },
@ -39,6 +40,7 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.0", "@types/passport-jwt": "^4.0.0",
"@types/rrule": "^2.1.7",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
@ -3168,6 +3170,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/send": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
@ -9528,6 +9537,15 @@
"node": ">= 18" "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": { "node_modules/run-parallel": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",

View File

@ -39,6 +39,7 @@
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.16.3", "pg": "^8.16.3",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rrule": "^2.8.1",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^0.3.27" "typeorm": "^0.3.27"
}, },
@ -53,6 +54,7 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.0", "@types/passport-jwt": "^4.0.0",
"@types/rrule": "^2.1.7",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",

View File

@ -19,8 +19,10 @@ import { UserGroupsModule } from './user-group/user-group.module';
import { UserRolesModule } from './user-role/user-role.module'; import { UserRolesModule } from './user-role/user-role.module';
import { RecurrenceRule } from './entity/recurrence-rule.entity'; import { RecurrenceRule } from './entity/recurrence-rule.entity';
import { RecurrenceRulesModule } from './recurrence-rule/recurrence-rules.module'; import { RecurrenceRulesModule } from './recurrence-rule/recurrence-rules.module';
import { EventException } from "./entity/event-exception.entity"; import { EventException } from './entity/event-exception.entity';
import { EventExceptionsModule } from "./event-exception/event-exceptions.module"; import { EventExceptionsModule } from './event-exception/event-exceptions.module';
import { CalendarModule } from './calendar/calendar.module';
import { Booking } from './entity/booking.entity';
const moduleTypeOrm = TypeOrmModule.forRootAsync({ const moduleTypeOrm = TypeOrmModule.forRootAsync({
imports: [ConfigModule], imports: [ConfigModule],
@ -41,8 +43,9 @@ const moduleTypeOrm = TypeOrmModule.forRootAsync({
Product, Product,
Event, Event,
RecurrenceRule, RecurrenceRule,
EventException EventException,
], Booking
],
logging: true, logging: true,
// synchronize: true, // synchronize: true,
}; };
@ -62,8 +65,9 @@ const moduleTypeOrm = TypeOrmModule.forRootAsync({
UserGroupsModule, UserGroupsModule,
UserRolesModule, UserRolesModule,
RecurrenceRulesModule, RecurrenceRulesModule,
EventExceptionsModule EventExceptionsModule,
], CalendarModule,
],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
}) })

View 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);
}
}

View 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 {}

View 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.`);
}
}
}

View 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;
}

View 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;
}

View 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;
}

View File

@ -6,6 +6,11 @@ import * as dotenv from 'dotenv';
import { UserGroup } from './entity/user-group'; import { UserGroup } from './entity/user-group';
import { UserRole } from './entity/user-role'; import { UserRole } from './entity/user-role';
import { EventType } from './entity/event-type.entity'; 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(); dotenv.config();
@ -18,9 +23,17 @@ export const AppDataSource = new DataSource({
database: process.env.DATABASE_NAME, database: process.env.DATABASE_NAME,
synchronize: false, synchronize: false,
logging: false, logging: false,
entities: [User, UserGroup, UserRole, EventType], entities: [
migrations: [ User,
'src/migration/**/*.ts' UserGroup,
UserRole,
EventType,
Event,
EventException,
RecurrenceRule,
Product,
Booking
], ],
migrations: ['src/migration/**/*.ts'],
subscribers: [], subscribers: [],
}); });

View 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;
}

View File

@ -1,52 +1,47 @@
// dvbooking-cli/src/templates/nestjs/entity.ts.tpl
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
import { import {
IsString, Column,
IsNumber, CreateDateColumn,
IsBoolean, Entity,
IsDate, JoinColumn,
IsOptional, ManyToOne,
} from 'class-validator'; PrimaryGeneratedColumn,
} from 'typeorm';
import { Event } from './event.entity';
@Entity({ name: 'event_exceptions' }) @Entity('event_exceptions')
export class EventException { export class EventException {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn({ type: 'bigint' })
id: number; id: number;
@Column() @Column({ name: 'original_start_time', type: 'timestamptz' })
@IsNumber() originalStartTime: Date;
event_id: number;
@Column() @Column({ name: 'is_cancelled', type: 'boolean', default: false })
@IsDate() isCancelled: boolean;
original_start_time: Date;
@Column({ default: false }) @Column({ name: 'new_start_time', type: 'timestamptz', nullable: true })
@IsBoolean() newStartTime: Date;
is_cancelled: boolean = false;
@Column({ type: 'timestamp with time zone', nullable: true }) @Column({ name: 'new_end_time', type: 'timestamptz', nullable: true })
@IsOptional() newEndTime: Date;
@IsDate()
new_start_time: Date | null;
@Column({ type: 'timestamp with time zone', nullable: true }) @Column({ name: 'title', type: 'varchar', length: 255, nullable: true })
@IsOptional() title: string;
@IsDate()
new_end_time: Date | null;
@Column({ type: 'character varying', nullable: true }) @Column({ name: 'description', type: 'text', nullable: true })
@IsOptional() description: string;
@IsString()
title: string | null;
@Column({ type: 'text', nullable: true }) @CreateDateColumn({ name: 'created_at', type: 'timestamp' })
@IsOptional() createdAt: Date;
@IsString()
description: string | null;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) // --- Relationships ---
@IsDate()
created_at: Date; @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;
} }

View File

@ -1,10 +1,8 @@
// dvbooking-cli/src/templates/nestjs/entity.ts.tpl // dvbooking-cli/src/templates/nestjs/entity.ts.tpl
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
import { import { Event } from './event.entity';
IsString, import { IsString, IsOptional } from 'class-validator';
IsOptional,
} from 'class-validator';
@Entity({ name: 'event_type' }) @Entity({ name: 'event_type' })
export class EventType { export class EventType {
@ -24,4 +22,7 @@ export class EventType {
@IsOptional() @IsOptional()
@IsString() @IsString()
color: string | null; color: string | null;
@OneToMany(() => Event, (event) => event.eventType)
events: Event[];
} }

View File

@ -1,54 +1,66 @@
// dvbooking-cli/src/templates/nestjs/entity.ts.tpl
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
import { import {
IsString, Column,
IsNumber, CreateDateColumn,
IsBoolean, Entity,
IsDate, ManyToOne,
IsOptional, OneToMany,
} from 'class-validator'; 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 { export class Event {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn({ type: 'bigint' })
id: number; id: number;
@Column({ type: 'bigint', nullable: true }) @Column({ name: 'title', type: 'varchar', length: 255 })
@IsOptional()
@IsNumber()
event_type_id: number | null;
@Column()
@IsString()
title: string; title: string;
@Column({ type: 'text', nullable: true }) @Column({ name: 'description', type: 'text', nullable: true })
@IsOptional() description: string;
@IsString()
description: string | null;
@Column() @Column({ name: 'start_time', type: 'timestamptz' })
@IsDate() startTime: Date;
start_time: Date;
@Column() @Column({ name: 'end_time', type: 'timestamptz' })
@IsDate() endTime: Date;
end_time: Date;
@Column() @Column({ name: 'timezone', type: 'varchar', length: 50 })
@IsString()
timezone: string; timezone: string;
@Column({ default: false }) @Column({ name: 'is_recurring', type: 'boolean', default: false })
@IsBoolean() isRecurring: boolean;
is_recurring: boolean = false;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) @CreateDateColumn({ name: 'created_at', type: 'timestamp' })
@IsDate() createdAt: Date;
created_at: Date;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) @UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
@IsDate() updatedAt: Date;
updated_at: 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[];
} }

View File

@ -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'; @Entity('recurrence_rules')
import { IsString, IsNumber, IsDate, IsOptional } from 'class-validator';
@Entity({ name: 'recurrence_rules' })
export class RecurrenceRule { export class RecurrenceRule {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn({ type: 'bigint' })
id: number; id: number;
@Column() @Column({ name: 'frequency', type: 'varchar', length: 10 })
@IsNumber() frequency: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
event_id: number;
@Column() @Column({ name: 'interval', type: 'integer', default: 1 })
@IsString() interval: number;
frequency: string;
@Column({ default: '1' }) @Column({ name: 'end_date', type: 'date', nullable: true })
@IsNumber() endDate: Date;
interval: number = 1;
@Column({ type: 'date', nullable: true }) @Column({ name: 'count', type: 'integer', nullable: true })
@IsOptional() count: number;
@IsDate()
end_date: Date | null;
@Column({ type: 'integer', nullable: true }) @Column({ name: 'by_day', type: 'varchar', length: 20, nullable: true })
@IsOptional() byDay: string; // e.g., 'MO,TU,WE'
@IsNumber()
count: number | null;
@Column({ type: 'character varying', nullable: true }) @Column({ name: 'by_month_day', type: 'integer', nullable: true })
@IsOptional() byMonthDay: number;
@IsString()
by_day: string | null;
@Column({ type: 'integer', nullable: true }) @Column({ name: 'by_month', type: 'integer', nullable: true })
@IsOptional() byMonth: number;
@IsNumber()
by_month_day: number | null;
@Column({ type: 'integer', nullable: true }) // --- Relationships ---
@IsOptional()
@IsNumber() @Column({ name: 'event_id', type: 'bigint' })
by_month: number | null; 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;
} }

View 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"`);
}
}

View File

@ -22,10 +22,7 @@ export class RecurrenceRulesService {
private readonly recurrenceRuleRepository: Repository<RecurrenceRule>, private readonly recurrenceRuleRepository: Repository<RecurrenceRule>,
) {} ) {}
private readonly searchableFields: (keyof RecurrenceRule)[] = [ private readonly searchableFields: (keyof RecurrenceRule)[] = [ ];
'frequency',
'by_day',
];
create(createRecurrenceRuleDto: CreateRecurrenceRuleDto) { create(createRecurrenceRuleDto: CreateRecurrenceRuleDto) {
const newRecord = this.recurrenceRuleRepository.create( const newRecord = this.recurrenceRuleRepository.create(