From 635975207f44be7e164576a3907c744d076c7307 Mon Sep 17 00:00:00 2001 From: Roland Schneider Date: Thu, 20 Nov 2025 15:08:32 +0100 Subject: [PATCH] add event object --- admin/src/app/app.routes.ts | 224 +++++++++++------- admin/src/app/app.ts | 10 + admin/src/app/auth/auth.service.ts | 9 +- admin/src/app/auth/jwt.interceptor.ts | 3 +- .../event-details.component.html | 70 ++++++ .../event-details/event-details.component.ts | 34 +++ .../event-filter/event-filter.component.html | 18 ++ .../event-filter/event-filter.component.ts | 39 +++ .../event-form/event-form.component.html | 51 ++++ .../event-form/event-form.component.ts | 93 ++++++++ .../event-list/event-list.component.html | 76 ++++++ .../event-list/event-list.component.ts | 70 ++++++ .../event-data-provider.service.ts | 26 ++ .../event-table/event-table.component.html | 11 + .../event-table/event-table.component.ts | 142 +++++++++++ .../app/features/events/models/event.model.ts | 15 ++ .../features/events/services/event.service.ts | 85 +++++++ admin/src/types.ts | 12 + server/src/app.module.ts | 7 +- server/src/entity/event.entity.ts | 54 +++++ server/src/event/dto/create-event.dto.ts | 4 + server/src/event/dto/query-event.dto.ts | 9 + server/src/event/dto/update-event.dto.ts | 4 + server/src/event/events.controller.ts | 63 +++++ server/src/event/events.module.ts | 12 + server/src/event/events.service.ts | 103 ++++++++ .../1763106308122-add_event_related_table.ts | 72 ++++++ 27 files changed, 1224 insertions(+), 92 deletions(-) create mode 100644 admin/src/app/features/events/components/event-details/event-details.component.html create mode 100644 admin/src/app/features/events/components/event-details/event-details.component.ts create mode 100644 admin/src/app/features/events/components/event-filter/event-filter.component.html create mode 100644 admin/src/app/features/events/components/event-filter/event-filter.component.ts create mode 100644 admin/src/app/features/events/components/event-form/event-form.component.html create mode 100644 admin/src/app/features/events/components/event-form/event-form.component.ts create mode 100644 admin/src/app/features/events/components/event-list/event-list.component.html create mode 100644 admin/src/app/features/events/components/event-list/event-list.component.ts create mode 100644 admin/src/app/features/events/components/event-table/event-data-provider.service.ts create mode 100644 admin/src/app/features/events/components/event-table/event-table.component.html create mode 100644 admin/src/app/features/events/components/event-table/event-table.component.ts create mode 100644 admin/src/app/features/events/models/event.model.ts create mode 100644 admin/src/app/features/events/services/event.service.ts create mode 100644 server/src/entity/event.entity.ts create mode 100644 server/src/event/dto/create-event.dto.ts create mode 100644 server/src/event/dto/query-event.dto.ts create mode 100644 server/src/event/dto/update-event.dto.ts create mode 100644 server/src/event/events.controller.ts create mode 100644 server/src/event/events.module.ts create mode 100644 server/src/event/events.service.ts create mode 100644 server/src/migration/1763106308122-add_event_related_table.ts diff --git a/admin/src/app/app.routes.ts b/admin/src/app/app.routes.ts index 9b6b821..2b9ed87 100644 --- a/admin/src/app/app.routes.ts +++ b/admin/src/app/app.routes.ts @@ -3,96 +3,144 @@ import { LoginComponent } from './components/login/login.component'; import { AuthGuard } from './auth/auth.guard'; import { HomeComponent } from './components/home/home.component'; import { Welcome } from './page/welcome/welcome'; -import { EventTypeFormComponent } from "./features/event-type/components/event-type-form/event-type-form.component"; -import { EventTypeDetailsComponent } from "./features/event-type/components/event-type-details/event-type-details.component"; -import { EventTypeTableComponent } from "./features/event-type/components/event-type-table/event-type-table.component"; -import { EventTypeListComponent } from "./features/event-type/components/event-type-list/event-type-list.component"; -import { ProductFormComponent } from "./features/products/components/product-form/product-form.component"; -import { ProductDetailsComponent } from "./features/products/components/product-details/product-details.component"; -import { ProductTableComponent } from "./features/products/components/product-table/product-table.component"; -import { ProductListComponent } from "./features/products/components/product-list/product-list.component"; +import { EventTypeFormComponent } from './features/event-type/components/event-type-form/event-type-form.component'; +import { + EventTypeDetailsComponent, +} from './features/event-type/components/event-type-details/event-type-details.component'; +import { EventTypeTableComponent } from './features/event-type/components/event-type-table/event-type-table.component'; +import { EventTypeListComponent } from './features/event-type/components/event-type-list/event-type-list.component'; +import { ProductFormComponent } from './features/products/components/product-form/product-form.component'; +import { ProductDetailsComponent } from './features/products/components/product-details/product-details.component'; +import { ProductTableComponent } from './features/products/components/product-table/product-table.component'; +import { ProductListComponent } from './features/products/components/product-list/product-list.component'; +import { EventFormComponent } from './features/events/components/event-form/event-form.component'; +import { EventDetailsComponent } from './features/events/components/event-details/event-details.component'; +import { EventTableComponent } from './features/events/components/event-table/event-table.component'; +import { EventListComponent } from './features/events/components/event-list/event-list.component'; export const routes: Routes = [ - { - path: 'products', - component: ProductListComponent, - canActivate: [AuthGuard], - data: { - roles: ['admin'], - }, - }, - { - path: 'products/table', - component: ProductTableComponent, - canActivate: [AuthGuard], - data: { - roles: ['admin'], - }, - }, - { - path: 'products/:id', - component: ProductDetailsComponent, - canActivate: [AuthGuard], - data: { - roles: ['admin'], - }, - }, - { - path: 'products/:id/edit', - component: ProductFormComponent, - canActivate: [AuthGuard], - data: { - roles: ['admin'], - }, - }, - { - path: 'products/new', - component: ProductFormComponent, - canActivate: [AuthGuard], - data: { - roles: ['admin'], - }, - }, - { - path: 'event-type', - component: EventTypeListComponent, - canActivate: [AuthGuard], - data: { - roles: ['admin'], - }, - }, - { - path: 'event-type/table', - component: EventTypeTableComponent, - canActivate: [AuthGuard], - data: { - roles: ['admin'], - }, - }, - { - path: 'event-type/:id', - component: EventTypeDetailsComponent, - canActivate: [AuthGuard], - data: { - roles: ['admin'], - }, - }, - { - path: 'event-type/:id/edit', - component: EventTypeFormComponent, - canActivate: [AuthGuard], - data: { - roles: ['admin'], - }, - }, - { - path: 'event-type/new', - component: EventTypeFormComponent, - canActivate: [AuthGuard], - data: { - roles: ['admin'], - }, - }, + { + path: 'events/new', + component: EventFormComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'events', + component: EventListComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'events/table', + component: EventTableComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'events/:id', + component: EventDetailsComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'events/:id/edit', + component: EventFormComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + + { + path: 'products', + component: ProductListComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'products/table', + component: ProductTableComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'products/:id', + component: ProductDetailsComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'products/:id/edit', + component: ProductFormComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'products/new', + component: ProductFormComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'event-type', + component: EventTypeListComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'event-type/table', + component: EventTypeTableComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'event-type/new', + component: EventTypeFormComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'event-type/:id', + component: EventTypeDetailsComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'event-type/:id/edit', + component: EventTypeFormComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { path: 'login', component: LoginComponent }, { path: 'welcome', component: Welcome }, { path: 'home', component: HomeComponent, canActivate: [AuthGuard] }, diff --git a/admin/src/app/app.ts b/admin/src/app/app.ts index 28b8898..8aec2f2 100644 --- a/admin/src/app/app.ts +++ b/admin/src/app/app.ts @@ -27,8 +27,18 @@ export class App { svgIcon: ` +` + }, + { + menuText: 'Események', + targetUrl: '/events/table', + svgIcon: ` + + ` } + + ] } diff --git a/admin/src/app/auth/auth.service.ts b/admin/src/app/auth/auth.service.ts index 03410b8..9759367 100644 --- a/admin/src/app/auth/auth.service.ts +++ b/admin/src/app/auth/auth.service.ts @@ -64,17 +64,24 @@ export class AuthService { } refreshToken(): Observable { + console.info("getting refresh token"); const refreshToken = this.getRefreshToken(); + if (!refreshToken) { + console.info("no refresh token found", refreshToken); // If no refresh token is present, logout and return an error. this.clientSideLogout(); return throwError(() => new Error('No refresh token available')); } + console.info("refresh token found", refreshToken); return this.http.post<{ accessToken: string; refreshToken: string }>(`${this.apiUrl}/refresh`, {}, { headers: { Authorization: `Bearer ${refreshToken}` } }).pipe( - tap((response) => this.setTokens(response.accessToken, response.refreshToken)) + tap((response) => { + this.setTokens(response.accessToken, response.refreshToken); + this.decodeAndSetUser(response.accessToken); + }) ); } diff --git a/admin/src/app/auth/jwt.interceptor.ts b/admin/src/app/auth/jwt.interceptor.ts index a5eb47b..7f0172f 100644 --- a/admin/src/app/auth/jwt.interceptor.ts +++ b/admin/src/app/auth/jwt.interceptor.ts @@ -48,7 +48,7 @@ export class JwtInterceptor implements HttpInterceptor { // this.refreshTokenSubject.next(null); this.refreshTokenSubject = new BehaviorSubject(null); - + console.info("Refreshing tokens"); return this.authService.refreshToken().pipe( switchMap((token: any) => { this.refreshTokenSubject.next(token.accessToken); @@ -60,6 +60,7 @@ export class JwtInterceptor implements HttpInterceptor { return throwError(() => err); }), finalize(() => { + console.info("refreshing done") // When the refresh attempt completes, set isRefreshing to false this.isRefreshing = false; }) diff --git a/admin/src/app/features/events/components/event-details/event-details.component.html b/admin/src/app/features/events/components/event-details/event-details.component.html new file mode 100644 index 0000000..9d36ee7 --- /dev/null +++ b/admin/src/app/features/events/components/event-details/event-details.component.html @@ -0,0 +1,70 @@ + + + +
+ +
+
+

Event Details

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
id{{ event.id }}
event_type_id{{ event.event_type_id }}
title{{ event.title }}
description{{ event.description }}
start_time{{ event.start_time }}
end_time{{ event.end_time }}
timezone{{ event.timezone }}
is_recurring{{ event.is_recurring }}
created_at{{ event.created_at }}
updated_at{{ event.updated_at }}
+
+ + +
+
+
+ + +
+ +
+
+
\ No newline at end of file diff --git a/admin/src/app/features/events/components/event-details/event-details.component.ts b/admin/src/app/features/events/components/event-details/event-details.component.ts new file mode 100644 index 0000000..0aba4c7 --- /dev/null +++ b/admin/src/app/features/events/components/event-details/event-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 { Event } from '../../models/event.model'; +import { EventService } from '../../services/event.service'; + +@Component({ + selector: 'app-event-details', + templateUrl: './event-details.component.html', + standalone: true, + imports: [CommonModule, RouterModule], +}) +export class EventDetailsComponent implements OnInit { + event$!: Observable; + + constructor( + private route: ActivatedRoute, + private eventService: EventService + ) {} + + ngOnInit(): void { + this.event$ = this.route.params.pipe( + switchMap(params => { + const id = params['id']; + return this.eventService.findOne(id); + }) + ); + } +} \ No newline at end of file diff --git a/admin/src/app/features/events/components/event-filter/event-filter.component.html b/admin/src/app/features/events/components/event-filter/event-filter.component.html new file mode 100644 index 0000000..04cef90 --- /dev/null +++ b/admin/src/app/features/events/components/event-filter/event-filter.component.html @@ -0,0 +1,18 @@ + + +
+
+
+
+ +
+
+ +
+
+ +
+ +
+
+
\ No newline at end of file diff --git a/admin/src/app/features/events/components/event-filter/event-filter.component.ts b/admin/src/app/features/events/components/event-filter/event-filter.component.ts new file mode 100644 index 0000000..25f0dc0 --- /dev/null +++ b/admin/src/app/features/events/components/event-filter/event-filter.component.ts @@ -0,0 +1,39 @@ +// 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-event-filter', + templateUrl: './event-filter.component.html', + standalone: true, + imports: [ReactiveFormsModule] +}) +export class EventFilterComponent { + @Output() filterChanged = new EventEmitter(); + filterForm: FormGroup; + + constructor(private fb: FormBuilder) { + this.filterForm = this.fb.group({ + title: [''], + description: [''], + timezone: [''] + }); + + 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/events/components/event-form/event-form.component.html b/admin/src/app/features/events/components/event-form/event-form.component.html new file mode 100644 index 0000000..6f9de63 --- /dev/null +++ b/admin/src/app/features/events/components/event-form/event-form.component.html @@ -0,0 +1,51 @@ + + + +
+
+
+

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

+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+ +
+
+ +
+ Cancel + +
+
+
+
+
\ No newline at end of file diff --git a/admin/src/app/features/events/components/event-form/event-form.component.ts b/admin/src/app/features/events/components/event-form/event-form.component.ts new file mode 100644 index 0000000..00e2bc4 --- /dev/null +++ b/admin/src/app/features/events/components/event-form/event-form.component.ts @@ -0,0 +1,93 @@ +// 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 { Event } from '../../models/event.model'; +import { EventService } from '../../services/event.service'; + +@Component({ + selector: 'app-event-form', + templateUrl: './event-form.component.html', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, RouterModule], +}) +export class EventFormComponent implements OnInit { + form: FormGroup; + isEditMode = false; + id: number | null = null; + + private numericFields = ["event_type_id"]; + + constructor( + private fb: FormBuilder, + private route: ActivatedRoute, + private router: Router, + private eventService: EventService + ) { + this.form = this.fb.group({ + event_type_id: [null], + title: [null], + description: [null], + start_time: [null], + end_time: [null], + timezone: [null], + is_recurring: [null], + created_at: [null], + updated_at: [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.eventService.findOne(this.id); + } + return of(null); + }) + ).subscribe(event => { + if (event) { + this.form.patchValue(event); + } + }); + } + + 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.eventService.update(this.id, payload); + } else { + action$ = this.eventService.create(payload); + } + + action$.subscribe({ + next: () => this.router.navigate(['/events']), + error: (err) => console.error('Failed to save event', err) + }); + } +} \ No newline at end of file diff --git a/admin/src/app/features/events/components/event-list/event-list.component.html b/admin/src/app/features/events/components/event-list/event-list.component.html new file mode 100644 index 0000000..6617194 --- /dev/null +++ b/admin/src/app/features/events/components/event-list/event-list.component.html @@ -0,0 +1,76 @@ + + + +
+
+

Events

+ Create New +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
idevent_type_idtitledescriptionstart_timeend_timetimezoneis_recurringcreated_atupdated_atActions
{{ item.id }}{{ item.event_type_id }}{{ item.title }}{{ item.description }}{{ item.start_time }}{{ item.end_time }}{{ item.timezone }}{{ item.is_recurring }}{{ item.created_at }}{{ item.updated_at }} + View + Edit + +
No events found.
+
+ + +
+
+ + + +
+
+
+ + +
+ +
+
+
\ No newline at end of file diff --git a/admin/src/app/features/events/components/event-list/event-list.component.ts b/admin/src/app/features/events/components/event-list/event-list.component.ts new file mode 100644 index 0000000..750d873 --- /dev/null +++ b/admin/src/app/features/events/components/event-list/event-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 { Event } from '../../models/event.model'; +import { EventService } from '../../services/event.service'; +import { EventFilterComponent } from '../event-filter/event-filter.component'; +import { PaginatedResponse } from '../../../../../types'; + + +@Component({ + selector: 'app-event-list', + templateUrl: './event-list.component.html', + standalone: true, + imports: [CommonModule,RouterModule, EventFilterComponent], +}) +export class EventListComponent implements OnInit { + + private refresh$ = new BehaviorSubject(undefined); + private filter$ = new BehaviorSubject({}); + private page$ = new BehaviorSubject(1); + + paginatedResponse$!: Observable>; + + constructor(private eventService: EventService) { } + + 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.eventService.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.eventService.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/events/components/event-table/event-data-provider.service.ts b/admin/src/app/features/events/components/event-table/event-data-provider.service.ts new file mode 100644 index 0000000..4efa84e --- /dev/null +++ b/admin/src/app/features/events/components/event-table/event-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 { Event } from '../../models/event.model'; +import { map, Observable } from 'rxjs'; +import { EventService } from '../../services/event.service'; + +@Injectable({ + providedIn: 'root', +}) +export class EventDataProvider implements DataProvider { + private eventService = inject(EventService); + + getData(options?: GetDataOptions): Observable> { + const {q,page,limit} = options?.params ?? {}; + // The generic table's params are compatible with our NestJS Query DTO + return this.eventService.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/events/components/event-table/event-table.component.html b/admin/src/app/features/events/components/event-table/event-table.component.html new file mode 100644 index 0000000..1a2063f --- /dev/null +++ b/admin/src/app/features/events/components/event-table/event-table.component.html @@ -0,0 +1,11 @@ + + + +
+
+

Events (Generic Table)

+ Create New +
+ + +
\ No newline at end of file diff --git a/admin/src/app/features/events/components/event-table/event-table.component.ts b/admin/src/app/features/events/components/event-table/event-table.component.ts new file mode 100644 index 0000000..e414afa --- /dev/null +++ b/admin/src/app/features/events/components/event-table/event-table.component.ts @@ -0,0 +1,142 @@ +// 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 { Event } from '../../models/event.model'; +import { EventDataProvider } from './event-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 { EventService } from '../../services/event.service'; +import { BehaviorSubject } from 'rxjs'; + +@Component({ + selector: 'app-event-table', + standalone: true, + imports: [GenericTable, RouterModule], + templateUrl: './event-table.component.html', +}) +export class EventTableComponent 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; + + eventDataProvider = inject(EventDataProvider); + eventService = inject(EventService); + + ngOnInit(): void { + const actionHandler = (action: ActionDefinition, item: Event) => { + switch (action.action) { + case 'view': + this.router.navigate(['/events', item?.id]); + break; + case 'edit': + this.router.navigate(['/events', 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.eventDataProvider, + columns: [ + { + attribute: 'event_type_id', + headerCell: true, + valueCell: true, + }, + { + attribute: 'title', + headerCell: true, + valueCell: true, + }, + { + attribute: 'description', + headerCell: true, + valueCell: true, + }, + { + attribute: 'start_time', + headerCell: true, + valueCell: true, + }, + { + attribute: 'end_time', + headerCell: true, + valueCell: true, + }, + { + attribute: 'timezone', + headerCell: true, + valueCell: true, + }, + { + attribute: 'is_recurring', + headerCell: true, + valueCell: { + value: item => (item as any)?.is_recurring ? 'yes' : 'no', + }, + }, + { + attribute: 'created_at', + headerCell: true, + valueCell: true, + }, + { + attribute: 'updated_at', + 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: 'event-table-container', + }; + } + + deleteItem(id: number): void { + if (confirm('Are you sure you want to delete this item?')) { + this.eventService.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/events/models/event.model.ts b/admin/src/app/features/events/models/event.model.ts new file mode 100644 index 0000000..0e95fd3 --- /dev/null +++ b/admin/src/app/features/events/models/event.model.ts @@ -0,0 +1,15 @@ +// dvbooking-cli/src/templates/angular/model.ts.tpl + +// Generated by the CLI +export interface Event { + id: number; + event_type_id: number; + title: string; + description: string; + start_time: Date; + end_time: Date; + timezone: string; + is_recurring: boolean; + created_at: Date; + updated_at: Date; +} diff --git a/admin/src/app/features/events/services/event.service.ts b/admin/src/app/features/events/services/event.service.ts new file mode 100644 index 0000000..2ccdcb9 --- /dev/null +++ b/admin/src/app/features/events/services/event.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 { Event } from '../models/event.model'; +import { ConfigurationService } from '../../../services/configuration.service'; +import { PaginatedResponse } from '../../../../types'; + + +export interface SearchResponse { + data: T[]; + total: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class EventService { + private readonly apiUrl: string; + + constructor( + private http: HttpClient, + private configService: ConfigurationService + ) { + this.apiUrl = `${this.configService.getApiUrl()}/events`; + } + + /** + * 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/admin/src/types.ts b/admin/src/types.ts index 8b94e90..a46ce60 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -2,3 +2,15 @@ export interface AppConfig{ apiUrl: string; } + + +export interface PaginatedResponse { + data: T[]; + meta: { + totalItems: number; + itemCount: number; + itemsPerPage: number; + totalPages: number; + currentPage: number; + }; +} diff --git a/server/src/app.module.ts b/server/src/app.module.ts index ae1f07e..0e35f1f 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -13,6 +13,8 @@ import { EventType } from './entity/event-type.entity'; import { EventTypesModule } from './event-type/event-type.module'; import { Product } from './entity/product.entity'; import { ProductsModule } from './product/products.module'; +import { Event } from "./entity/event.entity"; +import { EventsModule } from "./event/events.module"; const moduleTypeOrm = TypeOrmModule.forRootAsync({ imports: [ConfigModule], @@ -25,7 +27,7 @@ const moduleTypeOrm = TypeOrmModule.forRootAsync({ username: configService.get('DATABASE_USER'), password: configService.get('DATABASE_PASS'), database: configService.get('DATABASE_NAME'), - entities: [User, UserGroup, UserRole, EventType, Product], + entities: [User, UserGroup, UserRole, EventType, Product, Event], logging: true, // synchronize: true, }; @@ -41,7 +43,8 @@ const moduleTypeOrm = TypeOrmModule.forRootAsync({ LoggerModule, EventTypesModule, ProductsModule, - ], + EventsModule + ], controllers: [AppController], providers: [AppService], }) diff --git a/server/src/entity/event.entity.ts b/server/src/entity/event.entity.ts new file mode 100644 index 0000000..cc8e4f6 --- /dev/null +++ b/server/src/entity/event.entity.ts @@ -0,0 +1,54 @@ +// dvbooking-cli/src/templates/nestjs/entity.ts.tpl + +import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; +import { + IsString, + IsNumber, + IsBoolean, + IsDate, + IsOptional, +} from 'class-validator'; + +@Entity({ name: 'events' }) +export class Event { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'bigint', nullable: true }) + @IsOptional() + @IsNumber() + event_type_id: number | null; + + @Column() + @IsString() + title: string; + + @Column({ type: 'text', nullable: true }) + @IsOptional() + @IsString() + description: string | null; + + @Column() + @IsDate() + start_time: Date; + + @Column() + @IsDate() + end_time: Date; + + @Column() + @IsString() + timezone: string; + + @Column({ default: false }) + @IsBoolean() + is_recurring: boolean = false; + + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + @IsDate() + created_at: Date; + + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + @IsDate() + updated_at: Date; +} diff --git a/server/src/event/dto/create-event.dto.ts b/server/src/event/dto/create-event.dto.ts new file mode 100644 index 0000000..bdf8ad6 --- /dev/null +++ b/server/src/event/dto/create-event.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from '@nestjs/mapped-types'; +import { Event } from '../../entity/event.entity'; + +export class CreateEventDto extends OmitType(Event, ['id']) {} \ No newline at end of file diff --git a/server/src/event/dto/query-event.dto.ts b/server/src/event/dto/query-event.dto.ts new file mode 100644 index 0000000..6061dc9 --- /dev/null +++ b/server/src/event/dto/query-event.dto.ts @@ -0,0 +1,9 @@ +import { IsOptional, IsString, IsNumber, IsIn, IsBoolean } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class QueryEventDto { + @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/event/dto/update-event.dto.ts b/server/src/event/dto/update-event.dto.ts new file mode 100644 index 0000000..4f65a86 --- /dev/null +++ b/server/src/event/dto/update-event.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateEventDto } from './create-event.dto'; + +export class UpdateEventDto extends PartialType(CreateEventDto) {} \ No newline at end of file diff --git a/server/src/event/events.controller.ts b/server/src/event/events.controller.ts new file mode 100644 index 0000000..572dc88 --- /dev/null +++ b/server/src/event/events.controller.ts @@ -0,0 +1,63 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + ParseIntPipe, + DefaultValuePipe, + UseGuards, +} from '@nestjs/common'; +import { EventsService } from './events.service'; +import { CreateEventDto } from './dto/create-event.dto'; +import { UpdateEventDto } from './dto/update-event.dto'; +import { QueryEventDto } from './dto/query-event.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('events') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(Role.Admin) +export class EventsController { + constructor(private readonly eventsService: EventsService) {} + + @Post() + create(@Body() createEventDto: CreateEventDto) { + return this.eventsService.create(createEventDto); + } + + @Get() + findAll(@Query() queryParams: QueryEventDto) { + return this.eventsService.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.eventsService.search(term, { page, limit }); + } + + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.eventsService.findOne(id); + } + + @Patch(':id') + update(@Param('id', ParseIntPipe) id: number, @Body() updateEventDto: UpdateEventDto) { + return this.eventsService.update(id, updateEventDto); + } + + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.eventsService.remove(id); + } +} \ No newline at end of file diff --git a/server/src/event/events.module.ts b/server/src/event/events.module.ts new file mode 100644 index 0000000..11dba44 --- /dev/null +++ b/server/src/event/events.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { EventsService } from './events.service'; +import { EventsController } from './events.controller'; +import { Event } from '../entity/event.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Event])], + controllers: [EventsController], + providers: [EventsService], +}) +export class EventsModule {} \ No newline at end of file diff --git a/server/src/event/events.service.ts b/server/src/event/events.service.ts new file mode 100644 index 0000000..507c7ce --- /dev/null +++ b/server/src/event/events.service.ts @@ -0,0 +1,103 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, FindManyOptions, FindOptionsWhere, ILike } from 'typeorm'; +import { CreateEventDto } from './dto/create-event.dto'; +import { UpdateEventDto } from './dto/update-event.dto'; +import { QueryEventDto } from './dto/query-event.dto'; +import { Event } from '../entity/event.entity'; + +type QueryConfigItem = { + param: keyof Omit; + dbField: keyof Event; + operator: 'equals' | 'like'; +}; + +@Injectable() +export class EventsService { + constructor( + @InjectRepository(Event) + private readonly eventRepository: Repository, + ) {} + + private readonly searchableFields: (keyof Event)[] = [ + 'title', + 'description', + 'timezone' + ]; + + create(createEventDto: CreateEventDto) { + const newRecord = this.eventRepository.create(createEventDto); + return this.eventRepository.save(newRecord); + } + + async findAll(queryParams: QueryEventDto) { + 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.eventRepository.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.eventRepository.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.eventRepository.findOneBy({ id: id as any }); + if (!record) { + throw new NotFoundException(`Event with ID ${id} not found`); + } + return record; + } + + async update(id: number, updateEventDto: UpdateEventDto) { + const record = await this.findOne(id); + Object.assign(record, updateEventDto); + return this.eventRepository.save(record); + } + + async remove(id: number) { + const result = await this.eventRepository.delete(id); + if (result.affected === 0) { + throw new NotFoundException(`Event with ID ${id} not found`); + } + return { deleted: true, id }; + } +} \ No newline at end of file diff --git a/server/src/migration/1763106308122-add_event_related_table.ts b/server/src/migration/1763106308122-add_event_related_table.ts new file mode 100644 index 0000000..af1521d --- /dev/null +++ b/server/src/migration/1763106308122-add_event_related_table.ts @@ -0,0 +1,72 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddEventRelatedTable1763106308122 implements MigrationInterface { + name = 'AddEventRelatedTable1763106308122'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE events ( + id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + event_type_id BIGINT, -- This is the new nullable foreign key column. + title VARCHAR(255) NOT NULL, + description TEXT, + start_time TIMESTAMP WITH TIME ZONE NOT NULL, + end_time TIMESTAMP WITH TIME ZONE NOT NULL, + timezone VARCHAR(50) NOT NULL, + is_recurring BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + FOREIGN KEY (event_type_id) REFERENCES event_type(id) ON DELETE SET NULL + );`, + ); + + await queryRunner.query( + `CREATE TABLE recurrence_rules ( + id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + event_id BIGINT NOT NULL, + frequency VARCHAR(10) NOT NULL, + interval INTEGER NOT NULL DEFAULT 1, + end_date DATE, + count INTEGER, + by_day VARCHAR(20), + by_month_day INTEGER, + by_month INTEGER, + FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE + );`, + ); + + await queryRunner.query( + `CREATE TABLE event_exceptions ( + id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + event_id BIGINT NOT NULL, + original_start_time TIMESTAMP WITH TIME ZONE NOT NULL, + is_cancelled BOOLEAN NOT NULL DEFAULT FALSE, + new_start_time TIMESTAMP WITH TIME ZONE, + new_end_time TIMESTAMP WITH TIME ZONE, + title VARCHAR(255), + description TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE + );`, + ); + + await queryRunner.query( + `CREATE INDEX idx_events_event_type_id ON events (event_type_id);`, + ); + + await queryRunner.query( + `CREATE INDEX idx_recurrence_rules_event_id ON recurrence_rules (event_id);`, + ); + + await queryRunner.query( + `CREATE INDEX idx_event_exceptions_event_id ON event_exceptions (event_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_event_exceptions_original_start_time ON event_exceptions (original_start_time);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "event_type"`); + } +}