basic booking load behavior

This commit is contained in:
Roland Schneider
2025-12-19 16:23:53 +01:00
parent 72c213eaea
commit 4e77578abf
18 changed files with 335 additions and 75 deletions

View File

@@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="dev:server" type="ShConfigurationType"> <configuration default="false" name="dev:server" type="ShConfigurationType">
<option name="SCRIPT_TEXT" value="cd server &amp;&amp; npm run start" /> <option name="SCRIPT_TEXT" value="cd server &amp;&amp; npm run start:dev" />
<option name="INDEPENDENT_SCRIPT_PATH" value="true" /> <option name="INDEPENDENT_SCRIPT_PATH" value="true" />
<option name="SCRIPT_PATH" value="" /> <option name="SCRIPT_PATH" value="" />
<option name="SCRIPT_OPTIONS" value="" /> <option name="SCRIPT_OPTIONS" value="" />

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
echo "workdir ${pwd}" echo "workdir ${pwd}"
wget -O ./openapi/api.json localhost:4200/api-json wget -O ./openapi/api.json localhost:3000/api-json
npx ng-openapi -i ./openapi/api.json -o ./src/api npx ng-openapi -c ./openapitools.json -i ./openapi/api.json -o ./src/api

File diff suppressed because one or more lines are too long

View File

@@ -80,6 +80,34 @@ export interface CalendarCreateBookingDto {
export interface CancelBookingDto { export interface CancelBookingDto {
} }
export interface UserResponseDto {
id: number;
username: string;
}
export interface BookingResponseDto {
id: number;
eventId: number;
occurrenceStartTime: Date;
notes?: string;
reservedSeatsCount: number;
canceledAt: string | null;
user: UserResponseDto;
}
export interface PaginationResponseMetaDto {
totalItems: number;
itemCount: number;
itemsPerPage: number;
totalPages: number;
currentPage: number;
}
export interface CalenderControllerGetBookingResponse {
data: Array<BookingResponseDto>;
meta: PaginationResponseMetaDto;
}
export interface CreateBookingDto { export interface CreateBookingDto {
} }

View File

@@ -12,7 +12,7 @@ import { inject, Injectable } from "@angular/core";
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { BASE_PATH_DEFAULT, CLIENT_CONTEXT_TOKEN_DEFAULT } from "../tokens"; import { BASE_PATH_DEFAULT, CLIENT_CONTEXT_TOKEN_DEFAULT } from "../tokens";
import { HttpParamsBuilder } from "../utils/http-params-builder"; import { HttpParamsBuilder } from "../utils/http-params-builder";
import { RequestOptions, CreateEventDto, CreateExceptionDto, CalendarCreateBookingDto, CancelBookingDto } from "../models"; import { RequestOptions, CreateEventDto, CreateExceptionDto, CalendarCreateBookingDto, CancelBookingDto, CalenderControllerGetBookingResponse } from "../models";
@Injectable({ providedIn: "root" }) @Injectable({ providedIn: "root" })
export class CalendarService { export class CalendarService {
@@ -153,14 +153,32 @@ export class CalendarService {
return this.httpClient.patch(url, cancelBookingDto, requestOptions); return this.httpClient.patch(url, cancelBookingDto, requestOptions);
} }
calendarControllerGetBookings(eventId: number, startTime: Date, observe?: 'body', options?: RequestOptions<'json'>): Observable<any>; calendarControllerGetBookings(eventId: number, startTime: string, page?: number, limit?: number, sortBy?: string, order?: 'ASC' | 'DESC', observe?: 'body', options?: RequestOptions<'json'>): Observable<CalenderControllerGetBookingResponse>;
calendarControllerGetBookings(eventId: number, startTime: Date, observe?: 'response', options?: RequestOptions<'json'>): Observable<HttpResponse<any>>; calendarControllerGetBookings(eventId: number, startTime: string, page?: number, limit?: number, sortBy?: string, order?: 'ASC' | 'DESC', observe?: 'response', options?: RequestOptions<'json'>): Observable<HttpResponse<CalenderControllerGetBookingResponse>>;
calendarControllerGetBookings(eventId: number, startTime: Date, observe?: 'events', options?: RequestOptions<'json'>): Observable<HttpEvent<any>>; calendarControllerGetBookings(eventId: number, startTime: string, page?: number, limit?: number, sortBy?: string, order?: 'ASC' | 'DESC', observe?: 'events', options?: RequestOptions<'json'>): Observable<HttpEvent<CalenderControllerGetBookingResponse>>;
calendarControllerGetBookings(eventId: number, startTime: Date, observe?: 'body' | 'events' | 'response', options?: RequestOptions<'arraybuffer' | 'blob' | 'json' | 'text'>): Observable<any> { calendarControllerGetBookings(eventId: number, startTime: string, page?: number, limit?: number, sortBy?: string, order?: 'ASC' | 'DESC', observe?: 'body' | 'events' | 'response', options?: RequestOptions<'arraybuffer' | 'blob' | 'json' | 'text'>): Observable<any> {
const url = `${this.basePath}/api/calendar/bookings/${eventId}/${startTime}`; const url = `${this.basePath}/api/calendar/bookings/${eventId}`;
let params = new HttpParams();
if (page != null) {
params = HttpParamsBuilder.addToHttpParams(params, page, 'page');
}
if (limit != null) {
params = HttpParamsBuilder.addToHttpParams(params, limit, 'limit');
}
if (sortBy != null) {
params = HttpParamsBuilder.addToHttpParams(params, sortBy, 'sortBy');
}
if (order != null) {
params = HttpParamsBuilder.addToHttpParams(params, order, 'order');
}
if (startTime != null) {
params = HttpParamsBuilder.addToHttpParams(params, startTime, 'startTime');
}
const requestOptions: any = { const requestOptions: any = {
observe: observe as any, observe: observe as any,
params,
reportProgress: options?.reportProgress, reportProgress: options?.reportProgress,
withCredentials: options?.withCredentials, withCredentials: options?.withCredentials,
context: this.createContextWithClientId(options?.context) context: this.createContextWithClientId(options?.context)

View File

@@ -0,0 +1,10 @@
<!-- We use a generic container but tell the browser it is a heading -->
<div
role="heading"
[attr.aria-level]="level()"
class="text-3xl font-bold"
[class]="'h' + level()"
>
<ng-content></ng-content>
</div>

View File

@@ -0,0 +1,14 @@
import { Component, input } from '@angular/core';
export type HeadingLevel = 1|2|3|4|5|6;
@Component({
selector: 'app-heading',
imports: [],
templateUrl: './heading.html',
styleUrl: './heading.css',
})
export class Heading {
level = input.required<HeadingLevel>()
}

View File

@@ -1,11 +1,38 @@
<h1>Foglalások</h1> <app-heading [level]="1">
Foglalások
</app-heading>
@if (bookings.isLoading()) { @if (bookings.isLoading()) {
<div>loading...</div> <div>loading...</div>
} @else { } @else {
@for (booking of bookings.value()?.items; track booking) { @if (bookings.value()?.data?.length) {
<div> <div class="overflow-x-auto">
{{ booking }} <table class="table">
<!-- head -->
<thead>
<tr>
<th>Foglalás dátuma</th>
<th>Foglalt helyek száma</th>
<th></th>
</tr>
</thead>
<tbody>
@for (booking of bookings.value()?.data; track booking) {
<tr>
<td>{{formatDateTime(booking.occurrenceStartTime)}}</td>
<td>{{booking.reservedSeatsCount}}</td>
<td><rs-daisy-button [variant]="'error'">
Lemondás
</rs-daisy-button></td>
</tr>
}
</tbody>
</table>
</div> </div>
@if(bookings.value()?.meta?.totalPages! > 1){
<rs-daisy-pagination [pageCount]="pageCount()" [activePage]="activePage()"
(onPaginate)="paginate($event)"></rs-daisy-pagination>
}
} @else {
Nem találtunk foglalást erre az eseményre
} }
<rs-daisy-pagination [pageCount]="pageCount()" [activePage]="activePage()" (onPaginate)="paginate($event)"></rs-daisy-pagination>
} }

View File

@@ -1,15 +1,19 @@
import { Component, computed, inject, input, signal } from '@angular/core'; import { Component, computed, inject, input, signal } from '@angular/core';
import { EventBusService } from '../../../../../services/event-bus.service'; import { EventBusService } from '../../../../../services/event-bus.service';
import { CalendarEventDto } from '../../../models/events-in-range-dto.model'; import { CalendarEventDto } from '../../../models/events-in-range-dto.model';
import { CalendarService } from '../../../../../../api'; import { BookingResponseDto, CalendarService } from '../../../../../../api';
import { rxResource } from '@angular/core/rxjs-interop'; import { rxResource } from '@angular/core/rxjs-interop';
import { delay, of } from 'rxjs'; import { delay, of, throwError } from 'rxjs';
import { Pagination } from '@rschneider/ng-daisyui'; import { Button, Pagination } from '@rschneider/ng-daisyui';
import { Heading } from '../../../../../components/heading/heading';
import { format } from 'date-fns';
@Component({ @Component({
selector: 'app-single-event-booking-list', selector: 'app-single-event-booking-list',
imports: [ imports: [
Pagination, Pagination,
Heading,
Button,
], ],
templateUrl: './single-event-booking-list.html', templateUrl: './single-event-booking-list.html',
styleUrl: './single-event-booking-list.css', styleUrl: './single-event-booking-list.css',
@@ -24,7 +28,8 @@ export class SingleEventBookingList {
// bookings = toSignal(of(['a','b'])); // bookings = toSignal(of(['a','b']));
pageSize = input<number>(10); pageSize = input<number>(10);
pageCount = computed(() => { pageCount = computed(() => {
return this.bookings.value()?.pageCount || 1; // return this.bookings.value()?.pageCount || 1;
return 1;
}) })
bookings = rxResource( bookings = rxResource(
@@ -34,22 +39,30 @@ export class SingleEventBookingList {
}), }),
stream: ({params}) => { stream: ({params}) => {
console.info("loading resource", params); // console.info("loading resource", params);
//
const allData = ["a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t"] // const allData = ["a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t"]
//
let pageCount = Math.floor( allData.length / this.pageSize()); // let pageCount = Math.floor( allData.length / this.pageSize());
if ( (allData.length % this.pageSize()) > 0){ // if ( (allData.length % this.pageSize()) > 0){
pageCount += 1; // pageCount += 1;
// }
// pageCount = Math.max(pageCount ,1);
//
// const pageData = allData.slice( ((this.activePage()-1) * this.pageSize()),this.activePage()*this.pageSize());
// console.info("booking page data",pageData);
// return of({
// items: pageData,
// pageCount
// }).pipe( delay(1000))
if ( !this.event()){
return throwError( () => new Error("no event"))
} }
pageCount = Math.max(pageCount ,1); const event = this.event()!;
return this.calendarService.calendarControllerGetBookings(
const pageData = allData.slice( ((this.activePage()-1) * this.pageSize()),this.activePage()*this.pageSize()); event.id,
console.info("booking page data",pageData); new Date(event.startTime).toISOString()
return of({ )
items: pageData,
pageCount
}).pipe( delay(1000))
}, },
} }
@@ -61,6 +74,10 @@ export class SingleEventBookingList {
this.activePage.set($event); this.activePage.set($event);
} }
formatDateTime( dateStr: string|Date){
return format(new Date(dateStr),'yyyy-MM-dd HH:mm');
}
protected readonly format = format;
} }

View File

@@ -10,7 +10,6 @@ import {
ParseIntPipe, ParseIntPipe,
UseGuards, UseGuards,
ValidationPipe, ValidationPipe,
ParseDatePipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { CalendarService } from './calendar.service'; import { CalendarService } from './calendar.service';
import { GetCalendarDto } from './dto/get-calendar.dto'; import { GetCalendarDto } from './dto/get-calendar.dto';
@@ -22,9 +21,11 @@ import { Roles } from '../auth/roles.decorator';
import { Role } from '../auth/role.enum'; import { Role } from '../auth/role.enum';
import { CalendarCreateBookingDto } from './dto/create-booking.dto'; import { CalendarCreateBookingDto } from './dto/create-booking.dto';
import { CancelBookingDto } from './dto/cancel-booking.dto'; import { CancelBookingDto } from './dto/cancel-booking.dto';
import { ApiBody } from '@nestjs/swagger'; import { ApiBody, ApiCreatedResponse } from '@nestjs/swagger';
import { User } from '../auth/user.decorator'; import { User } from '../auth/user.decorator';
import * as types from '../types'; import * as types from '../types';
import { CalendarGetBookingDto } from './dto/calendar.get.booking.dto';
import { CalenderControllerGetBookingResponse } from './dto/booking.response.dto';
@Controller('calendar') @Controller('calendar')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@@ -101,16 +102,20 @@ export class CalendarController {
return this.calendarService.cancelBooking(bookingId, cancelBookingDto); return this.calendarService.cancelBooking(bookingId, cancelBookingDto);
} }
@Get('bookings/:eventId/:startTime') @Get('bookings/:eventId')
getBookings( @ApiCreatedResponse({ type: CalenderControllerGetBookingResponse }) // Removed <Booking> generic for cleaner syntax unless defined elsewhere
// Ensure ValidationPipe is enabled with transform: true (Global or Method scoped)
// @UsePipes(new ValidationPipe({ transform: true }))
async getBookings(
@User() user: types.AppUser, @User() user: types.AppUser,
@Param('eventId', ParseIntPipe) eventId: number, @Param('eventId', ParseIntPipe) eventId: number,
@Param('startTime', new ParseDatePipe()) startTime: Date, // Change @Query('dto') to @Query() to flatten the params
@Query() calendarGetBookingDto: CalendarGetBookingDto,
) { ) {
return this.calendarService.getBookings( return this.calendarService.getBookings(
user.user!.userId, user.user!.userId,
eventId, eventId,
startTime, calendarGetBookingDto,
); );
} }
} }

View File

@@ -24,6 +24,10 @@ import { CancelBookingDto } from './dto/cancel-booking.dto';
import { CalendarCreateBookingDto } from './dto/create-booking.dto'; import { CalendarCreateBookingDto } from './dto/create-booking.dto';
import { EventType } from '../entity/event-type.entity'; import { EventType } from '../entity/event-type.entity';
import { CalendarGetBookingDto } from './dto/calendar.get.booking.dto'; import { CalendarGetBookingDto } from './dto/calendar.get.booking.dto';
import {
BookingResponseDto,
CalenderControllerGetBookingResponse,
} from './dto/booking.response.dto';
// --- Type-Safe Maps --- // --- Type-Safe Maps ---
const frequencyMap: Record<string, RRule.Frequency> = { const frequencyMap: Record<string, RRule.Frequency> = {
@@ -602,26 +606,37 @@ export class CalendarService {
userId: number, userId: number,
eventId: number, eventId: number,
queryParams: CalendarGetBookingDto, queryParams: CalendarGetBookingDto,
): Promise<Booking[]> { ): Promise<CalenderControllerGetBookingResponse> {
console.info('getBookings', userId, eventId); console.info('getBookings', userId, eventId);
await Promise.resolve(); await Promise.resolve();
const { page = 1, limit = 0, sortBy, order } = queryParams; const { page = 1, limit = 0 } = queryParams;
const findOptions: FindManyOptions<Booking> = {}; // const { page = 1, limit = 0, sortBy, order } = queryParams;
const findOptions: FindManyOptions<Booking> = {
where: {
eventId: eventId,
occurrenceStartTime: queryParams.startTime,
},
};
const paginated = limit > 0; const paginated = limit > 0;
if (paginated) {
findOptions.skip = (page - 1) * limit;
findOptions.take = limit;
}
if (sortBy && order) {
findOptions.order = { [sortBy]: order };
}
const [data, totalItems] = const [data, totalItems] =
await this.bookingRepository.findAndCount(findOptions); await this.bookingRepository.findAndCount(findOptions);
const dtoData = data.map((booking) => new BookingResponseDto(booking));
if (!paginated) { if (!paginated) {
return { data, total: data.length }; return {
data: dtoData,
meta: {
totalItems,
totalPages: 1,
currentPage: 1,
itemCount: data.length,
itemsPerPage: Number.MAX_SAFE_INTEGER,
},
};
} }
return { return {
data, data: dtoData,
meta: { meta: {
totalItems, totalItems,
itemCount: data.length, itemCount: data.length,
@@ -630,22 +645,6 @@ export class CalendarService {
currentPage: page, currentPage: page,
}, },
}; };
// 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);
return [];
} }
private isValidOccurrence(event: Event, occurrenceTime: Date): boolean { private isValidOccurrence(event: Event, occurrenceTime: Date): boolean {

View File

@@ -0,0 +1,53 @@
import { ApiProperty } from '@nestjs/swagger';
import { Booking } from '../../entity/booking.entity';
import { UserResponseDto } from './user.response.dto';
import { PaginationResponseMetaDto } from './pagination.response.meta.dto';
export class BookingResponseDto {
@ApiProperty()
id: number;
@ApiProperty()
eventId: number;
@ApiProperty()
occurrenceStartTime: Date; // ISO String for the client
@ApiProperty({ required: false })
notes?: string;
@ApiProperty()
reservedSeatsCount: number;
@ApiProperty({ nullable: true })
canceledAt?: string;
// Flattened User Info (Avoid sending the whole User object)
@ApiProperty({
nullable: true,
type: UserResponseDto, // <--- Good practice to be explicit here too
})
user?: UserResponseDto;
constructor(booking: Booking) {
this.id = Number(booking.id); // Handle BigInt conversion
this.eventId = Number(booking.eventId);
this.occurrenceStartTime = booking.occurrenceStartTime;
this.notes = booking.notes;
this.reservedSeatsCount = booking.reservedSeatsCount;
this.canceledAt = booking.canceledAt?.toISOString() || undefined;
// Safety check: Only map user if relation is loaded
if (booking.user) {
// Assuming User entity has firstName/lastName
this.user = new UserResponseDto(booking.user);
}
}
}
export class CalenderControllerGetBookingResponse {
@ApiProperty({ type: [BookingResponseDto] })
data: BookingResponseDto[] = [];
@ApiProperty()
meta: PaginationResponseMetaDto;
}

View File

@@ -1,16 +1,62 @@
import { import {
IsOptional, IsOptional,
IsString, IsString,
IsNumber, IsInt,
IsIn, IsIn,
IsBoolean, IsDate,
Min,
} from 'class-validator'; } from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CalendarGetBookingDto { export class CalendarGetBookingDto {
@IsOptional() @Type(() => Number) @IsNumber() page?: number; @ApiPropertyOptional({
@IsOptional() @Type(() => Number) @IsNumber() limit?: number; description: 'Page number for pagination',
@IsOptional() @IsString() sortBy?: string; default: 1,
@IsOptional() @IsIn(['ASC', 'DESC']) order?: 'ASC' | 'DESC'; minimum: 1,
@IsString() startTime: string; })
@IsOptional()
@Type(() => Number) // Converts the query string '1' to number 1
@IsInt() // Ensures it is an integer (no decimals)
@Min(1) // Ensures it is at least 1
page?: number = 1; // Set default value in TS class
@ApiPropertyOptional({
description: 'Number of items per page',
default: 10,
minimum: 1,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
limit?: number = 10;
@ApiPropertyOptional({
description: 'Field to sort by',
default: 'startTime',
})
@IsOptional()
@IsString()
sortBy?: string = 'startTime';
@ApiPropertyOptional({
description: 'Sort order',
enum: ['ASC', 'DESC'],
default: 'ASC',
})
@IsOptional()
@IsIn(['ASC', 'DESC'])
order?: 'ASC' | 'DESC' = 'ASC';
@ApiProperty({
description: 'The start time for the event occurrence (ISO 8601 string)',
example: '2023-10-27T10:00:00.000Z',
required: true, // Explicitly stating this is required
type: String,
})
// No @IsOptional() here implies it is REQUIRED
@Type(() => Date) // Converts ISO string from URL to JavaScript Date object
@IsDate() // Validates that the result is a valid Date instance
startTime: Date;
} }

View File

@@ -0,0 +1,10 @@
import { PaginatedResponse } from '../../types';
import { ApiProperty } from '@nestjs/swagger';
import { PaginationResponseMetaDto } from './pagination.response.meta.dto';
export class PaginationResponseDto<T> implements PaginatedResponse<T> {
@ApiProperty()
data: T[];
@ApiProperty()
meta: PaginationResponseMetaDto;
}

View File

@@ -0,0 +1,15 @@
import { PaginatedResponseMeta } from '../../types';
import { ApiProperty } from '@nestjs/swagger';
export class PaginationResponseMetaDto implements PaginatedResponseMeta {
@ApiProperty()
totalItems: number;
@ApiProperty()
itemCount: number;
@ApiProperty()
itemsPerPage: number;
@ApiProperty()
totalPages: number;
@ApiProperty()
currentPage: number;
}

View File

@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { User } from '../../entity/user';
export class UserResponseDto {
@ApiProperty()
id: number;
@ApiProperty()
username: string;
constructor(user: User) {
this.id = Number(user.id); // Handle BigInt conversion
this.username = user.username;
}
}

View File

@@ -1,3 +1,6 @@
import { BookingResponseDto } from './calendar/dto/booking.response.dto';
import { PaginationResponseMetaDto } from './calendar/dto/pagination.response.meta.dto';
export interface LoginRequest { export interface LoginRequest {
username: string; username: string;
password: string; password: string;