From e9cf678a2bbceb28029266e74874e70417b2988f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sat, 24 Jan 2026 16:51:22 +0100 Subject: [PATCH 01/38] Fix note --- src/Turnierplan.App/Endpoints/Images/UploadImageEndpoint.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Turnierplan.App/Endpoints/Images/UploadImageEndpoint.cs b/src/Turnierplan.App/Endpoints/Images/UploadImageEndpoint.cs index e03f37df..7d4d4ad0 100644 --- a/src/Turnierplan.App/Endpoints/Images/UploadImageEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Images/UploadImageEndpoint.cs @@ -24,8 +24,7 @@ internal sealed class UploadImageEndpoint : EndpointBase protected override Delegate Handler => Handle; private static async Task Handle( - // Note the usage of [FromForm] instead of [FromBody] - [FromForm] UploadImageEndpointRequest request, + [FromForm] UploadImageEndpointRequest request, // Note: We use [FromForm] instead of [FromBody] IOrganizationRepository organizationRepository, IAccessValidator accessValidator, IImageStorage imageStorage, From 01475882f12901c73b1e05d63b1788e6ffe3b30e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sat, 24 Jan 2026 17:19:41 +0100 Subject: [PATCH 02/38] Add endpoint --- .../Endpoints/Images/SetImageNameEndpoint.cs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/Turnierplan.App/Endpoints/Images/SetImageNameEndpoint.cs diff --git a/src/Turnierplan.App/Endpoints/Images/SetImageNameEndpoint.cs b/src/Turnierplan.App/Endpoints/Images/SetImageNameEndpoint.cs new file mode 100644 index 00000000..a04c4491 --- /dev/null +++ b/src/Turnierplan.App/Endpoints/Images/SetImageNameEndpoint.cs @@ -0,0 +1,64 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using Turnierplan.App.Extensions; +using Turnierplan.App.Security; +using Turnierplan.Core.PublicId; +using Turnierplan.Dal.Repositories; + +namespace Turnierplan.App.Endpoints.Images; + +internal sealed class SetImageNameEndpoint : EndpointBase +{ + protected override HttpMethod Method => HttpMethod.Patch; + + protected override string Route => "/api/images/{id}/name"; + + protected override Delegate Handler => Handle; + + private static async Task Handle( + [FromRoute] PublicId id, + [FromBody] SetImageNameEndpointRequest request, + IImageRepository repository, + IAccessValidator accessValidator, + CancellationToken cancellationToken) + { + if (!Validator.Instance.ValidateAndGetResult(request, out var result)) + { + return result; + } + + var image = await repository.GetByPublicIdAsync(id); + + if (image is null) + { + return Results.NotFound(); + } + + if (!accessValidator.IsActionAllowed(image, Actions.GenericWrite)) + { + return Results.Forbid(); + } + + image.Name = request.Name; + + await repository.UnitOfWork.SaveChangesAsync(cancellationToken); + + return Results.NoContent(); + } + + public sealed record SetImageNameEndpointRequest + { + public required string Name { get; init; } + } + + private sealed class Validator : AbstractValidator + { + public static readonly Validator Instance = new(); + + private Validator() + { + RuleFor(x => x.Name) + .NotEmpty(); + } + } +} From 2a9acc338f3edf739c794b4bb3a0babe5906948f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 25 Jan 2026 10:20:12 +0100 Subject: [PATCH 03/38] save changes --- src/Turnierplan.App/Client/src/app/i18n/de.ts | 2 ++ .../image-manager.component.html | 1 + .../image-manager/image-manager.component.ts | 8 +++++ .../view-organization.component.html | 12 ++++++++ .../view-organization.component.ts | 30 +++++++++++++++++-- .../Endpoints/Images/GetImagesEndpoint.cs | 6 ++-- 6 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html create mode 100644 src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index 49d6da23..ee8a94b4 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -170,6 +170,7 @@ export const de = { Tournaments: 'Turniere', Venues: 'Spielstätten', PlanningRealms: 'Turnierplaner', + Images: 'Bilder', ApiKeys: 'API-Schlüssel', Settings: 'Einstellungen' }, @@ -177,6 +178,7 @@ export const de = { TournamentCount: 'Turniere', VenueCount: 'Spielstätten', PlanningRealmCount: 'Turnierplaner', + ImagesCount: 'Bilder', ApiKeyCount: 'API-Schlüssel' }, NewTournament: 'Neues Turnier', diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html new file mode 100644 index 00000000..75fd52b6 --- /dev/null +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html @@ -0,0 +1 @@ +

image-manager works!

diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts new file mode 100644 index 00000000..89e27ef5 --- /dev/null +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'tp-image-manager', + imports: [], + templateUrl: './image-manager.component.html' +}) +export class ImageManagerComponent {} diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html index 0a7a0df9..f5d6b280 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html @@ -130,6 +130,18 @@ } } + @case (5) { + @if (isLoadingImages || !images) { + + } @else { +
+ +
+
+ +
+ } + } @case (2) { @if (isLoadingApiKeys || !apiKeys) { diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.ts b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.ts index 3b99e2ba..24a5970e 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.ts @@ -42,6 +42,9 @@ import { deleteApiKey } from '../../../api/fn/api-keys/delete-api-key'; import { setApiKeyStatus } from '../../../api/fn/api-keys/set-api-key-status'; import { getApiKeys } from '../../../api/fn/api-keys/get-api-keys'; import { E2eDirective } from '../../../core/directives/e2e.directive'; +import { ImageManagerComponent } from '../../components/image-manager/image-manager.component'; +import { ImageDto } from '../../../api/models/image-dto'; +import { getImages } from '../../../api/fn/images/get-images'; @Component({ templateUrl: './view-organization.component.html', @@ -69,13 +72,15 @@ import { E2eDirective } from '../../../core/directives/e2e.directive'; TranslatePipe, TranslateDatePipe, IdWidgetComponent, - E2eDirective + E2eDirective, + ImageManagerComponent ] }) export class ViewOrganizationComponent implements OnInit, OnDestroy { private static readonly venuesPageId = 1; private static readonly apiKeysPageId = 2; private static readonly planningRealmsPageId = 4; + private static readonly imagesPageId = 5; protected readonly Actions = Actions; @@ -84,11 +89,13 @@ export class ViewOrganizationComponent implements OnInit, OnDestroy { protected tournaments?: TournamentHeaderDto[]; protected venues?: VenueDto[]; protected planningRealms?: PlanningRealmHeaderDto[]; + protected images?: ImageDto[]; protected apiKeys?: ApiKeyDto[]; protected displayApiKeyUsage?: string; protected isLoadingVenues = false; - protected isLoadingApiKeys = false; protected isLoadingPlanningRealms = false; + protected isLoadingImages = false; + protected isLoadingApiKeys = false; protected isUpdatingName = false; @@ -109,6 +116,11 @@ export class ViewOrganizationComponent implements OnInit, OnDestroy { title: 'Portal.ViewOrganization.Pages.PlanningRealms', icon: 'bi-ticket-perforated' }, + { + id: ViewOrganizationComponent.imagesPageId, + title: 'Portal.ViewOrganization.Pages.Images', + icon: 'bi-image' + }, { id: ViewOrganizationComponent.apiKeysPageId, title: 'Portal.ViewOrganization.Pages.ApiKeys', @@ -209,6 +221,20 @@ export class ViewOrganizationComponent implements OnInit, OnDestroy { }); } + if (number === ViewOrganizationComponent.imagesPageId && !this.images && !this.isLoadingImages) { + // Load images only when the page is opened + this.isLoadingImages = true; + this.turnierplanApi.invoke(getImages, { organizationId: this.organization.id }).subscribe({ + next: (images) => { + this.images = images; + this.isLoadingImages = false; + }, + error: (error) => { + this.loadingState = { isLoading: false, error: error }; + } + }); + } + if (number === ViewOrganizationComponent.apiKeysPageId && !this.apiKeys && !this.isLoadingApiKeys) { // Load API keys only when the page is opened this.loadApiKeys().subscribe({ diff --git a/src/Turnierplan.App/Endpoints/Images/GetImagesEndpoint.cs b/src/Turnierplan.App/Endpoints/Images/GetImagesEndpoint.cs index 10339f4f..9d6a5321 100644 --- a/src/Turnierplan.App/Endpoints/Images/GetImagesEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Images/GetImagesEndpoint.cs @@ -18,7 +18,7 @@ internal sealed class GetImagesEndpoint : EndpointBase> private static async Task Handle( [FromQuery] PublicId organizationId, - [FromQuery] ImageType imageType, + [FromQuery] ImageType? imageType, IOrganizationRepository repository, IAccessValidator accessValidator, IMapper mapper) @@ -35,7 +35,9 @@ private static async Task Handle( return Results.Forbid(); } - var filteredImages = organization.Images.Where(x => x.Type == imageType).ToList(); + var filteredImages = imageType.HasValue + ? organization.Images.Where(x => x.Type == imageType).ToList() + : organization.Images; foreach (var image in filteredImages) { From 6f4cd2ca3dcfc6ba4277cea152931d837b10b906 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sat, 31 Jan 2026 20:54:53 +0100 Subject: [PATCH 04/38] Add WIP image manager & new route for image upload --- src/Turnierplan.App/Client/src/app/i18n/de.ts | 12 +++++ .../image-manager.component.html | 50 ++++++++++++++++++- .../image-manager/image-manager.component.ts | 25 ++++++++-- .../upload-image/upload-image.component.html | 1 + .../upload-image/upload-image.component.ts | 9 ++++ .../view-organization.component.html | 10 +++- .../Client/src/app/portal/portal.routes.ts | 5 ++ 7 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.html create mode 100644 src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.ts diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index ee8a94b4..7b8893db 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -184,6 +184,7 @@ export const de = { NewTournament: 'Neues Turnier', NewVenue: 'Neue Spielstätte', NewPlanningRealm: 'Neuer Turnierplaner', + UploadImage: 'Bild hochladen', NewApiKey: 'Neuer API-Schlüssel', NoTournaments: 'In dieser Organisation gibt es aktuell keine Turniere.\nErstellen Sie ein Turner mit der Schaltfläche oben rechts.', NoVenues: @@ -213,6 +214,17 @@ export const de = { NoDescription: 'Keine Beschreibung vorhanden', Open: 'öffnen' }, + Images: { + Preview: 'Vorschau', + Name: 'Name', + Dimensions: '{{w}} x {{h}} px', + FileSize: '{{size}} KB', + NoImages: 'Es sind aktuell keine Bilder vorhanden.', + Rename: { + Title: 'Bild umbenennen', + EnterNewName: 'Geben Sie den neuen Namen für das Bild ein:' + } + }, ApiKeys: { TableLabel: 'API Schlüssel dieser Organisation', Id: 'ID', diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html index 75fd52b6..6b43a9c7 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html @@ -1 +1,49 @@ -

image-manager works!

+
+ + + + + + + + + + @for (image of images; track image.id) { + @let writeAllowed = (authorizationService.isActionAllowed$(image.id, Actions.GenericWrite) | async) ?? false; + + + + + + + + + } @empty { + + + + } + +
+ +
+
+
+ @if (writeAllowed) { + + } + + {{ image.name }} +
+
diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts index 89e27ef5..683c8b24 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts @@ -1,8 +1,27 @@ -import { Component } from '@angular/core'; +import { Component, inject, Input } from '@angular/core'; +import { ImageDto } from '../../../api/models/image-dto'; +import { TranslateDirective } from '@ngx-translate/core'; +import { AsyncPipe, DecimalPipe } from '@angular/common'; +import { Actions } from '../../../generated/actions'; +import { AuthorizationService } from '../../../core/services/authorization.service'; +import { RenameButtonComponent } from '../rename-button/rename-button.component'; +import { CopyToClipboardComponent } from '../copy-to-clipboard/copy-to-clipboard.component'; +import { TooltipIconComponent } from '../tooltip-icon/tooltip-icon.component'; @Component({ selector: 'tp-image-manager', - imports: [], + imports: [TranslateDirective, AsyncPipe, RenameButtonComponent, CopyToClipboardComponent, DecimalPipe, TooltipIconComponent], templateUrl: './image-manager.component.html' }) -export class ImageManagerComponent {} +export class ImageManagerComponent { + @Input({ required: true }) + public images: ImageDto[] = []; + + protected readonly authorizationService = inject(AuthorizationService); + protected readonly Actions = Actions; + + protected renameImage(id: string, name: string): void { + alert(name); + // TODO: Implement renaming the image + } +} diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.html new file mode 100644 index 00000000..388803f0 --- /dev/null +++ b/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.html @@ -0,0 +1 @@ +

upload-image works!

diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.ts b/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.ts new file mode 100644 index 00000000..8a959d7b --- /dev/null +++ b/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + imports: [], + templateUrl: './upload-image.component.html' +}) +export class UploadImageComponent { + // TODO: Implement this component +} diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html index f5d6b280..22327cb7 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html @@ -34,6 +34,14 @@ [icon]="'plus-circle'" [routerLink]="'create/planning-realm'" /> } + @case (5) { + + } @case (2) {
- +
} } diff --git a/src/Turnierplan.App/Client/src/app/portal/portal.routes.ts b/src/Turnierplan.App/Client/src/app/portal/portal.routes.ts index 97d16a65..df381dfd 100644 --- a/src/Turnierplan.App/Client/src/app/portal/portal.routes.ts +++ b/src/Turnierplan.App/Client/src/app/portal/portal.routes.ts @@ -18,6 +18,7 @@ import { ViewTournamentComponent } from './pages/view-tournament/view-tournament import { ConfigureTournamentComponent } from './pages/configure-tournament/configure-tournament.component'; import { EditMatchPlanComponent } from './pages/edit-match-plan/edit-match-plan.component'; import { ViewVenueComponent } from './pages/view-venue/view-venue.component'; +import { UploadImageComponent } from './pages/upload-image/upload-image.component'; export const portalRoutes: Routes = [ { @@ -50,6 +51,10 @@ export const portalRoutes: Routes = [ path: 'organization/:id/create/api-key', component: CreateApiKeyComponent }, + { + path: 'organization/:id/upload/image', + component: UploadImageComponent + }, { path: 'organization/:id/create/planning-realm', component: CreatePlanningRealmComponent From 088f020ee9d64640ed05c0d94ca24c12a3cf10ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 09:03:01 +0100 Subject: [PATCH 05/38] Fix missing assignments --- src/Turnierplan.Dal/Repositories/OrganizationRepository.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Turnierplan.Dal/Repositories/OrganizationRepository.cs b/src/Turnierplan.Dal/Repositories/OrganizationRepository.cs index b365d523..e9433571 100644 --- a/src/Turnierplan.Dal/Repositories/OrganizationRepository.cs +++ b/src/Turnierplan.Dal/Repositories/OrganizationRepository.cs @@ -46,7 +46,7 @@ internal sealed class OrganizationRepository(TurnierplanContext context) : Repos if (includes.HasFlag(IOrganizationRepository.Includes.Tournaments)) { - query = query.Include(x => x.Tournaments).ThenInclude(x => x.Folder); + query = query.Include(x => x.Tournaments).ThenInclude(x => x.Folder).ThenInclude(x => x!.RoleAssignments); } if (includes.HasFlag(IOrganizationRepository.Includes.Venues)) @@ -61,7 +61,7 @@ internal sealed class OrganizationRepository(TurnierplanContext context) : Repos if (includes.HasFlag(IOrganizationRepository.Includes.Images)) { - query = query.Include(x => x.Images); + query = query.Include(x => x.Images).ThenInclude(x => x.RoleAssignments); } if (includes.HasFlag(IOrganizationRepository.Includes.ApiKeys)) From 62ef42bd62ded966e2af7a428f02ff33af169ff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 09:18:38 +0100 Subject: [PATCH 06/38] Implement rename button + add delete/rbac buttons --- src/Turnierplan.App/Client/src/app/i18n/de.ts | 4 + .../image-manager.component.html | 26 +++++-- .../image-manager/image-manager.component.ts | 77 +++++++++++++++++-- .../view-organization.component.html | 2 +- 4 files changed, 96 insertions(+), 13 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index 7b8893db..cf1b538e 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -223,6 +223,10 @@ export const de = { Rename: { Title: 'Bild umbenennen', EnterNewName: 'Geben Sie den neuen Namen für das Bild ein:' + }, + DeleteToast: { + Title: 'Bild wurde gelöscht', + Message: 'Das Bild wurde gelöscht.' } }, ApiKeys: { diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html index 6b43a9c7..ac30b859 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html @@ -5,10 +5,12 @@ + + - @for (image of images; track image.id) { + @for (image of _images; track image.id) { @let writeAllowed = (authorizationService.isActionAllowed$(image.id, Actions.GenericWrite) | async) ?? false; @@ -23,8 +25,10 @@ translate="Portal.ViewOrganization.Images.FileSize" [translateParams]="{ size: image.fileSize / 1000 | number: '1.1-1' : 'de' }"> - - @if (writeAllowed) { + + @if (image.isUpdatingName) { + + } @else if (writeAllowed) { {{ image.name }} - + + + + + @if (writeAllowed) { + + } + } @empty { - + + } diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts index 683c8b24..d99d9d2c 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts @@ -1,27 +1,90 @@ -import { Component, inject, Input } from '@angular/core'; +import { Component, EventEmitter, inject, Input, Output } from '@angular/core'; import { ImageDto } from '../../../api/models/image-dto'; import { TranslateDirective } from '@ngx-translate/core'; import { AsyncPipe, DecimalPipe } from '@angular/common'; import { Actions } from '../../../generated/actions'; import { AuthorizationService } from '../../../core/services/authorization.service'; import { RenameButtonComponent } from '../rename-button/rename-button.component'; -import { CopyToClipboardComponent } from '../copy-to-clipboard/copy-to-clipboard.component'; -import { TooltipIconComponent } from '../tooltip-icon/tooltip-icon.component'; +import { TurnierplanApi } from '../../../api/turnierplan-api'; +import { SmallSpinnerComponent } from '../../../core/components/small-spinner/small-spinner.component'; +import { setImageName } from '../../../api/fn/images/set-image-name'; +import { IsActionAllowedDirective } from '../../directives/is-action-allowed.directive'; +import { RbacWidgetComponent } from '../rbac-widget/rbac-widget.component'; +import { DeleteButtonComponent } from '../delete-button/delete-button.component'; +import { deleteImage } from '../../../api/fn/images/delete-image'; +import { NotificationService } from '../../../core/services/notification.service'; + +type ImageView = ImageDto & { isUpdatingName: boolean }; @Component({ selector: 'tp-image-manager', - imports: [TranslateDirective, AsyncPipe, RenameButtonComponent, CopyToClipboardComponent, DecimalPipe, TooltipIconComponent], + imports: [ + TranslateDirective, + AsyncPipe, + RenameButtonComponent, + DecimalPipe, + SmallSpinnerComponent, + IsActionAllowedDirective, + RbacWidgetComponent, + DeleteButtonComponent + ], templateUrl: './image-manager.component.html' }) export class ImageManagerComponent { @Input({ required: true }) - public images: ImageDto[] = []; + public set images(value: ImageDto[]) { + this._images = value.map((x) => ({ ...x, isUpdatingName: false })); + } + + @Output() + public errorOccured = new EventEmitter(); protected readonly authorizationService = inject(AuthorizationService); protected readonly Actions = Actions; + protected _images: ImageView[] = []; + + private readonly turnierplanApi = inject(TurnierplanApi); + private readonly notificationService = inject(NotificationService); + protected renameImage(id: string, name: string): void { - alert(name); - // TODO: Implement renaming the image + if (this._images.some((x) => x.isUpdatingName)) { + return; + } + + const image = this._images.find((x) => x.id === id); + + if (!image) { + return; + } + + image.isUpdatingName = true; + + this.turnierplanApi.invoke(setImageName, { id: id, body: { name: name } }).subscribe({ + next: () => { + image.isUpdatingName = false; + image.name = name; + }, + error: (error) => { + this.errorOccured.emit(error); + } + }); + } + + protected deleteImage(id: string): void { + this.turnierplanApi.invoke(deleteImage, { id: id }).subscribe({ + next: () => { + this._images = this._images.filter((x) => x.id !== id); + + this.notificationService.showNotification( + 'info', + 'Portal.ViewOrganization.Images.DeleteToast.Title', + 'Portal.ViewOrganization.Images.DeleteToast.Message' + ); + }, + error: (error) => { + this.errorOccured.emit(error); + } + }); } } diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html index 22327cb7..a2bb556d 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html @@ -146,7 +146,7 @@
- +
} } From 6d6392b8b1a9c59cb4984c63c4a716495daefbe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 10:03:37 +0100 Subject: [PATCH 07/38] Remove lines that might become unnecessary --- src/Turnierplan.App/Security/AccessValidator.cs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/Turnierplan.App/Security/AccessValidator.cs b/src/Turnierplan.App/Security/AccessValidator.cs index 85338670..e3077d84 100644 --- a/src/Turnierplan.App/Security/AccessValidator.cs +++ b/src/Turnierplan.App/Security/AccessValidator.cs @@ -82,15 +82,6 @@ public void AddRolesToResponseHeader(IEntityWithRoleAssignments target) var rolesHeaderValue = $"{targetPublicId}={rolesList}"; _httpContext.Response.Headers.Append(RolesHeaderName, rolesHeaderValue); - - if (target is IEntityWithOrganization entityWithOrganization) - { - // TODO: Evaluate if this is still necessary after #309 / #324 - // Always add the organization-level roles, too. This is necessary because for example, the tournament - // page allows the user to upload images. But for uploading images, the authorization check is done - // against the organization. - AddRolesToResponseHeader(entityWithOrganization.Organization); - } } internal static bool IsActionAllowed(IEntityWithRoleAssignments target, Actions.Action action, Principal principal) From 3c5d4e54320d0ad01b7c809a7e2fbfd44459ed0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 10:05:58 +0100 Subject: [PATCH 08/38] init; only --- .../Models/ApplicationTeamLinkedTournamentDto.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Turnierplan.App/Models/ApplicationTeamLinkedTournamentDto.cs b/src/Turnierplan.App/Models/ApplicationTeamLinkedTournamentDto.cs index a94e9d32..70c990c0 100644 --- a/src/Turnierplan.App/Models/ApplicationTeamLinkedTournamentDto.cs +++ b/src/Turnierplan.App/Models/ApplicationTeamLinkedTournamentDto.cs @@ -4,9 +4,9 @@ namespace Turnierplan.App.Models; public sealed record ApplicationTeamLinkedTournamentDto { - public required PublicId Id { get; set; } + public required PublicId Id { get; init; } - public required string Name { get; set; } + public required string Name { get; init; } - public required string? FolderName { get; set; } + public required string? FolderName { get; init; } } From 9c50edef748f562541b92703f05ca3b0e3efbdfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 11:50:35 +0100 Subject: [PATCH 09/38] Display number of references & finish images table --- src/Turnierplan.App/Client/src/app/i18n/de.ts | 13 +++++- .../image-chooser/image-chooser.component.ts | 4 +- .../image-manager.component.html | 26 ++++++++---- .../image-manager/image-manager.component.ts | 16 +++++-- .../view-organization.component.html | 2 +- .../view-organization.component.ts | 6 +-- .../Converters/JsonPublicIdConverter.cs | 8 ++++ .../Endpoints/Images/GetImagesEndpoint.cs | 42 ++++++++++++++++--- .../Mapping/Rules/ImageMappingRule.cs | 1 + src/Turnierplan.App/Models/ImageDto.cs | 3 ++ .../Repositories/ImageRepository.cs | 17 +++++++- 11 files changed, 113 insertions(+), 25 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index cf1b538e..b28ac05f 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -216,9 +216,20 @@ export const de = { }, Images: { Preview: 'Vorschau', - Name: 'Name', Dimensions: '{{w}} x {{h}} px', FileSize: '{{size}} KB', + Type: { + Header: 'Typ', + Logo: 'Logo', + Banner: 'Banner' + }, + CreatedAt: 'Hochgeladen am', + Name: 'Name', + References: { + Header: 'Verwendungen', + Tooltip: 'Gibt an, von wie vielen Turnieren wird dieses Bild verwendet wird', + None: 'keine' + }, NoImages: 'Es sind aktuell keine Bilder vorhanden.', Rename: { Title: 'Bild umbenennen', diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.ts b/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.ts index 8f46d38a..541294f4 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.ts @@ -155,8 +155,8 @@ export class ImageChooserComponent { private loadImages(): void { this.turnierplanApi.invoke(getImages, { organizationId: this.organizationId, imageType: this.imageType }).subscribe({ - next: (images) => { - this.existingImages = images; + next: (response) => { + this.existingImages = response.images; this.existingImages.sort((a, b) => { if (a.id === this.currentImageId) return -1; if (b.id === this.currentImageId) return 1; diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html index ac30b859..5edccfb5 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html @@ -3,8 +3,13 @@ - - + + + + + + + @@ -25,7 +30,9 @@ translate="Portal.ViewOrganization.Images.FileSize" [translateParams]="{ size: image.fileSize / 1000 | number: '1.1-1' : 'de' }"> - + + {{ image.createdAt | translateDate }} + @if (image.isUpdatingName) { } @else if (writeAllowed) { @@ -37,10 +44,16 @@ (renamed)="renameImage(image.id, $event)" /> } + + {{ image.name }} + - {{ image.name }} + @if (image.referenceCount === 0) { + + } @else if (image.referenceCount !== undefined) { + {{ image.referenceCount }} + } - } @empty { - - + } diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts index d99d9d2c..90c6f7f2 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts @@ -13,8 +13,10 @@ import { RbacWidgetComponent } from '../rbac-widget/rbac-widget.component'; import { DeleteButtonComponent } from '../delete-button/delete-button.component'; import { deleteImage } from '../../../api/fn/images/delete-image'; import { NotificationService } from '../../../core/services/notification.service'; +import { TooltipIconComponent } from '../tooltip-icon/tooltip-icon.component'; +import { TranslateDatePipe } from '../../pipes/translate-date.pipe'; -type ImageView = ImageDto & { isUpdatingName: boolean }; +type ImageView = ImageDto & { referenceCount?: number; isUpdatingName: boolean }; @Component({ selector: 'tp-image-manager', @@ -26,14 +28,20 @@ type ImageView = ImageDto & { isUpdatingName: boolean }; SmallSpinnerComponent, IsActionAllowedDirective, RbacWidgetComponent, - DeleteButtonComponent + DeleteButtonComponent, + TooltipIconComponent, + TranslateDatePipe ], templateUrl: './image-manager.component.html' }) export class ImageManagerComponent { @Input({ required: true }) - public set images(value: ImageDto[]) { - this._images = value.map((x) => ({ ...x, isUpdatingName: false })); + public set images(value: { images: ImageDto[]; references?: { [key: string]: number } }) { + this._images = value.images.map((x) => ({ + ...x, + referenceCount: value.references ? value.references[x.id] : undefined, + isUpdatingName: false + })); } @Output() diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html index a2bb556d..56f6d122 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html @@ -143,7 +143,7 @@ } @else {
- +
diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.ts b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.ts index 24a5970e..64f0f88c 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.ts @@ -43,8 +43,8 @@ import { setApiKeyStatus } from '../../../api/fn/api-keys/set-api-key-status'; import { getApiKeys } from '../../../api/fn/api-keys/get-api-keys'; import { E2eDirective } from '../../../core/directives/e2e.directive'; import { ImageManagerComponent } from '../../components/image-manager/image-manager.component'; -import { ImageDto } from '../../../api/models/image-dto'; import { getImages } from '../../../api/fn/images/get-images'; +import { GetImagesEndpointResponse } from '../../../api/models/get-images-endpoint-response'; @Component({ templateUrl: './view-organization.component.html', @@ -89,7 +89,7 @@ export class ViewOrganizationComponent implements OnInit, OnDestroy { protected tournaments?: TournamentHeaderDto[]; protected venues?: VenueDto[]; protected planningRealms?: PlanningRealmHeaderDto[]; - protected images?: ImageDto[]; + protected images?: GetImagesEndpointResponse; protected apiKeys?: ApiKeyDto[]; protected displayApiKeyUsage?: string; protected isLoadingVenues = false; @@ -224,7 +224,7 @@ export class ViewOrganizationComponent implements OnInit, OnDestroy { if (number === ViewOrganizationComponent.imagesPageId && !this.images && !this.isLoadingImages) { // Load images only when the page is opened this.isLoadingImages = true; - this.turnierplanApi.invoke(getImages, { organizationId: this.organization.id }).subscribe({ + this.turnierplanApi.invoke(getImages, { organizationId: this.organization.id, includeReferences: true }).subscribe({ next: (images) => { this.images = images; this.isLoadingImages = false; diff --git a/src/Turnierplan.App/Converters/JsonPublicIdConverter.cs b/src/Turnierplan.App/Converters/JsonPublicIdConverter.cs index 1dcfb2f3..aeb9cccf 100644 --- a/src/Turnierplan.App/Converters/JsonPublicIdConverter.cs +++ b/src/Turnierplan.App/Converters/JsonPublicIdConverter.cs @@ -24,5 +24,13 @@ public override void Write(Utf8JsonWriter writer, PublicId value, JsonSerializer { writer.WriteStringValue(value.ToString()); } + + /// + /// Required for serialization of properties with the type Dictionary<PublicId, T> + /// + public override void WriteAsPropertyName(Utf8JsonWriter writer, PublicId value, JsonSerializerOptions options) + { + writer.WritePropertyName(value.ToString()); + } } diff --git a/src/Turnierplan.App/Endpoints/Images/GetImagesEndpoint.cs b/src/Turnierplan.App/Endpoints/Images/GetImagesEndpoint.cs index 9d6a5321..93370767 100644 --- a/src/Turnierplan.App/Endpoints/Images/GetImagesEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Images/GetImagesEndpoint.cs @@ -8,7 +8,7 @@ namespace Turnierplan.App.Endpoints.Images; -internal sealed class GetImagesEndpoint : EndpointBase> +internal sealed class GetImagesEndpoint : EndpointBase { protected override HttpMethod Method => HttpMethod.Get; @@ -19,11 +19,13 @@ internal sealed class GetImagesEndpoint : EndpointBase> private static async Task Handle( [FromQuery] PublicId organizationId, [FromQuery] ImageType? imageType, - IOrganizationRepository repository, + [FromQuery] bool? includeReferences, + IOrganizationRepository organizationRepository, + IImageRepository imageRepository, IAccessValidator accessValidator, IMapper mapper) { - var organization = await repository.GetByPublicIdAsync(organizationId, IOrganizationRepository.Includes.Images); + var organization = await organizationRepository.GetByPublicIdAsync(organizationId, IOrganizationRepository.Includes.Images); if (organization is null) { @@ -36,14 +38,42 @@ private static async Task Handle( } var filteredImages = imageType.HasValue - ? organization.Images.Where(x => x.Type == imageType).ToList() + ? organization.Images.Where(x => x.Type == imageType) : organization.Images; - foreach (var image in filteredImages) + var filteredAndSortedImages = filteredImages + .OrderByDescending(x => x.CreatedAt) + .ToList(); + + foreach (var image in filteredAndSortedImages) { accessValidator.AddRolesToResponseHeader(image); } - return Results.Ok(mapper.MapCollection(filteredImages)); + Dictionary? references = null; + + if (includeReferences == true) + { + references = []; + + foreach (var image in filteredAndSortedImages) + { + var count = await imageRepository.CountNumberOfReferencingTournamentsAsync(image.Id); + references[image.PublicId] = count; + } + } + + return Results.Ok(new GetImagesEndpointResponse + { + Images = mapper.MapCollection(filteredAndSortedImages).ToArray(), + References = references + }); + } + + public sealed record GetImagesEndpointResponse + { + public required ImageDto[] Images { get; init; } + + public required Dictionary? References { get; init; } } } diff --git a/src/Turnierplan.App/Mapping/Rules/ImageMappingRule.cs b/src/Turnierplan.App/Mapping/Rules/ImageMappingRule.cs index b04d43e9..123d2f6f 100644 --- a/src/Turnierplan.App/Mapping/Rules/ImageMappingRule.cs +++ b/src/Turnierplan.App/Mapping/Rules/ImageMappingRule.cs @@ -21,6 +21,7 @@ protected override ImageDto Map(IMapper mapper, MappingContext context, Image so Id = source.PublicId, RbacScopeId = source.GetScopeId(), CreatedAt = source.CreatedAt, + Type = source.Type, Name = source.Name, Url = _imageStorage.GetFullImageUrl(source), FileSize = source.FileSize, diff --git a/src/Turnierplan.App/Models/ImageDto.cs b/src/Turnierplan.App/Models/ImageDto.cs index dba8f82b..4072c506 100644 --- a/src/Turnierplan.App/Models/ImageDto.cs +++ b/src/Turnierplan.App/Models/ImageDto.cs @@ -1,3 +1,4 @@ +using Turnierplan.Core.Image; using Turnierplan.Core.PublicId; namespace Turnierplan.App.Models; @@ -10,6 +11,8 @@ public sealed record ImageDto public required DateTime CreatedAt { get; init; } + public required ImageType Type { get; init; } + public required string Name { get; init; } public required string Url { get; init; } diff --git a/src/Turnierplan.Dal/Repositories/ImageRepository.cs b/src/Turnierplan.Dal/Repositories/ImageRepository.cs index 0b41a574..1357e440 100644 --- a/src/Turnierplan.Dal/Repositories/ImageRepository.cs +++ b/src/Turnierplan.Dal/Repositories/ImageRepository.cs @@ -4,10 +4,15 @@ namespace Turnierplan.Dal.Repositories; -public interface IImageRepository : IRepositoryWithPublicId; +public interface IImageRepository : IRepositoryWithPublicId +{ + Task CountNumberOfReferencingTournamentsAsync(long imageId); +} internal sealed class ImageRepository(TurnierplanContext context) : RepositoryBaseWithPublicId(context), IImageRepository { + private readonly TurnierplanContext _context = context; + public override Task GetByPublicIdAsync(PublicId id) { return DbSet.Where(x => x.PublicId == id) @@ -16,4 +21,14 @@ internal sealed class ImageRepository(TurnierplanContext context) : RepositoryBa .AsSplitQuery() .FirstOrDefaultAsync(); } + + public async Task CountNumberOfReferencingTournamentsAsync(long imageId) + { + var count = await _context.Tournaments + .Where(x => x.PrimaryLogo!.Id == imageId || x.SecondaryLogo!.Id == imageId || x.BannerImage!.Id == imageId) + .CountAsync(); + + return count; + } + } From af027814b6d16af018dd0ffcc61096ea60d83785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 11:52:13 +0100 Subject: [PATCH 10/38] Fix rbac offcanvas for image --- src/Turnierplan.App/Client/src/app/i18n/de.ts | 4 ++++ .../components/rbac-offcanvas/rbac-offcanvas.component.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index b28ac05f..054d3c4b 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -1381,6 +1381,10 @@ export const de = { Tooltip: 'Ordner', NotInherited: 'Zuweisung liegt auf diesem Ordner' }, + Image: { + Tooltip: 'Bild', + NotInherited: 'Zuweisung liegt auf diesem Bild' + }, Organization: { Tooltip: 'Organisation', NotInherited: 'Zuweisung liegt auf dieser Organisation' diff --git a/src/Turnierplan.App/Client/src/app/portal/components/rbac-offcanvas/rbac-offcanvas.component.ts b/src/Turnierplan.App/Client/src/app/portal/components/rbac-offcanvas/rbac-offcanvas.component.ts index f569dad6..98cf18cd 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/rbac-offcanvas/rbac-offcanvas.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/components/rbac-offcanvas/rbac-offcanvas.component.ts @@ -71,6 +71,9 @@ export class RbacOffcanvasComponent implements OnDestroy { case 'Folder': this.targetIcon = 'folder2-open'; break; + case 'Image': + this.targetIcon = 'image'; + break; case 'Organization': this.targetIcon = 'boxes'; break; From 9fd44fe3d98f51ca1326fd5988c636a1bd620a29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 12:07:30 +0100 Subject: [PATCH 11/38] Remove upload functionality from image chooser --- src/Turnierplan.App/Client/src/app/i18n/de.ts | 19 +--- .../image-chooser.component.html | 57 ++-------- .../image-chooser/image-chooser.component.ts | 102 +----------------- .../image-widget/image-widget.component.ts | 2 +- 4 files changed, 13 insertions(+), 167 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index 054d3c4b..86c4f0c4 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -1289,23 +1289,10 @@ export const de = { Banner: 'Banner' }, ImageChooser: { - Title: 'Bild hochladen oder auswählen', + Title: 'Bild auswählen', Remove: 'Bild entfernen', - NoImages: 'Laden Sie Ihr erstes Bild hoch...', - Upload: 'Hochladen', - UploadFailed: 'Das Bild konnte nicht hochgeladen werden. Prüfen Sie die Maße und die maximale Dateigröße.', - Constraints: { - Logo: 'Das Bild muss quadratisch sein mit einer Auflösung zwischen 50x50 und 3000x3000 Pixel. Die maximale Dateigröße beträgt 8 MB.', - Banner: - 'Das Bild muss mindestens 50px hoch sein und darf maximal 3000px breit sein. Das Seitenverhältnis muss zwischen 3:1 und 5:1 liegen. Die maximale Dateigröße beträgt 8 MB.' - }, - DetailView: { - Title: 'Hier sehen Sie die Detailinformationen zu folgendem Bild:', - Name: 'Dateiname: {{value}}', - CreatedAt: 'Hochgeladen am: {{value}}', - FileSize: 'Dateigröße: {{value}} KB', - Resolution: 'Auflösung: {{width}}x{{height}} px' - } + NoImages: 'In dieser Organisation wurden bisher noch keine Bilder hochgeladen.', + UploadViaOrgPage: 'Neue Bilder können Sie auf der Seite der Organisation hochladen.' }, MultiSelectFilter: { All: 'alle', diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.html b/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.html index 7505ffbd..0fb1e21f 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.html @@ -8,52 +8,14 @@
- } @else if (isInImageDetailView) { -
- - -
- -
- -
-
-
-
-
-
-
- -
-
} @else {
- +
- @if (isUploadingImage) { - - } @else { - - }
- @if (hasUploadError) { - - } -
@for (image of existingImages; track image.id) {
- @if (image.id !== currentImageId) { @if (!isLoadingImages) { } } diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.ts b/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.ts index 541294f4..5ebee2be 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.ts @@ -4,23 +4,15 @@ import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { Actions } from '../../../generated/actions'; import { TranslateDirective } from '@ngx-translate/core'; import { LoadingIndicatorComponent } from '../loading-indicator/loading-indicator.component'; -import { IsActionAllowedDirective } from '../../directives/is-action-allowed.directive'; -import { DeleteButtonComponent } from '../delete-button/delete-button.component'; -import { SmallSpinnerComponent } from '../../../core/components/small-spinner/small-spinner.component'; import { ActionButtonComponent } from '../action-button/action-button.component'; -import { AlertComponent } from '../alert/alert.component'; import { NgClass } from '@angular/common'; -import { TranslateDatePipe } from '../../pipes/translate-date.pipe'; import { TurnierplanApi } from '../../../api/turnierplan-api'; import { ImageType } from '../../../api/models/image-type'; -import { uploadImage$FormData } from '../../../api/fn/images/upload-image-form-data'; import { getImages } from '../../../api/fn/images/get-images'; import { ImageDto } from '../../../api/models/image-dto'; -import { deleteImage } from '../../../api/fn/images/delete-image'; -import { from, switchMap } from 'rxjs'; export interface ImageChooserResult { - type: 'ImageDeleted' | 'ImageSelected' | 'ImageUploaded'; + type: 'ImageDeleted' | 'ImageSelected'; image?: ImageDto; deletedImageId?: string; } @@ -28,17 +20,7 @@ export interface ImageChooserResult { @Component({ templateUrl: './image-chooser.component.html', styleUrls: ['./image-chooser.component.scss'], - imports: [ - TranslateDirective, - LoadingIndicatorComponent, - IsActionAllowedDirective, - DeleteButtonComponent, - SmallSpinnerComponent, - ActionButtonComponent, - AlertComponent, - NgClass, - TranslateDatePipe - ] + imports: [TranslateDirective, LoadingIndicatorComponent, ActionButtonComponent, NgClass] }) export class ImageChooserComponent { protected readonly Actions = Actions; @@ -52,23 +34,14 @@ export class ImageChooserComponent { protected isLoadingImages = true; protected currentImageId?: string; - protected isUploadingImage = false; - protected hasUploadError = false; - // On mobile, where hovering does not work, the current "hovered" image is set by clicking and stored in this variable. protected hoverOverrideImageId?: string; - protected imageForDetailView?: ImageDto; - constructor( protected readonly modal: NgbActiveModal, private readonly turnierplanApi: TurnierplanApi ) {} - protected get isInImageDetailView(): boolean { - return this.imageForDetailView !== undefined; - } - public init(organizationId: string, imageType: ImageType, currentImageId?: string): void { if (this.isInitialized) { return; @@ -82,77 +55,6 @@ export class ImageChooserComponent { this.loadImages(); } - protected openFileSelectionDialog(): void { - if (this.isUploadingImage) { - return; - } - - const tempElement = document.createElement('input'); - tempElement.type = 'file'; - tempElement.accept = 'image/png,image/jpeg'; - - tempElement.addEventListener('change', (event) => { - const targetFile = (event.target as HTMLInputElement).files?.item(0); - - if (targetFile) { - this.isUploadingImage = true; - - from(targetFile.arrayBuffer()) - .pipe( - switchMap((arrayBuffer) => - this.turnierplanApi.invoke(uploadImage$FormData, { - body: { - organizationId: this.organizationId, - imageType: this.imageType, - imageName: targetFile.name, - image: new Blob([arrayBuffer]) - } - }) - ) - ) - .subscribe({ - next: (result) => { - this.modal.close({ type: 'ImageUploaded', image: result } as ImageChooserResult); - }, - error: () => { - this.isUploadingImage = false; - this.hasUploadError = true; - } - }); - } - }); - - tempElement.click(); - } - - protected getRoundedFileSize(fileSize: number): number { - return Math.round(fileSize / 1000); - } - - protected deleteCurrentViewedImage(): void { - if (!this.imageForDetailView) { - return; - } - - const deleteImageId = this.imageForDetailView.id; - this.imageForDetailView = undefined; - - this.isLoadingImages = true; - - this.turnierplanApi.invoke(deleteImage, { id: deleteImageId }).subscribe({ - next: () => { - if (deleteImageId === this.currentImageId) { - this.modal.close({ type: 'ImageDeleted', deletedImageId: deleteImageId } as ImageChooserResult); - } else { - this.loadImages(); - } - }, - error: (error) => { - this.modal.dismiss({ isApiError: true, apiError: error as unknown }); - } - }); - } - private loadImages(): void { this.turnierplanApi.invoke(getImages, { organizationId: this.organizationId, imageType: this.imageType }).subscribe({ next: (response) => { diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-widget/image-widget.component.ts b/src/Turnierplan.App/Client/src/app/portal/components/image-widget/image-widget.component.ts index 553bd42e..739b55a4 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-widget/image-widget.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-widget/image-widget.component.ts @@ -55,7 +55,7 @@ export class ImageWidgetComponent { ref.closed.subscribe({ next: (result: ImageChooserResult) => { - if (result.type === 'ImageUploaded' || result.type === 'ImageSelected') { + if (result.type === 'ImageSelected') { this.imageChange.emit(result.image); } else if (result.type === 'ImageDeleted') { this.imageDelete.emit(result.deletedImageId); From 607db89f847b8be986044679146b83d5bf54d03d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 12:13:25 +0100 Subject: [PATCH 12/38] Change icon for new upload button --- .../pages/view-organization/view-organization.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html index 56f6d122..321a3dc7 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html @@ -39,7 +39,7 @@ *tpIsActionAllowed="[organization.id, Actions.GenericWrite]" [title]="'Portal.ViewOrganization.UploadImage'" [type]="'outline-success'" - [icon]="'plus-circle'" + [icon]="'upload'" [routerLink]="'upload/image'" /> } @case (2) { From b020837c1f09778a7c6a7362ac09b4a764f30d97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 12:56:44 +0100 Subject: [PATCH 13/38] Add form for image upload --- src/Turnierplan.App/Client/src/app/i18n/de.ts | 13 +++ .../upload-image/upload-image.component.html | 50 ++++++++++- .../upload-image/upload-image.component.ts | 87 ++++++++++++++++++- 3 files changed, 145 insertions(+), 5 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index 86c4f0c4..ed7cab02 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -1228,6 +1228,19 @@ export const de = { } } }, + UploadImage: { + Title: 'Bild hochladen', + Form: { + File: 'Datei auswählen:', + Name: 'Name:', + NameInvalid: 'Der angegebene Name ist ungültig' + }, + NameTooltip: 'Wenn der Name leergelassen wird, wird der Dateinahme der gewählten Datei als Name verwendet.', + Preview: 'Bildvorschau:', + OrganizationNotice: + 'Es wird ein neues Bild in der Organisation {{organizationName}} hochgeladen. Das Bild kann anschließend von allen Turnieren und Turnierplanern innerhalb der Organisation verwendet werden.', + Submit: 'Hochladen' + }, CreateApiKey: { Title: 'Neuen API-Schlüssel erstellen', Form: { diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.html index 388803f0..a3b164c8 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.html @@ -1 +1,49 @@ -

upload-image works!

+ + @let imageNameInvalid = (imageName.dirty || imageName.touched) && imageName.value.length > 0 && imageName.value.trim().length === 0; + +
+ + +
+
+
+ + +
+ +
+
+ + @let previewUrl = previewUrl$ | async; + @if (previewUrl) { +
+
+
+ +
+ } + +
+ + +
+ + +
+ +
+ +
+ +
+
diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.ts b/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.ts index 8a959d7b..13e3a276 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.ts @@ -1,9 +1,88 @@ -import { Component } from '@angular/core'; +import { Component, OnDestroy } from '@angular/core'; +import { LoadingState, LoadingStateDirective } from '../../directives/loading-state.directive'; +import { PageFrameComponent } from '../../components/page-frame/page-frame.component'; +import { TranslateDirective, TranslatePipe } from '@ngx-translate/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { OrganizationDto } from '../../../api/models/organization-dto'; +import { TooltipIconComponent } from '../../components/tooltip-icon/tooltip-icon.component'; +import { BehaviorSubject, map, Observable } from 'rxjs'; +import { AsyncPipe } from '@angular/common'; +import { filter } from 'rxjs/operators'; +import { ActionButtonComponent } from '../../components/action-button/action-button.component'; @Component({ - imports: [], + imports: [ + LoadingStateDirective, + PageFrameComponent, + TranslatePipe, + TranslateDirective, + ReactiveFormsModule, + TooltipIconComponent, + AsyncPipe, + ActionButtonComponent + ], templateUrl: './upload-image.component.html' }) -export class UploadImageComponent { - // TODO: Implement this component +export class UploadImageComponent implements OnDestroy { + protected loadingState: LoadingState = { isLoading: false }; + + protected organization?: OrganizationDto; + protected imageName = new FormControl('', { nonNullable: true }); + protected selectedFile?: File; + protected fallbackName?: string; + protected previewUrl$: Observable; + + private arrayBuffer$: BehaviorSubject; + + constructor() { + this.arrayBuffer$ = new BehaviorSubject(undefined); + this.previewUrl$ = this.arrayBuffer$.pipe( + filter((blob) => !!blob), + map((blob) => URL.createObjectURL(blob)) + ); + } + + public ngOnDestroy() { + this.arrayBuffer$.complete(); + } + + protected onFileSelected(event: unknown): void { + const file = (event as { target: HTMLInputElement }).target.files?.item(0); + + if (!file) { + return; + } + + this.selectedFile = file; + this.setFallbackFileName(file.name); + + this.selectedFile.arrayBuffer().then((arrayBuffer): void => { + this.arrayBuffer$.next(new Blob([arrayBuffer])); + }); + } + + protected confirmButtonClicked(): void { + alert('Not Implemented'); // TODO + } + + private setFallbackFileName(name: string): void { + // Cut off the file extension + const i = name.lastIndexOf('.'); + + if (i === -1) { + this.fallbackName = name.trim(); + } else { + const extensions = name.substring(i); + if (extensions === '.png' || extensions === '.jpg' || extensions === '.jpg') { + this.fallbackName = name.substring(0, i).trim(); + } else { + this.fallbackName = name.trim(); + } + } + + if (this.fallbackName.length === 0) { + // Don't bother with translating this string because realistically, this will never happen + this.fallbackName = 'Unnamed Image'; + } + } } From 7e822d963aa5ab5a8afdeb7109c7bf37640c8f68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 13:23:59 +0100 Subject: [PATCH 14/38] Replace --- .../configure-tournament/configure-tournament.component.html | 2 +- .../portal/pages/create-api-key/create-api-key.component.html | 4 ++-- .../create-organization/create-organization.component.html | 2 +- .../create-planning-realm.component.html | 2 +- .../pages/create-tournament/create-tournament.component.html | 4 ++-- .../app/portal/pages/create-user/create-user.component.html | 2 +- .../app/portal/pages/create-venue/create-venue.component.html | 2 +- .../pages/edit-match-plan/edit-match-plan.component.html | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/configure-tournament/configure-tournament.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/configure-tournament/configure-tournament.component.html index 4c6a881f..e127ba5d 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/configure-tournament/configure-tournament.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/configure-tournament/configure-tournament.component.html @@ -23,7 +23,7 @@
+
diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/create-api-key/create-api-key.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/create-api-key/create-api-key.component.html index 5e43ccae..9d9498fd 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/create-api-key/create-api-key.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/create-api-key/create-api-key.component.html @@ -72,7 +72,7 @@
-
+
-
+
diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/create-organization/create-organization.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/create-organization/create-organization.component.html index 1f521f1d..2b222270 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/create-organization/create-organization.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/create-organization/create-organization.component.html @@ -18,7 +18,7 @@
-
+
diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/create-planning-realm/create-planning-realm.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/create-planning-realm/create-planning-realm.component.html index a3d0c6b2..5a2f0468 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/create-planning-realm/create-planning-realm.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/create-planning-realm/create-planning-realm.component.html @@ -17,7 +17,7 @@
-
+
-
+
@if (visibility === 'Private') { -
+
diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.html index 9fd7615d..dcd9b078 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.html @@ -51,7 +51,7 @@
-
+
diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/create-venue/create-venue.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/create-venue/create-venue.component.html index b52c7e7f..80c2457b 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/create-venue/create-venue.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/create-venue/create-venue.component.html @@ -13,7 +13,7 @@
-
+
diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/edit-match-plan/edit-match-plan.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/edit-match-plan/edit-match-plan.component.html index f14dc0a7..0c9478e4 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/edit-match-plan/edit-match-plan.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/edit-match-plan/edit-match-plan.component.html @@ -125,7 +125,7 @@
-
+
From ae13962fc7eda803476f4630757267217a1ccc9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 13:24:22 +0100 Subject: [PATCH 15/38] Remove TODO --- .../app/portal/pages/upload-image/upload-image.component.html | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.html index a3b164c8..12635f05 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.html @@ -31,7 +31,6 @@
-
From 8c83c6b4849e4af49e73a2579fab6a57b1f969f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 13:27:19 +0100 Subject: [PATCH 16/38] Add image alt text --- src/Turnierplan.App/Client/src/app/i18n/de.ts | 1 + .../portal/pages/upload-image/upload-image.component.html | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index ed7cab02..2ceed387 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -1237,6 +1237,7 @@ export const de = { }, NameTooltip: 'Wenn der Name leergelassen wird, wird der Dateinahme der gewählten Datei als Name verwendet.', Preview: 'Bildvorschau:', + PreviewAlt: 'Bildvorschau der Datei "{{fileName}}"', OrganizationNotice: 'Es wird ein neues Bild in der Organisation {{organizationName}} hochgeladen. Das Bild kann anschließend von allen Turnieren und Turnierplanern innerhalb der Organisation verwendet werden.', Submit: 'Hochladen' diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.html index 12635f05..5dd70c62 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.html @@ -25,7 +25,11 @@
- +
} From 6181e1f7aac80e4f9878f125beea14bf09f9f09f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 15:25:55 +0100 Subject: [PATCH 17/38] Refactor: Remove image type and rename "FileType" to "FileExtension" --- .../Security/AccessValidatorTest.cs | 4 +- .../Endpoints/Images/GetImagesEndpoint.cs | 14 +- .../Endpoints/Images/UploadImageEndpoint.cs | 54 +- .../Mapping/Rules/ImageMappingRule.cs | 1 - src/Turnierplan.App/Models/ImageDto.cs | 3 - src/Turnierplan.Core/Image/Image.cs | 27 +- .../Image/ImageConstraints.cs | 64 - src/Turnierplan.Core/Image/ImageType.cs | 9 - .../PlanningRealm/InvitationLink.cs | 11 +- src/Turnierplan.Core/Tournament/Tournament.cs | 14 +- .../ImageEntityTypeConfiguration.cs | 5 +- ...0260201142441_Remove_ImageType.Designer.cs | 1881 +++++++++++++++++ .../20260201142441_Remove_ImageType.cs | 31 + ...2521_Rename_ImageFileExtension.Designer.cs | 1881 +++++++++++++++++ ...0260201142521_Rename_ImageFileExtension.cs | 30 + .../TurnierplanContextModelSnapshot.cs | 11 +- .../Azure/AzureImageStorage.cs | 2 +- .../Local/LocalImageStorage.cs | 2 +- .../S3/S3ImageStorage.cs | 2 +- .../Tracing/DocumentRendererActivitySource.cs | 3 +- 20 files changed, 3857 insertions(+), 192 deletions(-) delete mode 100644 src/Turnierplan.Core/Image/ImageConstraints.cs delete mode 100644 src/Turnierplan.Core/Image/ImageType.cs create mode 100644 src/Turnierplan.Dal/Migrations/20260201142441_Remove_ImageType.Designer.cs create mode 100644 src/Turnierplan.Dal/Migrations/20260201142441_Remove_ImageType.cs create mode 100644 src/Turnierplan.Dal/Migrations/20260201142521_Rename_ImageFileExtension.Designer.cs create mode 100644 src/Turnierplan.Dal/Migrations/20260201142521_Rename_ImageFileExtension.cs diff --git a/src/Turnierplan.App.Test.Unit/Security/AccessValidatorTest.cs b/src/Turnierplan.App.Test.Unit/Security/AccessValidatorTest.cs index 56142d97..c1635d74 100644 --- a/src/Turnierplan.App.Test.Unit/Security/AccessValidatorTest.cs +++ b/src/Turnierplan.App.Test.Unit/Security/AccessValidatorTest.cs @@ -46,7 +46,7 @@ public void IsActionAllowed___When_Called_With_Indirect_Target___Returns_Expecte organization.AddRoleAssignment(Role.Contributor, otherPrincipal); Test(() => new ApiKey(organization, "Test", null, DateTime.MaxValue)); - Test(() => new Image(organization, "Test", ImageType.Logo, "", 0, 1, 1)); + Test(() => new Image(organization, "Test", "", 0, 1, 1)); Test(() => new Folder(organization, "Test")); Test(() => new Tournament(organization, "Test", Visibility.Public)); Test(() => new Venue(organization, "Test", "")); @@ -121,7 +121,7 @@ public void AddAvailableRoles___When_Called_With_Indirect_Target___Returns_Expec organization.AddRoleAssignment(Role.Contributor, otherPrincipal); Test(() => new ApiKey(organization, "Test", null, DateTime.MaxValue)); - Test(() => new Image(organization, "Test", ImageType.Logo, "", 0, 1, 1)); + Test(() => new Image(organization, "Test", "", 0, 1, 1)); Test(() => new Folder(organization, "Test")); Test(() => new Tournament(organization, "Test", Visibility.Public)); Test(() => new Venue(organization, "Test", "")); diff --git a/src/Turnierplan.App/Endpoints/Images/GetImagesEndpoint.cs b/src/Turnierplan.App/Endpoints/Images/GetImagesEndpoint.cs index 93370767..86655ac2 100644 --- a/src/Turnierplan.App/Endpoints/Images/GetImagesEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Images/GetImagesEndpoint.cs @@ -2,7 +2,6 @@ using Turnierplan.App.Mapping; using Turnierplan.App.Models; using Turnierplan.App.Security; -using Turnierplan.Core.Image; using Turnierplan.Core.PublicId; using Turnierplan.Dal.Repositories; @@ -18,7 +17,6 @@ internal sealed class GetImagesEndpoint : EndpointBase Handle( [FromQuery] PublicId organizationId, - [FromQuery] ImageType? imageType, [FromQuery] bool? includeReferences, IOrganizationRepository organizationRepository, IImageRepository imageRepository, @@ -37,15 +35,11 @@ private static async Task Handle( return Results.Forbid(); } - var filteredImages = imageType.HasValue - ? organization.Images.Where(x => x.Type == imageType) - : organization.Images; - - var filteredAndSortedImages = filteredImages + var sortedImages = organization.Images .OrderByDescending(x => x.CreatedAt) .ToList(); - foreach (var image in filteredAndSortedImages) + foreach (var image in sortedImages) { accessValidator.AddRolesToResponseHeader(image); } @@ -56,7 +50,7 @@ private static async Task Handle( { references = []; - foreach (var image in filteredAndSortedImages) + foreach (var image in sortedImages) { var count = await imageRepository.CountNumberOfReferencingTournamentsAsync(image.Id); references[image.PublicId] = count; @@ -65,7 +59,7 @@ private static async Task Handle( return Results.Ok(new GetImagesEndpointResponse { - Images = mapper.MapCollection(filteredAndSortedImages).ToArray(), + Images = mapper.MapCollection(sortedImages).ToArray(), References = references }); } diff --git a/src/Turnierplan.App/Endpoints/Images/UploadImageEndpoint.cs b/src/Turnierplan.App/Endpoints/Images/UploadImageEndpoint.cs index 7d4d4ad0..a9403e40 100644 --- a/src/Turnierplan.App/Endpoints/Images/UploadImageEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Images/UploadImageEndpoint.cs @@ -14,9 +14,6 @@ namespace Turnierplan.App.Endpoints.Images; internal sealed class UploadImageEndpoint : EndpointBase { - private const int MinimumImageSizeInPixels = 50; - private const int MaximumImageSizeInPixels = 3000; - protected override HttpMethod Method => HttpMethod.Post; protected override string Route => "/api/images"; @@ -24,7 +21,7 @@ internal sealed class UploadImageEndpoint : EndpointBase protected override Delegate Handler => Handle; private static async Task Handle( - [FromForm] UploadImageEndpointRequest request, // Note: We use [FromForm] instead of [FromBody] + [FromForm] UploadImageEndpointRequest request, // Note that [FromForm] is used instead of [FromBody] IOrganizationRepository organizationRepository, IAccessValidator accessValidator, IImageStorage imageStorage, @@ -66,31 +63,19 @@ private static async Task Handle( return Results.BadRequest("Could not process image."); } - if (imageData.Width < MinimumImageSizeInPixels || imageData.Height < MinimumImageSizeInPixels) - { - return Results.BadRequest($"Image is too small (must be at least {MinimumImageSizeInPixels}px along both sides)."); - } - + // TODO: Make this configurable via appsettings as well + const int MaximumImageSizeInPixels = 3000; if (imageData.Width > MaximumImageSizeInPixels || imageData.Height > MaximumImageSizeInPixels) { return Results.BadRequest($"Image is too large (maximum is {MaximumImageSizeInPixels}px along each side)."); } - var constraints = ImageConstraints.GetImageConstraints(request.ImageType); - - if (!constraints.IsSizeValid((ushort)imageData.Width, (ushort)imageData.Height)) - { - return Results.BadRequest($"Image dimensions do not meet the constraints: {constraints}"); - } - - imageData = ScaleBitmapToMaximumDimensions(imageData, request.ImageType); - var memoryStream = new MemoryStream(); - var encodedData = imageData.Encode(SKEncodedImageFormat.Webp, 80); // IDEA: Make the quality configurable via app settings + var encodedData = imageData.Encode(SKEncodedImageFormat.Webp, 80); // TODO: Make the image format & quality configurable via app settings (NOTE: Update file type in Image ctor below) encodedData.SaveTo(memoryStream); memoryStream.Seek(0, SeekOrigin.Begin); - var image = new Image(organization, request.ImageName, request.ImageType, "webp", memoryStream.Length, (ushort)imageData.Width, (ushort)imageData.Height); + var image = new Image(organization, request.ImageName, "webp", memoryStream.Length, (ushort)imageData.Width, (ushort)imageData.Height); // Dispose here because Image() ctor accesses width and height of imageData imageData.Dispose(); @@ -110,29 +95,6 @@ private static async Task Handle( return Results.Ok(mapper.Map(image)); } - private static SKBitmap ScaleBitmapToMaximumDimensions(SKBitmap imageData, ImageType imageType) - { - var maxWidth = imageType switch - { - ImageType.Logo => 400, - ImageType.Banner => 1600, - _ => throw new ArgumentOutOfRangeException(nameof(imageType), imageType, null) - }; - - if (imageData.Width < maxWidth) - { - return imageData; - } - - var scaleFactor = (float)maxWidth / imageData.Width; - var destinationSize = new SKImageInfo(maxWidth, (int)(imageData.Height * scaleFactor)); - var scaledImage = imageData.Resize(destinationSize, new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear)); - - imageData.Dispose(); - - return scaledImage; - } - public sealed record UploadImageEndpointRequest { /// @@ -141,8 +103,6 @@ public sealed record UploadImageEndpointRequest /// public required string OrganizationId { get; init; } - public required ImageType ImageType { get; init; } - public required IFormFile Image { get; init; } public required string ImageName { get; init; } @@ -154,9 +114,7 @@ private sealed class Validator : AbstractValidator private Validator() { - RuleFor(x => x.ImageType) - .IsInEnum(); - + // TODO: Add app configuration for maximum image size RuleFor(x => x.Image.Length) .LessThanOrEqualTo(8 * 1024 * 1024) .WithMessage("Image file size must be 8MB or less."); diff --git a/src/Turnierplan.App/Mapping/Rules/ImageMappingRule.cs b/src/Turnierplan.App/Mapping/Rules/ImageMappingRule.cs index 123d2f6f..b04d43e9 100644 --- a/src/Turnierplan.App/Mapping/Rules/ImageMappingRule.cs +++ b/src/Turnierplan.App/Mapping/Rules/ImageMappingRule.cs @@ -21,7 +21,6 @@ protected override ImageDto Map(IMapper mapper, MappingContext context, Image so Id = source.PublicId, RbacScopeId = source.GetScopeId(), CreatedAt = source.CreatedAt, - Type = source.Type, Name = source.Name, Url = _imageStorage.GetFullImageUrl(source), FileSize = source.FileSize, diff --git a/src/Turnierplan.App/Models/ImageDto.cs b/src/Turnierplan.App/Models/ImageDto.cs index 4072c506..dba8f82b 100644 --- a/src/Turnierplan.App/Models/ImageDto.cs +++ b/src/Turnierplan.App/Models/ImageDto.cs @@ -1,4 +1,3 @@ -using Turnierplan.Core.Image; using Turnierplan.Core.PublicId; namespace Turnierplan.App.Models; @@ -11,8 +10,6 @@ public sealed record ImageDto public required DateTime CreatedAt { get; init; } - public required ImageType Type { get; init; } - public required string Name { get; init; } public required string Url { get; init; } diff --git a/src/Turnierplan.Core/Image/Image.cs b/src/Turnierplan.Core/Image/Image.cs index ec8a173b..2dd0149d 100644 --- a/src/Turnierplan.Core/Image/Image.cs +++ b/src/Turnierplan.Core/Image/Image.cs @@ -1,5 +1,4 @@ using Turnierplan.Core.Entity; -using Turnierplan.Core.Exceptions; using Turnierplan.Core.RoleAssignment; namespace Turnierplan.Core.Image; @@ -8,10 +7,8 @@ public sealed class Image : Entity, IEntityWithRoleAssignments, IEn { internal readonly List> _roleAssignments = []; - public Image(Organization.Organization organization, string name, ImageType type, string fileType, long fileSize, ushort width, ushort height) + public Image(Organization.Organization organization, string name, string fileExtension, long fileSize, ushort width, ushort height) { - ValidateImageSize(type, width, height); - organization._images.Add(this); Id = 0; @@ -20,22 +17,20 @@ public Image(Organization.Organization organization, string name, ImageType type Organization = organization; CreatedAt = DateTime.UtcNow; Name = name; - Type = type; - FileType = fileType; + FileExtension = fileExtension; FileSize = fileSize; Width = width; Height = height; } - internal Image(long id, Guid resourceIdentifier, PublicId.PublicId publicId, DateTime createdAt, string name, ImageType type, string fileType, long fileSize, ushort width, ushort height) + internal Image(long id, Guid resourceIdentifier, PublicId.PublicId publicId, DateTime createdAt, string name, string fileExtension, long fileSize, ushort width, ushort height) { Id = id; ResourceIdentifier = resourceIdentifier; PublicId = publicId; CreatedAt = createdAt; Name = name; - Type = type; - FileType = fileType; + FileExtension = fileExtension; FileSize = fileSize; Width = width; Height = height; @@ -55,9 +50,7 @@ internal Image(long id, Guid resourceIdentifier, PublicId.PublicId publicId, Dat public string Name { get; set; } - public ImageType Type { get; } - - public string FileType { get; } + public string FileExtension { get; } public long FileSize { get; } @@ -77,14 +70,4 @@ public void RemoveRoleAssignment(RoleAssignment roleAssignment) { _roleAssignments.Remove(roleAssignment); } - - private static void ValidateImageSize(ImageType type, ushort width, ushort height) - { - var constraints = ImageConstraints.GetImageConstraints(type); - - if (!constraints.IsSizeValid(width, height)) - { - throw new TurnierplanException($"Image with size {width}x{height} does not meet criteria of type {type}: {constraints}"); - } - } } diff --git a/src/Turnierplan.Core/Image/ImageConstraints.cs b/src/Turnierplan.Core/Image/ImageConstraints.cs deleted file mode 100644 index bdbfcbd3..00000000 --- a/src/Turnierplan.Core/Image/ImageConstraints.cs +++ /dev/null @@ -1,64 +0,0 @@ -namespace Turnierplan.Core.Image; - -public sealed class ImageConstraints -{ - private static readonly ImageConstraints __logoConstraints = new(true); - private static readonly ImageConstraints __bannerConstraints = new(false, 3, 5); - - private readonly bool _mustBeSquare; - private readonly float? _minimumAspectRatio; - private readonly float? _maximumAspectRatio; - - private ImageConstraints(bool mustBeSquare, float? minimumAspectRatio = null, float? maximumAspectRatio = null) - { - if (mustBeSquare) - { - if (minimumAspectRatio is not null || maximumAspectRatio is not null) - { - throw new ArgumentException("Aspect ratio cannot be set if mustBeSquare is true."); - } - - _mustBeSquare = true; - } - else - { - _mustBeSquare = false; - _minimumAspectRatio = minimumAspectRatio ?? throw new ArgumentNullException(nameof(minimumAspectRatio)); - _maximumAspectRatio = maximumAspectRatio ?? throw new ArgumentNullException(nameof(maximumAspectRatio)); - } - } - - public bool IsSizeValid(ushort width, ushort height) - { - if (width == 0 || height == 0) - { - return false; - } - - if (_mustBeSquare) - { - return width == height; - } - - var aspectRatio = (float)width / height; - - return aspectRatio >= _minimumAspectRatio && aspectRatio <= _maximumAspectRatio; - } - - public override string ToString() - { - return _mustBeSquare - ? "Must be square" - : $"Must have aspect ratio between {_minimumAspectRatio:F2} and {_maximumAspectRatio:F2}"; - } - - public static ImageConstraints GetImageConstraints(ImageType imageType) - { - return imageType switch - { - ImageType.Logo => __logoConstraints, - ImageType.Banner => __bannerConstraints, - _ => throw new ArgumentOutOfRangeException(nameof(imageType), imageType, "Invalid image type specified.") - }; - } -} diff --git a/src/Turnierplan.Core/Image/ImageType.cs b/src/Turnierplan.Core/Image/ImageType.cs deleted file mode 100644 index b91cf30b..00000000 --- a/src/Turnierplan.Core/Image/ImageType.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Turnierplan.Core.Image; - -public enum ImageType -{ - // Note: Don't change enum values (DB serialization) - - Logo = 1, - Banner = 2 -} diff --git a/src/Turnierplan.Core/PlanningRealm/InvitationLink.cs b/src/Turnierplan.Core/PlanningRealm/InvitationLink.cs index 126df461..4c623a30 100644 --- a/src/Turnierplan.Core/PlanningRealm/InvitationLink.cs +++ b/src/Turnierplan.Core/PlanningRealm/InvitationLink.cs @@ -85,12 +85,12 @@ public void RemoveEntry(InvitationLinkEntry entry) public void SetPrimaryLogo(Image.Image? primaryLogo) { - CheckImageTypeAndSetImage(primaryLogo, () => PrimaryLogo = primaryLogo); + ValidateAndSetImage(primaryLogo, () => PrimaryLogo = primaryLogo); } public void SetSecondaryLogo(Image.Image? secondaryLogo) { - CheckImageTypeAndSetImage(secondaryLogo, () => SecondaryLogo = secondaryLogo); + ValidateAndSetImage(secondaryLogo, () => SecondaryLogo = secondaryLogo); } public bool IsValidUntilSurpassed() @@ -98,7 +98,7 @@ public bool IsValidUntilSurpassed() return ValidUntil.HasValue && ValidUntil.Value < DateTime.UtcNow; } - private void CheckImageTypeAndSetImage(Image.Image? provided, Action apply) + private void ValidateAndSetImage(Image.Image? provided, Action apply) { if (provided is null) { @@ -111,11 +111,6 @@ private void CheckImageTypeAndSetImage(Image.Image? provided, Action apply) throw new TurnierplanException("Cannot assign an image from another organization."); } - if (provided.Type != ImageType.Logo) - { - throw new TurnierplanException($"Cannot assign image because the image's type is not the expected type '{ImageType.Logo}'."); - } - apply(); } diff --git a/src/Turnierplan.Core/Tournament/Tournament.cs b/src/Turnierplan.Core/Tournament/Tournament.cs index e1e140a1..6b8cb1fa 100644 --- a/src/Turnierplan.Core/Tournament/Tournament.cs +++ b/src/Turnierplan.Core/Tournament/Tournament.cs @@ -1,7 +1,6 @@ using Turnierplan.Core.Entity; using Turnierplan.Core.Exceptions; using Turnierplan.Core.Extensions; -using Turnierplan.Core.Image; using Turnierplan.Core.RoleAssignment; using Turnierplan.Core.Tournament.Comparers; using Turnierplan.Core.Tournament.Definitions; @@ -304,17 +303,17 @@ public void SetVenue(Venue.Venue? venue) public void SetPrimaryLogo(Image.Image? primaryLogo) { - CheckImageTypeAndSetImage(primaryLogo, ImageType.Logo, () => PrimaryLogo = primaryLogo); + ValidateAndSetImage(primaryLogo, () => PrimaryLogo = primaryLogo); } public void SetSecondaryLogo(Image.Image? secondaryLogo) { - CheckImageTypeAndSetImage(secondaryLogo, ImageType.Logo, () => SecondaryLogo = secondaryLogo); + ValidateAndSetImage(secondaryLogo, () => SecondaryLogo = secondaryLogo); } public void SetBannerImage(Image.Image? bannerImage) { - CheckImageTypeAndSetImage(bannerImage, ImageType.Banner, () => BannerImage = bannerImage); + ValidateAndSetImage(bannerImage, () => BannerImage = bannerImage); } public void ShiftToTimezone(TimeZoneInfo timeZone) @@ -1075,7 +1074,7 @@ private int[] GetGroupIdsForConvertingAbstractTeamSelector() .ToArray(); } - private void CheckImageTypeAndSetImage(Image.Image? provided, ImageType expectedType, Action apply) + private void ValidateAndSetImage(Image.Image? provided, Action apply) { if (provided is null) { @@ -1088,11 +1087,6 @@ private void CheckImageTypeAndSetImage(Image.Image? provided, ImageType expected throw new TurnierplanException("Cannot assign an image from another organization."); } - if (provided.Type != expectedType) - { - throw new TurnierplanException($"Cannot assign image because the image's type is not the expected type '{expectedType}'."); - } - apply(); } diff --git a/src/Turnierplan.Dal/EntityConfigurations/ImageEntityTypeConfiguration.cs b/src/Turnierplan.Dal/EntityConfigurations/ImageEntityTypeConfiguration.cs index a2027120..67b50ff1 100644 --- a/src/Turnierplan.Dal/EntityConfigurations/ImageEntityTypeConfiguration.cs +++ b/src/Turnierplan.Dal/EntityConfigurations/ImageEntityTypeConfiguration.cs @@ -40,10 +40,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Name) .IsRequired(); - builder.Property(x => x.Type) - .IsRequired(); - - builder.Property(x => x.FileType) + builder.Property(x => x.FileExtension) .IsRequired(); builder.Property(x => x.FileSize) diff --git a/src/Turnierplan.Dal/Migrations/20260201142441_Remove_ImageType.Designer.cs b/src/Turnierplan.Dal/Migrations/20260201142441_Remove_ImageType.Designer.cs new file mode 100644 index 00000000..737fee25 --- /dev/null +++ b/src/Turnierplan.Dal/Migrations/20260201142441_Remove_ImageType.Designer.cs @@ -0,0 +1,1881 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Turnierplan.Dal; + +#nullable disable + +namespace Turnierplan.Dal.Migrations +{ + [DbContext(typeof(TurnierplanContext))] + [Migration("20260201142441_Remove_ImageType")] + partial class Remove_ImageType + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ApplicationTeamLabel", b => + { + b.Property("ApplicationTeamId") + .HasColumnType("bigint"); + + b.Property("LabelsId") + .HasColumnType("bigint"); + + b.HasKey("ApplicationTeamId", "LabelsId"); + + b.HasIndex("LabelsId"); + + b.ToTable("ApplicationTeamLabel", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PrincipalId") + .HasColumnType("uuid"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("SecretHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PrincipalId") + .IsUnique(); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("ApiKeys", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKeyRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiKeyId") + .HasColumnType("bigint"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ApiKeyId"); + + b.HasIndex("Timestamp"); + + b.ToTable("ApiKeyRequests", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Document.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Configuration") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GenerationCount") + .HasColumnType("integer"); + + b.Property("LastGeneration") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("TournamentId"); + + b.ToTable("Documents", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Folder.Folder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("Folders", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Image.Image", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("ResourceIdentifier") + .HasColumnType("uuid"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("ResourceIdentifier") + .IsUnique(); + + b.ToTable("Images", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Organization.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("Organizations", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.Application", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Comment") + .HasColumnType("text"); + + b.Property("Contact") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("ContactTelephone") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FormSession") + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlanningRealmId") + .HasColumnType("bigint"); + + b.Property("SourceLinkId") + .HasColumnType("bigint"); + + b.Property("Tag") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FormSession") + .IsUnique(); + + b.HasIndex("PlanningRealmId"); + + b.HasIndex("SourceLinkId"); + + b.ToTable("Applications", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.ApplicationChangeLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApplicationId") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationChangeLogs", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.ApplicationTeam", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApplicationId") + .HasColumnType("bigint"); + + b.Property("ClassId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("ClassId"); + + b.ToTable("ApplicationTeams", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.InvitationLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ColorCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("ContactPerson") + .HasColumnType("text"); + + b.Property("ContactTelephone") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlanningRealmId") + .HasColumnType("bigint"); + + b.Property("PrimaryLogoId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("SecondaryLogoId") + .HasColumnType("bigint"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PlanningRealmId"); + + b.HasIndex("PrimaryLogoId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("SecondaryLogoId"); + + b.ToTable("InvitationLinks", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.InvitationLinkEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowNewRegistrations") + .HasColumnType("boolean"); + + b.Property("ClassId") + .HasColumnType("bigint"); + + b.Property("InvitationLinkId") + .HasColumnType("bigint"); + + b.Property("MaxTeamsPerRegistration") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClassId"); + + b.HasIndex("InvitationLinkId"); + + b.ToTable("InvitationLinkEntries", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.Label", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ColorCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlanningRealmId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PlanningRealmId"); + + b.ToTable("Labels", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.PlanningRealm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("PlanningRealms", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.TeamLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApplicationTeamId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TeamId") + .HasColumnType("integer"); + + b.Property("TeamTournamentId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationTeamId") + .IsUnique(); + + b.HasIndex("TeamTournamentId", "TeamId") + .IsUnique(); + + b.ToTable("TeamLinks", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.TournamentClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlanningRealmId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PlanningRealmId"); + + b.ToTable("TournamentClasses", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiKeyId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApiKeyId"); + + b.ToTable("IAM_ApiKey", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FolderId") + .HasColumnType("bigint"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.ToTable("IAM_Folder", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ImageId") + .HasColumnType("bigint"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImageId"); + + b.ToTable("IAM_Image", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("IAM_Organization", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlanningRealmId") + .HasColumnType("bigint"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlanningRealmId"); + + b.ToTable("IAM_PlanningRealm", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TournamentId"); + + b.ToTable("IAM_Tournament", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("VenueId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("VenueId"); + + b.ToTable("IAM_Venue", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Group", b => + { + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AlphabeticalId") + .HasColumnType("character(1)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.HasKey("TournamentId", "Id"); + + b.ToTable("Groups", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.GroupParticipant", b => + { + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("GroupId") + .HasColumnType("integer"); + + b.Property("TeamId") + .HasColumnType("integer"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.HasKey("TournamentId", "GroupId", "TeamId"); + + b.HasIndex("TournamentId", "TeamId"); + + b.ToTable("GroupParticipants", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Match", b => + { + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("Court") + .HasColumnType("smallint"); + + b.Property("FinalsRound") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("integer"); + + b.Property("Index") + .HasColumnType("integer"); + + b.Property("IsCurrentlyPlaying") + .HasColumnType("boolean"); + + b.Property("Kickoff") + .HasColumnType("timestamp with time zone"); + + b.Property("OutcomeType") + .HasColumnType("integer"); + + b.Property("PlayoffPosition") + .HasColumnType("integer"); + + b.Property("ScoreA") + .HasColumnType("integer"); + + b.Property("ScoreB") + .HasColumnType("integer"); + + b.Property("TeamSelectorA") + .IsRequired() + .HasColumnType("text"); + + b.Property("TeamSelectorB") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("TournamentId", "Id"); + + b.HasIndex("TournamentId", "GroupId"); + + b.ToTable("Matches", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.RankingOverwrite", b => + { + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AssignTeamId") + .HasColumnType("integer"); + + b.Property("AssignTeamTournamentId") + .HasColumnType("bigint"); + + b.Property("HideRanking") + .HasColumnType("boolean"); + + b.Property("PlacementRank") + .HasColumnType("integer"); + + b.HasKey("TournamentId", "Id"); + + b.HasIndex("AssignTeamTournamentId", "AssignTeamId"); + + b.ToTable("RankingOverwrites", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Team", b => + { + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("EntryFeePaidAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutOfCompetition") + .HasColumnType("boolean"); + + b.HasKey("TournamentId", "Id"); + + b.ToTable("Teams", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Tournament", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BannerImageId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FolderId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PrimaryLogoId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("PublicPageViews") + .HasColumnType("integer"); + + b.Property("SecondaryLogoId") + .HasColumnType("bigint"); + + b.Property("VenueId") + .HasColumnType("bigint"); + + b.Property("Visibility") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BannerImageId"); + + b.HasIndex("FolderId"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PrimaryLogoId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("SecondaryLogoId"); + + b.HasIndex("VenueId"); + + b.ToTable("Tournaments", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.User.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EMail") + .HasColumnType("text"); + + b.Property("FullName") + .HasColumnType("text"); + + b.Property("IsAdministrator") + .HasColumnType("boolean"); + + b.Property("LastLogin") + .HasColumnType("timestamp with time zone"); + + b.Property("LastPasswordChange") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEMail") + .HasColumnType("text"); + + b.Property("NormalizedUserName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrincipalId") + .HasColumnType("uuid"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEMail") + .IsUnique(); + + b.HasIndex("NormalizedUserName") + .IsUnique(); + + b.HasIndex("PrincipalId") + .IsUnique(); + + b.ToTable("Users", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Venue.Venue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.PrimitiveCollection>("AddressDetails") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.PrimitiveCollection>("ExternalLinks") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("Venues", "turnierplan"); + }); + + modelBuilder.Entity("ApplicationTeamLabel", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.ApplicationTeam", null) + .WithMany() + .HasForeignKey("ApplicationTeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.PlanningRealm.Label", null) + .WithMany() + .HasForeignKey("LabelsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKey", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKeyRequest", b => + { + b.HasOne("Turnierplan.Core.ApiKey.ApiKey", "ApiKey") + .WithMany("Requests") + .HasForeignKey("ApiKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApiKey"); + }); + + modelBuilder.Entity("Turnierplan.Core.Document.Document", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", "Tournament") + .WithMany("Documents") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tournament"); + }); + + modelBuilder.Entity("Turnierplan.Core.Folder.Folder", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("Folders") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.Image.Image", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("Images") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.Application", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.PlanningRealm", "PlanningRealm") + .WithMany("Applications") + .HasForeignKey("PlanningRealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.PlanningRealm.InvitationLink", "SourceLink") + .WithMany() + .HasForeignKey("SourceLinkId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("PlanningRealm"); + + b.Navigation("SourceLink"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.ApplicationChangeLog", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.Application", "Application") + .WithMany("ChangeLog") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("Turnierplan.Core.PlanningRealm.ApplicationChangeLog+Property", "Properties", b1 => + { + b1.Property("ApplicationChangeLogId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Type") + .HasJsonPropertyName("t"); + + b1.Property("Value") + .IsRequired() + .HasJsonPropertyName("v"); + + b1.HasKey("ApplicationChangeLogId", "__synthesizedOrdinal"); + + b1.ToTable("ApplicationChangeLogs", "turnierplan"); + + b1.ToJson("Properties"); + + b1.WithOwner() + .HasForeignKey("ApplicationChangeLogId"); + }); + + b.Navigation("Application"); + + b.Navigation("Properties"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.ApplicationTeam", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.Application", "Application") + .WithMany("Teams") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.PlanningRealm.TournamentClass", "Class") + .WithMany() + .HasForeignKey("ClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Class"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.InvitationLink", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.PlanningRealm", "PlanningRealm") + .WithMany("InvitationLinks") + .HasForeignKey("PlanningRealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Image.Image", "PrimaryLogo") + .WithMany() + .HasForeignKey("PrimaryLogoId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Image.Image", "SecondaryLogo") + .WithMany() + .HasForeignKey("SecondaryLogoId") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsMany("Turnierplan.Core.PlanningRealm.InvitationLink+ExternalLink", "ExternalLinks", b1 => + { + b1.Property("InvitationLinkId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Name") + .IsRequired() + .HasJsonPropertyName("n"); + + b1.Property("Url") + .IsRequired() + .HasJsonPropertyName("u"); + + b1.HasKey("InvitationLinkId", "__synthesizedOrdinal"); + + b1.ToTable("InvitationLinks", "turnierplan"); + + b1.ToJson("ExternalLinks"); + + b1.WithOwner() + .HasForeignKey("InvitationLinkId"); + }); + + b.Navigation("ExternalLinks"); + + b.Navigation("PlanningRealm"); + + b.Navigation("PrimaryLogo"); + + b.Navigation("SecondaryLogo"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.InvitationLinkEntry", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.TournamentClass", "Class") + .WithMany() + .HasForeignKey("ClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.PlanningRealm.InvitationLink", null) + .WithMany("Entries") + .HasForeignKey("InvitationLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Class"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.Label", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.PlanningRealm", null) + .WithMany("Labels") + .HasForeignKey("PlanningRealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.PlanningRealm", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("PlanningRealms") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.TeamLink", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.ApplicationTeam", "ApplicationTeam") + .WithOne("TeamLink") + .HasForeignKey("Turnierplan.Core.PlanningRealm.TeamLink", "ApplicationTeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Tournament.Team", "Team") + .WithOne("TeamLink") + .HasForeignKey("Turnierplan.Core.PlanningRealm.TeamLink", "TeamTournamentId", "TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApplicationTeam"); + + b.Navigation("Team"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.TournamentClass", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.PlanningRealm", null) + .WithMany("TournamentClasses") + .HasForeignKey("PlanningRealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.ApiKey.ApiKey", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("ApiKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Folder.Folder", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Image.Image", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("ImageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.PlanningRealm", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("PlanningRealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Venue.Venue", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("VenueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Group", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", null) + .WithMany("Groups") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.GroupParticipant", b => + { + b.HasOne("Turnierplan.Core.Tournament.Group", "Group") + .WithMany("Participants") + .HasForeignKey("TournamentId", "GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Tournament.Team", "Team") + .WithMany() + .HasForeignKey("TournamentId", "TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Team"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Match", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", null) + .WithMany("Matches") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Tournament.Group", "Group") + .WithMany() + .HasForeignKey("TournamentId", "GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.RankingOverwrite", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", null) + .WithMany("RankingOverwrites") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Tournament.Team", "AssignTeam") + .WithMany() + .HasForeignKey("AssignTeamTournamentId", "AssignTeamId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AssignTeam"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Team", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", "Tournament") + .WithMany("Teams") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tournament"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Tournament", b => + { + b.HasOne("Turnierplan.Core.Image.Image", "BannerImage") + .WithMany() + .HasForeignKey("BannerImageId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Folder.Folder", "Folder") + .WithMany("Tournaments") + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("Tournaments") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Image.Image", "PrimaryLogo") + .WithMany() + .HasForeignKey("PrimaryLogoId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Image.Image", "SecondaryLogo") + .WithMany() + .HasForeignKey("SecondaryLogoId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Venue.Venue", "Venue") + .WithMany("Tournaments") + .HasForeignKey("VenueId") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("Turnierplan.Core.Tournament.ComputationConfiguration", "ComputationConfiguration", b1 => + { + b1.Property("TournamentId"); + + b1.PrimitiveCollection("ComparisonModes") + .IsRequired() + .HasJsonPropertyName("cmp"); + + b1.Property("HigherScoreLoses") + .HasJsonPropertyName("r"); + + b1.Property("MatchDrawnPoints") + .HasJsonPropertyName("d"); + + b1.Property("MatchLostPoints") + .HasJsonPropertyName("l"); + + b1.Property("MatchWonPoints") + .HasJsonPropertyName("w"); + + b1.HasKey("TournamentId"); + + b1.ToTable("Tournaments", "turnierplan"); + + b1.ToJson("ComputationConfiguration"); + + b1.WithOwner() + .HasForeignKey("TournamentId"); + }); + + b.OwnsOne("Turnierplan.Core.Tournament.MatchPlanConfiguration", "MatchPlanConfiguration", b1 => + { + b1.Property("TournamentId"); + + b1.HasKey("TournamentId"); + + b1.ToTable("Tournaments", "turnierplan"); + + b1.ToJson("MatchPlanConfiguration"); + + b1.WithOwner() + .HasForeignKey("TournamentId"); + + b1.OwnsOne("Turnierplan.Core.Tournament.FinalsRoundConfig", "FinalsRoundConfig", b2 => + { + b2.Property("MatchPlanConfigurationTournamentId"); + + b2.Property("EnableThirdPlacePlayoff") + .HasJsonPropertyName("3rd"); + + b2.Property("FirstFinalsRoundOrder") + .HasJsonPropertyName("fo"); + + b2.PrimitiveCollection("TeamSelectors") + .HasJsonPropertyName("ts"); + + b2.HasKey("MatchPlanConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasJsonPropertyName("fr"); + + b2.WithOwner() + .HasForeignKey("MatchPlanConfigurationTournamentId"); + + b2.OwnsMany("Turnierplan.Core.Tournament.AdditionalPlayoffConfig", "AdditionalPlayoffs", b3 => + { + b3.Property("FinalsRoundConfigMatchPlanConfigurationTournamentId"); + + b3.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b3.Property("PlayoffPosition") + .HasJsonPropertyName("p"); + + b3.Property("TeamSelectorA") + .IsRequired() + .HasJsonPropertyName("a"); + + b3.Property("TeamSelectorB") + .IsRequired() + .HasJsonPropertyName("b"); + + b3.HasKey("FinalsRoundConfigMatchPlanConfigurationTournamentId", "__synthesizedOrdinal"); + + b3.ToTable("Tournaments", "turnierplan"); + + b3.HasJsonPropertyName("ap"); + + b3.WithOwner() + .HasForeignKey("FinalsRoundConfigMatchPlanConfigurationTournamentId"); + }); + + b2.Navigation("AdditionalPlayoffs"); + }); + + b1.OwnsOne("Turnierplan.Core.Tournament.GroupRoundConfig", "GroupRoundConfig", b2 => + { + b2.Property("MatchPlanConfigurationTournamentId"); + + b2.Property("GroupMatchOrder") + .HasJsonPropertyName("o"); + + b2.Property("GroupPhaseRounds") + .HasJsonPropertyName("r"); + + b2.HasKey("MatchPlanConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasJsonPropertyName("gr"); + + b2.WithOwner() + .HasForeignKey("MatchPlanConfigurationTournamentId"); + }); + + b1.OwnsOne("Turnierplan.Core.Tournament.ScheduleConfig", "ScheduleConfig", b2 => + { + b2.Property("MatchPlanConfigurationTournamentId"); + + b2.Property("FinalsPhaseNumberOfCourts") + .HasJsonPropertyName("fc"); + + b2.Property("FinalsPhasePauseTime") + .HasJsonPropertyName("fp"); + + b2.Property("FinalsPhasePlayTime") + .HasJsonPropertyName("fd"); + + b2.Property("FirstMatchKickoff") + .HasJsonPropertyName("f"); + + b2.Property("GroupPhaseNumberOfCourts") + .HasJsonPropertyName("gc"); + + b2.Property("GroupPhasePauseTime") + .HasJsonPropertyName("gp"); + + b2.Property("GroupPhasePlayTime") + .HasJsonPropertyName("gd"); + + b2.Property("PauseBetweenGroupAndFinalsPhase") + .HasJsonPropertyName("p"); + + b2.HasKey("MatchPlanConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasJsonPropertyName("sc"); + + b2.WithOwner() + .HasForeignKey("MatchPlanConfigurationTournamentId"); + }); + + b1.Navigation("FinalsRoundConfig"); + + b1.Navigation("GroupRoundConfig"); + + b1.Navigation("ScheduleConfig"); + }); + + b.OwnsOne("Turnierplan.Core.Tournament.PresentationConfiguration", "PresentationConfiguration", b1 => + { + b1.Property("TournamentId"); + + b1.Property("ShowPrimaryLogo") + .HasJsonPropertyName("ol"); + + b1.Property("ShowResults") + .HasJsonPropertyName("o"); + + b1.Property("ShowSecondaryLogo") + .HasJsonPropertyName("sl"); + + b1.HasKey("TournamentId"); + + b1.ToTable("Tournaments", "turnierplan"); + + b1.ToJson("PresentationConfiguration"); + + b1.WithOwner() + .HasForeignKey("TournamentId"); + + b1.OwnsOne("Turnierplan.Core.Tournament.PresentationConfiguration+HeaderLine", "Header1", b2 => + { + b2.Property("PresentationConfigurationTournamentId"); + + b2.Property("Content") + .HasJsonPropertyName("c"); + + b2.Property("CustomContent") + .HasJsonPropertyName("cc"); + + b2.HasKey("PresentationConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasJsonPropertyName("h1"); + + b2.WithOwner() + .HasForeignKey("PresentationConfigurationTournamentId"); + }); + + b1.OwnsOne("Turnierplan.Core.Tournament.PresentationConfiguration+HeaderLine", "Header2", b2 => + { + b2.Property("PresentationConfigurationTournamentId"); + + b2.Property("Content") + .HasJsonPropertyName("c"); + + b2.Property("CustomContent") + .HasJsonPropertyName("cc"); + + b2.HasKey("PresentationConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasJsonPropertyName("h2"); + + b2.WithOwner() + .HasForeignKey("PresentationConfigurationTournamentId"); + }); + + b1.Navigation("Header1") + .IsRequired(); + + b1.Navigation("Header2") + .IsRequired(); + }); + + b.Navigation("BannerImage"); + + b.Navigation("ComputationConfiguration") + .IsRequired(); + + b.Navigation("Folder"); + + b.Navigation("MatchPlanConfiguration"); + + b.Navigation("Organization"); + + b.Navigation("PresentationConfiguration") + .IsRequired(); + + b.Navigation("PrimaryLogo"); + + b.Navigation("SecondaryLogo"); + + b.Navigation("Venue"); + }); + + modelBuilder.Entity("Turnierplan.Core.Venue.Venue", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("Venues") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKey", b => + { + b.Navigation("Requests"); + + b.Navigation("RoleAssignments"); + }); + + modelBuilder.Entity("Turnierplan.Core.Folder.Folder", b => + { + b.Navigation("RoleAssignments"); + + b.Navigation("Tournaments"); + }); + + modelBuilder.Entity("Turnierplan.Core.Image.Image", b => + { + b.Navigation("RoleAssignments"); + }); + + modelBuilder.Entity("Turnierplan.Core.Organization.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Folders"); + + b.Navigation("Images"); + + b.Navigation("PlanningRealms"); + + b.Navigation("RoleAssignments"); + + b.Navigation("Tournaments"); + + b.Navigation("Venues"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.Application", b => + { + b.Navigation("ChangeLog"); + + b.Navigation("Teams"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.ApplicationTeam", b => + { + b.Navigation("TeamLink"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.InvitationLink", b => + { + b.Navigation("Entries"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.PlanningRealm", b => + { + b.Navigation("Applications"); + + b.Navigation("InvitationLinks"); + + b.Navigation("Labels"); + + b.Navigation("RoleAssignments"); + + b.Navigation("TournamentClasses"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Group", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Team", b => + { + b.Navigation("TeamLink"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Tournament", b => + { + b.Navigation("Documents"); + + b.Navigation("Groups"); + + b.Navigation("Matches"); + + b.Navigation("RankingOverwrites"); + + b.Navigation("RoleAssignments"); + + b.Navigation("Teams"); + }); + + modelBuilder.Entity("Turnierplan.Core.Venue.Venue", b => + { + b.Navigation("RoleAssignments"); + + b.Navigation("Tournaments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Turnierplan.Dal/Migrations/20260201142441_Remove_ImageType.cs b/src/Turnierplan.Dal/Migrations/20260201142441_Remove_ImageType.cs new file mode 100644 index 00000000..c45e9629 --- /dev/null +++ b/src/Turnierplan.Dal/Migrations/20260201142441_Remove_ImageType.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Turnierplan.Dal.Migrations +{ + /// + public partial class Remove_ImageType : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Type", + schema: "turnierplan", + table: "Images"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Type", + schema: "turnierplan", + table: "Images", + type: "integer", + nullable: false, + defaultValue: 0); + } + } +} diff --git a/src/Turnierplan.Dal/Migrations/20260201142521_Rename_ImageFileExtension.Designer.cs b/src/Turnierplan.Dal/Migrations/20260201142521_Rename_ImageFileExtension.Designer.cs new file mode 100644 index 00000000..7daf64a1 --- /dev/null +++ b/src/Turnierplan.Dal/Migrations/20260201142521_Rename_ImageFileExtension.Designer.cs @@ -0,0 +1,1881 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Turnierplan.Dal; + +#nullable disable + +namespace Turnierplan.Dal.Migrations +{ + [DbContext(typeof(TurnierplanContext))] + [Migration("20260201142521_Rename_ImageFileExtension")] + partial class Rename_ImageFileExtension + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ApplicationTeamLabel", b => + { + b.Property("ApplicationTeamId") + .HasColumnType("bigint"); + + b.Property("LabelsId") + .HasColumnType("bigint"); + + b.HasKey("ApplicationTeamId", "LabelsId"); + + b.HasIndex("LabelsId"); + + b.ToTable("ApplicationTeamLabel", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PrincipalId") + .HasColumnType("uuid"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("SecretHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PrincipalId") + .IsUnique(); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("ApiKeys", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKeyRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiKeyId") + .HasColumnType("bigint"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ApiKeyId"); + + b.HasIndex("Timestamp"); + + b.ToTable("ApiKeyRequests", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Document.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Configuration") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GenerationCount") + .HasColumnType("integer"); + + b.Property("LastGeneration") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("TournamentId"); + + b.ToTable("Documents", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Folder.Folder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("Folders", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Image.Image", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("ResourceIdentifier") + .HasColumnType("uuid"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("ResourceIdentifier") + .IsUnique(); + + b.ToTable("Images", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Organization.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("Organizations", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.Application", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Comment") + .HasColumnType("text"); + + b.Property("Contact") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("ContactTelephone") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FormSession") + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlanningRealmId") + .HasColumnType("bigint"); + + b.Property("SourceLinkId") + .HasColumnType("bigint"); + + b.Property("Tag") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FormSession") + .IsUnique(); + + b.HasIndex("PlanningRealmId"); + + b.HasIndex("SourceLinkId"); + + b.ToTable("Applications", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.ApplicationChangeLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApplicationId") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationChangeLogs", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.ApplicationTeam", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApplicationId") + .HasColumnType("bigint"); + + b.Property("ClassId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("ClassId"); + + b.ToTable("ApplicationTeams", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.InvitationLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ColorCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("ContactPerson") + .HasColumnType("text"); + + b.Property("ContactTelephone") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlanningRealmId") + .HasColumnType("bigint"); + + b.Property("PrimaryLogoId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("SecondaryLogoId") + .HasColumnType("bigint"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PlanningRealmId"); + + b.HasIndex("PrimaryLogoId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("SecondaryLogoId"); + + b.ToTable("InvitationLinks", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.InvitationLinkEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowNewRegistrations") + .HasColumnType("boolean"); + + b.Property("ClassId") + .HasColumnType("bigint"); + + b.Property("InvitationLinkId") + .HasColumnType("bigint"); + + b.Property("MaxTeamsPerRegistration") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClassId"); + + b.HasIndex("InvitationLinkId"); + + b.ToTable("InvitationLinkEntries", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.Label", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ColorCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlanningRealmId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PlanningRealmId"); + + b.ToTable("Labels", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.PlanningRealm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("PlanningRealms", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.TeamLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApplicationTeamId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TeamId") + .HasColumnType("integer"); + + b.Property("TeamTournamentId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationTeamId") + .IsUnique(); + + b.HasIndex("TeamTournamentId", "TeamId") + .IsUnique(); + + b.ToTable("TeamLinks", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.TournamentClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlanningRealmId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PlanningRealmId"); + + b.ToTable("TournamentClasses", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiKeyId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApiKeyId"); + + b.ToTable("IAM_ApiKey", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FolderId") + .HasColumnType("bigint"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.ToTable("IAM_Folder", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ImageId") + .HasColumnType("bigint"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImageId"); + + b.ToTable("IAM_Image", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("IAM_Organization", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlanningRealmId") + .HasColumnType("bigint"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlanningRealmId"); + + b.ToTable("IAM_PlanningRealm", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TournamentId"); + + b.ToTable("IAM_Tournament", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("VenueId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("VenueId"); + + b.ToTable("IAM_Venue", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Group", b => + { + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AlphabeticalId") + .HasColumnType("character(1)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.HasKey("TournamentId", "Id"); + + b.ToTable("Groups", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.GroupParticipant", b => + { + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("GroupId") + .HasColumnType("integer"); + + b.Property("TeamId") + .HasColumnType("integer"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.HasKey("TournamentId", "GroupId", "TeamId"); + + b.HasIndex("TournamentId", "TeamId"); + + b.ToTable("GroupParticipants", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Match", b => + { + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("Court") + .HasColumnType("smallint"); + + b.Property("FinalsRound") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("integer"); + + b.Property("Index") + .HasColumnType("integer"); + + b.Property("IsCurrentlyPlaying") + .HasColumnType("boolean"); + + b.Property("Kickoff") + .HasColumnType("timestamp with time zone"); + + b.Property("OutcomeType") + .HasColumnType("integer"); + + b.Property("PlayoffPosition") + .HasColumnType("integer"); + + b.Property("ScoreA") + .HasColumnType("integer"); + + b.Property("ScoreB") + .HasColumnType("integer"); + + b.Property("TeamSelectorA") + .IsRequired() + .HasColumnType("text"); + + b.Property("TeamSelectorB") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("TournamentId", "Id"); + + b.HasIndex("TournamentId", "GroupId"); + + b.ToTable("Matches", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.RankingOverwrite", b => + { + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AssignTeamId") + .HasColumnType("integer"); + + b.Property("AssignTeamTournamentId") + .HasColumnType("bigint"); + + b.Property("HideRanking") + .HasColumnType("boolean"); + + b.Property("PlacementRank") + .HasColumnType("integer"); + + b.HasKey("TournamentId", "Id"); + + b.HasIndex("AssignTeamTournamentId", "AssignTeamId"); + + b.ToTable("RankingOverwrites", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Team", b => + { + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("EntryFeePaidAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutOfCompetition") + .HasColumnType("boolean"); + + b.HasKey("TournamentId", "Id"); + + b.ToTable("Teams", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Tournament", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BannerImageId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FolderId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PrimaryLogoId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("PublicPageViews") + .HasColumnType("integer"); + + b.Property("SecondaryLogoId") + .HasColumnType("bigint"); + + b.Property("VenueId") + .HasColumnType("bigint"); + + b.Property("Visibility") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BannerImageId"); + + b.HasIndex("FolderId"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PrimaryLogoId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("SecondaryLogoId"); + + b.HasIndex("VenueId"); + + b.ToTable("Tournaments", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.User.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EMail") + .HasColumnType("text"); + + b.Property("FullName") + .HasColumnType("text"); + + b.Property("IsAdministrator") + .HasColumnType("boolean"); + + b.Property("LastLogin") + .HasColumnType("timestamp with time zone"); + + b.Property("LastPasswordChange") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEMail") + .HasColumnType("text"); + + b.Property("NormalizedUserName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrincipalId") + .HasColumnType("uuid"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEMail") + .IsUnique(); + + b.HasIndex("NormalizedUserName") + .IsUnique(); + + b.HasIndex("PrincipalId") + .IsUnique(); + + b.ToTable("Users", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Venue.Venue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.PrimitiveCollection>("AddressDetails") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.PrimitiveCollection>("ExternalLinks") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("Venues", "turnierplan"); + }); + + modelBuilder.Entity("ApplicationTeamLabel", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.ApplicationTeam", null) + .WithMany() + .HasForeignKey("ApplicationTeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.PlanningRealm.Label", null) + .WithMany() + .HasForeignKey("LabelsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKey", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKeyRequest", b => + { + b.HasOne("Turnierplan.Core.ApiKey.ApiKey", "ApiKey") + .WithMany("Requests") + .HasForeignKey("ApiKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApiKey"); + }); + + modelBuilder.Entity("Turnierplan.Core.Document.Document", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", "Tournament") + .WithMany("Documents") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tournament"); + }); + + modelBuilder.Entity("Turnierplan.Core.Folder.Folder", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("Folders") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.Image.Image", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("Images") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.Application", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.PlanningRealm", "PlanningRealm") + .WithMany("Applications") + .HasForeignKey("PlanningRealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.PlanningRealm.InvitationLink", "SourceLink") + .WithMany() + .HasForeignKey("SourceLinkId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("PlanningRealm"); + + b.Navigation("SourceLink"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.ApplicationChangeLog", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.Application", "Application") + .WithMany("ChangeLog") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("Turnierplan.Core.PlanningRealm.ApplicationChangeLog+Property", "Properties", b1 => + { + b1.Property("ApplicationChangeLogId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Type") + .HasJsonPropertyName("t"); + + b1.Property("Value") + .IsRequired() + .HasJsonPropertyName("v"); + + b1.HasKey("ApplicationChangeLogId", "__synthesizedOrdinal"); + + b1.ToTable("ApplicationChangeLogs", "turnierplan"); + + b1.ToJson("Properties"); + + b1.WithOwner() + .HasForeignKey("ApplicationChangeLogId"); + }); + + b.Navigation("Application"); + + b.Navigation("Properties"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.ApplicationTeam", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.Application", "Application") + .WithMany("Teams") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.PlanningRealm.TournamentClass", "Class") + .WithMany() + .HasForeignKey("ClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Class"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.InvitationLink", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.PlanningRealm", "PlanningRealm") + .WithMany("InvitationLinks") + .HasForeignKey("PlanningRealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Image.Image", "PrimaryLogo") + .WithMany() + .HasForeignKey("PrimaryLogoId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Image.Image", "SecondaryLogo") + .WithMany() + .HasForeignKey("SecondaryLogoId") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsMany("Turnierplan.Core.PlanningRealm.InvitationLink+ExternalLink", "ExternalLinks", b1 => + { + b1.Property("InvitationLinkId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Name") + .IsRequired() + .HasJsonPropertyName("n"); + + b1.Property("Url") + .IsRequired() + .HasJsonPropertyName("u"); + + b1.HasKey("InvitationLinkId", "__synthesizedOrdinal"); + + b1.ToTable("InvitationLinks", "turnierplan"); + + b1.ToJson("ExternalLinks"); + + b1.WithOwner() + .HasForeignKey("InvitationLinkId"); + }); + + b.Navigation("ExternalLinks"); + + b.Navigation("PlanningRealm"); + + b.Navigation("PrimaryLogo"); + + b.Navigation("SecondaryLogo"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.InvitationLinkEntry", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.TournamentClass", "Class") + .WithMany() + .HasForeignKey("ClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.PlanningRealm.InvitationLink", null) + .WithMany("Entries") + .HasForeignKey("InvitationLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Class"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.Label", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.PlanningRealm", null) + .WithMany("Labels") + .HasForeignKey("PlanningRealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.PlanningRealm", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("PlanningRealms") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.TeamLink", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.ApplicationTeam", "ApplicationTeam") + .WithOne("TeamLink") + .HasForeignKey("Turnierplan.Core.PlanningRealm.TeamLink", "ApplicationTeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Tournament.Team", "Team") + .WithOne("TeamLink") + .HasForeignKey("Turnierplan.Core.PlanningRealm.TeamLink", "TeamTournamentId", "TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApplicationTeam"); + + b.Navigation("Team"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.TournamentClass", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.PlanningRealm", null) + .WithMany("TournamentClasses") + .HasForeignKey("PlanningRealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.ApiKey.ApiKey", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("ApiKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Folder.Folder", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Image.Image", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("ImageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.PlanningRealm", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("PlanningRealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Venue.Venue", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("VenueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Group", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", null) + .WithMany("Groups") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.GroupParticipant", b => + { + b.HasOne("Turnierplan.Core.Tournament.Group", "Group") + .WithMany("Participants") + .HasForeignKey("TournamentId", "GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Tournament.Team", "Team") + .WithMany() + .HasForeignKey("TournamentId", "TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Team"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Match", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", null) + .WithMany("Matches") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Tournament.Group", "Group") + .WithMany() + .HasForeignKey("TournamentId", "GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.RankingOverwrite", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", null) + .WithMany("RankingOverwrites") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Tournament.Team", "AssignTeam") + .WithMany() + .HasForeignKey("AssignTeamTournamentId", "AssignTeamId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AssignTeam"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Team", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", "Tournament") + .WithMany("Teams") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tournament"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Tournament", b => + { + b.HasOne("Turnierplan.Core.Image.Image", "BannerImage") + .WithMany() + .HasForeignKey("BannerImageId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Folder.Folder", "Folder") + .WithMany("Tournaments") + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("Tournaments") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Image.Image", "PrimaryLogo") + .WithMany() + .HasForeignKey("PrimaryLogoId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Image.Image", "SecondaryLogo") + .WithMany() + .HasForeignKey("SecondaryLogoId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Venue.Venue", "Venue") + .WithMany("Tournaments") + .HasForeignKey("VenueId") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("Turnierplan.Core.Tournament.ComputationConfiguration", "ComputationConfiguration", b1 => + { + b1.Property("TournamentId"); + + b1.PrimitiveCollection("ComparisonModes") + .IsRequired() + .HasJsonPropertyName("cmp"); + + b1.Property("HigherScoreLoses") + .HasJsonPropertyName("r"); + + b1.Property("MatchDrawnPoints") + .HasJsonPropertyName("d"); + + b1.Property("MatchLostPoints") + .HasJsonPropertyName("l"); + + b1.Property("MatchWonPoints") + .HasJsonPropertyName("w"); + + b1.HasKey("TournamentId"); + + b1.ToTable("Tournaments", "turnierplan"); + + b1.ToJson("ComputationConfiguration"); + + b1.WithOwner() + .HasForeignKey("TournamentId"); + }); + + b.OwnsOne("Turnierplan.Core.Tournament.MatchPlanConfiguration", "MatchPlanConfiguration", b1 => + { + b1.Property("TournamentId"); + + b1.HasKey("TournamentId"); + + b1.ToTable("Tournaments", "turnierplan"); + + b1.ToJson("MatchPlanConfiguration"); + + b1.WithOwner() + .HasForeignKey("TournamentId"); + + b1.OwnsOne("Turnierplan.Core.Tournament.FinalsRoundConfig", "FinalsRoundConfig", b2 => + { + b2.Property("MatchPlanConfigurationTournamentId"); + + b2.Property("EnableThirdPlacePlayoff") + .HasJsonPropertyName("3rd"); + + b2.Property("FirstFinalsRoundOrder") + .HasJsonPropertyName("fo"); + + b2.PrimitiveCollection("TeamSelectors") + .HasJsonPropertyName("ts"); + + b2.HasKey("MatchPlanConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasJsonPropertyName("fr"); + + b2.WithOwner() + .HasForeignKey("MatchPlanConfigurationTournamentId"); + + b2.OwnsMany("Turnierplan.Core.Tournament.AdditionalPlayoffConfig", "AdditionalPlayoffs", b3 => + { + b3.Property("FinalsRoundConfigMatchPlanConfigurationTournamentId"); + + b3.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b3.Property("PlayoffPosition") + .HasJsonPropertyName("p"); + + b3.Property("TeamSelectorA") + .IsRequired() + .HasJsonPropertyName("a"); + + b3.Property("TeamSelectorB") + .IsRequired() + .HasJsonPropertyName("b"); + + b3.HasKey("FinalsRoundConfigMatchPlanConfigurationTournamentId", "__synthesizedOrdinal"); + + b3.ToTable("Tournaments", "turnierplan"); + + b3.HasJsonPropertyName("ap"); + + b3.WithOwner() + .HasForeignKey("FinalsRoundConfigMatchPlanConfigurationTournamentId"); + }); + + b2.Navigation("AdditionalPlayoffs"); + }); + + b1.OwnsOne("Turnierplan.Core.Tournament.GroupRoundConfig", "GroupRoundConfig", b2 => + { + b2.Property("MatchPlanConfigurationTournamentId"); + + b2.Property("GroupMatchOrder") + .HasJsonPropertyName("o"); + + b2.Property("GroupPhaseRounds") + .HasJsonPropertyName("r"); + + b2.HasKey("MatchPlanConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasJsonPropertyName("gr"); + + b2.WithOwner() + .HasForeignKey("MatchPlanConfigurationTournamentId"); + }); + + b1.OwnsOne("Turnierplan.Core.Tournament.ScheduleConfig", "ScheduleConfig", b2 => + { + b2.Property("MatchPlanConfigurationTournamentId"); + + b2.Property("FinalsPhaseNumberOfCourts") + .HasJsonPropertyName("fc"); + + b2.Property("FinalsPhasePauseTime") + .HasJsonPropertyName("fp"); + + b2.Property("FinalsPhasePlayTime") + .HasJsonPropertyName("fd"); + + b2.Property("FirstMatchKickoff") + .HasJsonPropertyName("f"); + + b2.Property("GroupPhaseNumberOfCourts") + .HasJsonPropertyName("gc"); + + b2.Property("GroupPhasePauseTime") + .HasJsonPropertyName("gp"); + + b2.Property("GroupPhasePlayTime") + .HasJsonPropertyName("gd"); + + b2.Property("PauseBetweenGroupAndFinalsPhase") + .HasJsonPropertyName("p"); + + b2.HasKey("MatchPlanConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasJsonPropertyName("sc"); + + b2.WithOwner() + .HasForeignKey("MatchPlanConfigurationTournamentId"); + }); + + b1.Navigation("FinalsRoundConfig"); + + b1.Navigation("GroupRoundConfig"); + + b1.Navigation("ScheduleConfig"); + }); + + b.OwnsOne("Turnierplan.Core.Tournament.PresentationConfiguration", "PresentationConfiguration", b1 => + { + b1.Property("TournamentId"); + + b1.Property("ShowPrimaryLogo") + .HasJsonPropertyName("ol"); + + b1.Property("ShowResults") + .HasJsonPropertyName("o"); + + b1.Property("ShowSecondaryLogo") + .HasJsonPropertyName("sl"); + + b1.HasKey("TournamentId"); + + b1.ToTable("Tournaments", "turnierplan"); + + b1.ToJson("PresentationConfiguration"); + + b1.WithOwner() + .HasForeignKey("TournamentId"); + + b1.OwnsOne("Turnierplan.Core.Tournament.PresentationConfiguration+HeaderLine", "Header1", b2 => + { + b2.Property("PresentationConfigurationTournamentId"); + + b2.Property("Content") + .HasJsonPropertyName("c"); + + b2.Property("CustomContent") + .HasJsonPropertyName("cc"); + + b2.HasKey("PresentationConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasJsonPropertyName("h1"); + + b2.WithOwner() + .HasForeignKey("PresentationConfigurationTournamentId"); + }); + + b1.OwnsOne("Turnierplan.Core.Tournament.PresentationConfiguration+HeaderLine", "Header2", b2 => + { + b2.Property("PresentationConfigurationTournamentId"); + + b2.Property("Content") + .HasJsonPropertyName("c"); + + b2.Property("CustomContent") + .HasJsonPropertyName("cc"); + + b2.HasKey("PresentationConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasJsonPropertyName("h2"); + + b2.WithOwner() + .HasForeignKey("PresentationConfigurationTournamentId"); + }); + + b1.Navigation("Header1") + .IsRequired(); + + b1.Navigation("Header2") + .IsRequired(); + }); + + b.Navigation("BannerImage"); + + b.Navigation("ComputationConfiguration") + .IsRequired(); + + b.Navigation("Folder"); + + b.Navigation("MatchPlanConfiguration"); + + b.Navigation("Organization"); + + b.Navigation("PresentationConfiguration") + .IsRequired(); + + b.Navigation("PrimaryLogo"); + + b.Navigation("SecondaryLogo"); + + b.Navigation("Venue"); + }); + + modelBuilder.Entity("Turnierplan.Core.Venue.Venue", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("Venues") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKey", b => + { + b.Navigation("Requests"); + + b.Navigation("RoleAssignments"); + }); + + modelBuilder.Entity("Turnierplan.Core.Folder.Folder", b => + { + b.Navigation("RoleAssignments"); + + b.Navigation("Tournaments"); + }); + + modelBuilder.Entity("Turnierplan.Core.Image.Image", b => + { + b.Navigation("RoleAssignments"); + }); + + modelBuilder.Entity("Turnierplan.Core.Organization.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Folders"); + + b.Navigation("Images"); + + b.Navigation("PlanningRealms"); + + b.Navigation("RoleAssignments"); + + b.Navigation("Tournaments"); + + b.Navigation("Venues"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.Application", b => + { + b.Navigation("ChangeLog"); + + b.Navigation("Teams"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.ApplicationTeam", b => + { + b.Navigation("TeamLink"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.InvitationLink", b => + { + b.Navigation("Entries"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.PlanningRealm", b => + { + b.Navigation("Applications"); + + b.Navigation("InvitationLinks"); + + b.Navigation("Labels"); + + b.Navigation("RoleAssignments"); + + b.Navigation("TournamentClasses"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Group", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Team", b => + { + b.Navigation("TeamLink"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Tournament", b => + { + b.Navigation("Documents"); + + b.Navigation("Groups"); + + b.Navigation("Matches"); + + b.Navigation("RankingOverwrites"); + + b.Navigation("RoleAssignments"); + + b.Navigation("Teams"); + }); + + modelBuilder.Entity("Turnierplan.Core.Venue.Venue", b => + { + b.Navigation("RoleAssignments"); + + b.Navigation("Tournaments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Turnierplan.Dal/Migrations/20260201142521_Rename_ImageFileExtension.cs b/src/Turnierplan.Dal/Migrations/20260201142521_Rename_ImageFileExtension.cs new file mode 100644 index 00000000..3e6c364d --- /dev/null +++ b/src/Turnierplan.Dal/Migrations/20260201142521_Rename_ImageFileExtension.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Turnierplan.Dal.Migrations +{ + /// + public partial class Rename_ImageFileExtension : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "FileType", + schema: "turnierplan", + table: "Images", + newName: "FileExtension"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "FileExtension", + schema: "turnierplan", + table: "Images", + newName: "FileType"); + } + } +} diff --git a/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs b/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs index 3f3727b6..3a1a6f5b 100644 --- a/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs +++ b/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs @@ -205,13 +205,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); - b.Property("FileSize") - .HasColumnType("bigint"); - - b.Property("FileType") + b.Property("FileExtension") .IsRequired() .HasColumnType("text"); + b.Property("FileSize") + .HasColumnType("bigint"); + b.Property("Height") .HasColumnType("integer"); @@ -228,9 +228,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ResourceIdentifier") .HasColumnType("uuid"); - b.Property("Type") - .HasColumnType("integer"); - b.Property("Width") .HasColumnType("integer"); diff --git a/src/Turnierplan.ImageStorage/Azure/AzureImageStorage.cs b/src/Turnierplan.ImageStorage/Azure/AzureImageStorage.cs index 0ab2f9e5..4f7e92fe 100644 --- a/src/Turnierplan.ImageStorage/Azure/AzureImageStorage.cs +++ b/src/Turnierplan.ImageStorage/Azure/AzureImageStorage.cs @@ -141,6 +141,6 @@ public void Dispose() private static string GetBlobName(Image image) { - return $"{image.CreatedAt.Year}/{image.CreatedAt.Month:D2}/{image.ResourceIdentifier}.{image.FileType}"; + return $"{image.CreatedAt.Year}/{image.CreatedAt.Month:D2}/{image.ResourceIdentifier}.{image.FileExtension}"; } } diff --git a/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs b/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs index 85be3a5a..3534a99a 100644 --- a/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs +++ b/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs @@ -114,6 +114,6 @@ private string GetImageFullPath(Image image) private static string GetImageFileName(Image image) { - return $"{image.ResourceIdentifier}.{image.FileType}"; + return $"{image.ResourceIdentifier}.{image.FileExtension}"; } } diff --git a/src/Turnierplan.ImageStorage/S3/S3ImageStorage.cs b/src/Turnierplan.ImageStorage/S3/S3ImageStorage.cs index f1a3705a..0c54c038 100644 --- a/src/Turnierplan.ImageStorage/S3/S3ImageStorage.cs +++ b/src/Turnierplan.ImageStorage/S3/S3ImageStorage.cs @@ -151,6 +151,6 @@ public void Dispose() private static string GetObjectKey(Image image) { - return $"images/{image.CreatedAt.Year}/{image.CreatedAt.Month:D2}/{image.ResourceIdentifier}.{image.FileType}"; + return $"images/{image.CreatedAt.Year}/{image.CreatedAt.Month:D2}/{image.ResourceIdentifier}.{image.FileExtension}"; } } diff --git a/src/Turnierplan.PdfRendering/Tracing/DocumentRendererActivitySource.cs b/src/Turnierplan.PdfRendering/Tracing/DocumentRendererActivitySource.cs index 01822552..4dff6bf7 100644 --- a/src/Turnierplan.PdfRendering/Tracing/DocumentRendererActivitySource.cs +++ b/src/Turnierplan.PdfRendering/Tracing/DocumentRendererActivitySource.cs @@ -40,7 +40,8 @@ internal static class DocumentRendererActivitySource } activity.SetTag("turnierplan.image.id", image.PublicId.ToString()); - activity.SetTag("turnierplan.image.type", image.Type.ToString()); + activity.SetTag("turnierplan.image.file_size", image.FileSize); + activity.SetTag("turnierplan.image.file_extension", image.FileExtension); activity.SetTag("turnierplan.image_usage", usage); activity.Start(); From 28c678a379e5fe02ec7f7e54d6238c797ed56237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 15:31:06 +0100 Subject: [PATCH 18/38] Remove image type in frontend --- src/Turnierplan.App/Client/src/app/i18n/de.ts | 5 ----- .../components/image-chooser/image-chooser.component.ts | 9 ++------- .../image-manager/image-manager.component.html | 2 -- .../components/image-widget/image-widget.component.ts | 6 +----- .../invitation-link-tile.component.html | 2 -- .../invitation-link-tile.component.ts | 2 -- .../pages/view-tournament/view-tournament.component.html | 3 --- .../pages/view-tournament/view-tournament.component.ts | 2 -- 8 files changed, 3 insertions(+), 28 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index 2ceed387..84f3e6ca 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -218,11 +218,6 @@ export const de = { Preview: 'Vorschau', Dimensions: '{{w}} x {{h}} px', FileSize: '{{size}} KB', - Type: { - Header: 'Typ', - Logo: 'Logo', - Banner: 'Banner' - }, CreatedAt: 'Hochgeladen am', Name: 'Name', References: { diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.ts b/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.ts index 5ebee2be..6002740f 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.ts @@ -7,7 +7,6 @@ import { LoadingIndicatorComponent } from '../loading-indicator/loading-indicato import { ActionButtonComponent } from '../action-button/action-button.component'; import { NgClass } from '@angular/common'; import { TurnierplanApi } from '../../../api/turnierplan-api'; -import { ImageType } from '../../../api/models/image-type'; import { getImages } from '../../../api/fn/images/get-images'; import { ImageDto } from '../../../api/models/image-dto'; @@ -26,10 +25,7 @@ export class ImageChooserComponent { protected readonly Actions = Actions; protected isInitialized = false; - protected organizationId!: string; - protected imageType!: ImageType; - protected existingImages: ImageDto[] = []; protected isLoadingImages = true; protected currentImageId?: string; @@ -42,13 +38,12 @@ export class ImageChooserComponent { private readonly turnierplanApi: TurnierplanApi ) {} - public init(organizationId: string, imageType: ImageType, currentImageId?: string): void { + public init(organizationId: string, currentImageId?: string): void { if (this.isInitialized) { return; } this.organizationId = organizationId; - this.imageType = imageType; this.currentImageId = currentImageId; this.isInitialized = true; @@ -56,7 +51,7 @@ export class ImageChooserComponent { } private loadImages(): void { - this.turnierplanApi.invoke(getImages, { organizationId: this.organizationId, imageType: this.imageType }).subscribe({ + this.turnierplanApi.invoke(getImages, { organizationId: this.organizationId }).subscribe({ next: (response) => { this.existingImages = response.images; this.existingImages.sort((a, b) => { diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html index 5edccfb5..c3179ad0 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html @@ -3,7 +3,6 @@ - @@ -30,7 +29,6 @@ translate="Portal.ViewOrganization.Images.FileSize" [translateParams]="{ size: image.fileSize / 1000 | number: '1.1-1' : 'de' }">
- {{ image.createdAt | translateDate }} @if (image.isUpdatingName) { diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-widget/image-widget.component.ts b/src/Turnierplan.App/Client/src/app/portal/components/image-widget/image-widget.component.ts index 739b55a4..7fa979b9 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-widget/image-widget.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-widget/image-widget.component.ts @@ -5,7 +5,6 @@ import { ImageChooserComponent, ImageChooserResult } from '../image-chooser/imag import { TranslateDirective } from '@ngx-translate/core'; import { ActionButtonComponent } from '../action-button/action-button.component'; import { ImageDto } from '../../../api/models/image-dto'; -import { ImageType } from '../../../api/models/image-type'; @Component({ selector: 'tp-image-widget', @@ -19,9 +18,6 @@ export class ImageWidgetComponent { @Input() public imageAlt!: string; - @Input() - public imageType!: ImageType; - @Input() public organizationId!: string; @@ -51,7 +47,7 @@ export class ImageWidgetComponent { }); const component = ref.componentInstance as ImageChooserComponent; - component.init(this.organizationId, this.imageType, this.currentImage?.id); + component.init(this.organizationId, this.currentImage?.id); ref.closed.subscribe({ next: (result: ImageChooserResult) => { diff --git a/src/Turnierplan.App/Client/src/app/portal/components/invitation-link-tile/invitation-link-tile.component.html b/src/Turnierplan.App/Client/src/app/portal/components/invitation-link-tile/invitation-link-tile.component.html index 27bf053b..7d19e2c5 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/invitation-link-tile/invitation-link-tile.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/components/invitation-link-tile/invitation-link-tile.component.html @@ -84,7 +84,6 @@ (); protected readonly Actions = Actions; - protected readonly ImageType = ImageType; protected invitationLinkExpired = false; protected tournamentClassesToAdd: TournamentClassDto[] = []; diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/view-tournament/view-tournament.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/view-tournament/view-tournament.component.html index c37bbdb8..b647cfde 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/view-tournament/view-tournament.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/view-tournament/view-tournament.component.html @@ -321,7 +321,6 @@ class="flex-md-grow-1" [currentImage]="images.primaryLogo" [imageAlt]="'PrimaryLogo'" - [imageType]="ImageType.Logo" [organizationId]="tournament.organizationId" (imageChange)="setImage('primaryLogo', $event?.id)" (imageDelete)="loadTournamentImages()" @@ -330,7 +329,6 @@ class="flex-md-grow-1" [currentImage]="images.secondaryLogo" [imageAlt]="'SecondaryLogo'" - [imageType]="ImageType.Logo" [organizationId]="tournament.organizationId" (imageChange)="setImage('secondaryLogo', $event?.id)" (imageDelete)="loadTournamentImages()" @@ -339,7 +337,6 @@ class="flex-md-grow-1" [currentImage]="images.bannerImage" [imageAlt]="'Banner'" - [imageType]="ImageType.Banner" [organizationId]="tournament.organizationId" (imageChange)="setImage('bannerImage', $event?.id)" (imageDelete)="loadTournamentImages()" diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/view-tournament/view-tournament.component.ts b/src/Turnierplan.App/Client/src/app/portal/pages/view-tournament/view-tournament.component.ts index 135f4d39..aa334572 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/view-tournament/view-tournament.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/pages/view-tournament/view-tournament.component.ts @@ -42,7 +42,6 @@ import { MatchTreeComponent } from '../../components/match-tree/match-tree.compo import { TranslateDatePipe } from '../../pipes/translate-date.pipe'; import { IdWidgetComponent } from '../../components/id-widget/id-widget.component'; import { TurnierplanApi } from '../../../api/turnierplan-api'; -import { ImageType } from '../../../api/models/image-type'; import { Visibility } from '../../../api/models/visibility'; import { TournamentDto } from '../../../api/models/tournament-dto'; import { TournamentImagesDto } from '../../../api/models/tournament-images-dto'; @@ -118,7 +117,6 @@ export class ViewTournamentComponent implements OnInit, OnDestroy { private static readonly DocumentsPageId = 4; private static readonly SettingsPageId = 6; - protected readonly ImageType = ImageType; protected readonly Visibility = Visibility; protected readonly Actions = Actions; From 260c1be590848e4d92ca5f311d6d0b854abfa3d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 15:31:40 +0100 Subject: [PATCH 19/38] Fix example --- src/Turnierplan.App/OpenApi/EnumSchemaTransformer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turnierplan.App/OpenApi/EnumSchemaTransformer.cs b/src/Turnierplan.App/OpenApi/EnumSchemaTransformer.cs index 6c7fdbcf..52d31729 100644 --- a/src/Turnierplan.App/OpenApi/EnumSchemaTransformer.cs +++ b/src/Turnierplan.App/OpenApi/EnumSchemaTransformer.cs @@ -14,7 +14,7 @@ namespace Turnierplan.App.OpenApi; /// to string: /// /// -/// export enum ImageType { +/// export enum MatchType { /// A = 'A', /// B = 'B', /// C = 'C' From c8a899af9335aa10dab27f70e6999719435728d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 16:01:35 +0100 Subject: [PATCH 20/38] Implement upload functionality --- .../upload-image/upload-image.component.html | 41 ++++---- .../upload-image/upload-image.component.ts | 94 +++++++++++++++++-- .../view-organization.component.ts | 3 +- 3 files changed, 110 insertions(+), 28 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.html index 5dd70c62..d94825a5 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.html @@ -1,24 +1,29 @@ @let imageNameInvalid = (imageName.dirty || imageName.touched) && imageName.value.length > 0 && imageName.value.trim().length === 0; -
- - -
-
+ @if (uploadStarted) { + + } @else { +
+ + +
+
- - +
+ + +
+ +
- -
-
+ } @let previewUrl = previewUrl$ | async; @if (previewUrl) { @@ -27,7 +32,7 @@
@@ -46,7 +51,7 @@
diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.ts b/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.ts index 13e3a276..c9f674cb 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/pages/upload-image/upload-image.component.ts @@ -1,14 +1,23 @@ -import { Component, OnDestroy } from '@angular/core'; +import { Component, inject, OnDestroy } from '@angular/core'; import { LoadingState, LoadingStateDirective } from '../../directives/loading-state.directive'; import { PageFrameComponent } from '../../components/page-frame/page-frame.component'; import { TranslateDirective, TranslatePipe } from '@ngx-translate/core'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { OrganizationDto } from '../../../api/models/organization-dto'; import { TooltipIconComponent } from '../../components/tooltip-icon/tooltip-icon.component'; -import { BehaviorSubject, map, Observable } from 'rxjs'; +import { BehaviorSubject, map, Observable, of, switchMap } from 'rxjs'; import { AsyncPipe } from '@angular/common'; import { filter } from 'rxjs/operators'; import { ActionButtonComponent } from '../../components/action-button/action-button.component'; +import { LoadingIndicatorComponent } from '../../components/loading-indicator/loading-indicator.component'; +import { ActivatedRoute, Router } from '@angular/router'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { getOrganization } from '../../../api/fn/organizations/get-organization'; +import { TurnierplanApi } from '../../../api/turnierplan-api'; +import { TitleService } from '../../services/title.service'; +import { uploadImage$FormData } from '../../../api/fn/images/upload-image-form-data'; +import { LocalStorageService } from '../../services/local-storage.service'; +import { ViewOrganizationComponent } from '../view-organization/view-organization.component'; @Component({ imports: [ @@ -19,7 +28,8 @@ import { ActionButtonComponent } from '../../components/action-button/action-but ReactiveFormsModule, TooltipIconComponent, AsyncPipe, - ActionButtonComponent + ActionButtonComponent, + LoadingIndicatorComponent ], templateUrl: './upload-image.component.html' }) @@ -31,22 +41,58 @@ export class UploadImageComponent implements OnDestroy { protected selectedFile?: File; protected fallbackName?: string; protected previewUrl$: Observable; + protected uploadStarted = false; - private arrayBuffer$: BehaviorSubject; + private readonly localStorageService = inject(LocalStorageService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly titleService = inject(TitleService); + private readonly turnierplanApi = inject(TurnierplanApi); + + private blob$: BehaviorSubject; constructor() { - this.arrayBuffer$ = new BehaviorSubject(undefined); - this.previewUrl$ = this.arrayBuffer$.pipe( + this.blob$ = new BehaviorSubject(undefined); + this.previewUrl$ = this.blob$.pipe( filter((blob) => !!blob), map((blob) => URL.createObjectURL(blob)) ); + + this.route.paramMap + .pipe( + takeUntilDestroyed(), + switchMap((params) => { + const organizationId = params.get('id'); + if (organizationId === null) { + this.loadingState = { isLoading: false }; + return of(undefined); + } + + this.loadingState = { isLoading: true }; + return this.turnierplanApi.invoke(getOrganization, { id: organizationId }); + }) + ) + .subscribe({ + next: (organization) => { + this.organization = organization; + this.titleService.setTitleFrom(organization); + this.loadingState = { isLoading: false }; + }, + error: (error) => { + this.loadingState = { isLoading: false, error: error }; + } + }); } public ngOnDestroy() { - this.arrayBuffer$.complete(); + this.blob$.complete(); } protected onFileSelected(event: unknown): void { + if (this.uploadStarted) { + return; + } + const file = (event as { target: HTMLInputElement }).target.files?.item(0); if (!file) { @@ -57,12 +103,42 @@ export class UploadImageComponent implements OnDestroy { this.setFallbackFileName(file.name); this.selectedFile.arrayBuffer().then((arrayBuffer): void => { - this.arrayBuffer$.next(new Blob([arrayBuffer])); + this.blob$.next(new Blob([arrayBuffer])); }); } protected confirmButtonClicked(): void { - alert('Not Implemented'); // TODO + const currentBlob = this.blob$.value; + const organizationId = this.organization?.id; + + if (this.uploadStarted || !currentBlob || !organizationId) { + return; + } + + this.uploadStarted = true; + + let imageName = this.imageName.value.trim(); + if (imageName.length === 0) { + imageName = this.fallbackName ?? '-'; + } + + this.turnierplanApi + .invoke(uploadImage$FormData, { + body: { + organizationId: organizationId, + imageName: imageName, + image: currentBlob + } + }) + .subscribe({ + next: (): void => { + this.localStorageService.setNavigationTab(organizationId, ViewOrganizationComponent.imagesPageId); + void this.router.navigate(['../../'], { relativeTo: this.route }); + }, + error: (error): void => { + this.loadingState = { isLoading: false, error: error }; + } + }); } private setFallbackFileName(name: string): void { diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.ts b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.ts index 64f0f88c..3608d20f 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.ts @@ -77,10 +77,11 @@ import { GetImagesEndpointResponse } from '../../../api/models/get-images-endpoi ] }) export class ViewOrganizationComponent implements OnInit, OnDestroy { + public static readonly imagesPageId = 5; + private static readonly venuesPageId = 1; private static readonly apiKeysPageId = 2; private static readonly planningRealmsPageId = 4; - private static readonly imagesPageId = 5; protected readonly Actions = Actions; From 58dbea138a22dee969680f87e17467bb54707ef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 16:10:36 +0100 Subject: [PATCH 21/38] Add pipe for displaying file size --- src/Turnierplan.App/Client/src/app/i18n/de.ts | 1 - .../image-manager.component.html | 5 +--- .../image-manager/image-manager.component.ts | 4 ++- .../app/portal/pipes/file-size.pipe.spec.ts | 20 ++++++++++++++ .../src/app/portal/pipes/file-size.pipe.ts | 26 +++++++++++++++++++ 5 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 src/Turnierplan.App/Client/src/app/portal/pipes/file-size.pipe.spec.ts create mode 100644 src/Turnierplan.App/Client/src/app/portal/pipes/file-size.pipe.ts diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index 84f3e6ca..d98c3cf6 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -217,7 +217,6 @@ export const de = { Images: { Preview: 'Vorschau', Dimensions: '{{w}} x {{h}} px', - FileSize: '{{size}} KB', CreatedAt: 'Hochgeladen am', Name: 'Name', References: { diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html index c3179ad0..0405e657 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html @@ -24,10 +24,7 @@ class="text-center mt-1 small text-secondary" translate="Portal.ViewOrganization.Images.Dimensions" [translateParams]="{ w: image.width, h: image.height }">
-
+
{{ image.fileSize | fileSize: 'de' }}
{{ image.createdAt | translateDate }} diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts index 90c6f7f2..41e50790 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts @@ -15,6 +15,7 @@ import { deleteImage } from '../../../api/fn/images/delete-image'; import { NotificationService } from '../../../core/services/notification.service'; import { TooltipIconComponent } from '../tooltip-icon/tooltip-icon.component'; import { TranslateDatePipe } from '../../pipes/translate-date.pipe'; +import { FileSizePipe } from '../../pipes/file-size.pipe'; type ImageView = ImageDto & { referenceCount?: number; isUpdatingName: boolean }; @@ -30,7 +31,8 @@ type ImageView = ImageDto & { referenceCount?: number; isUpdatingName: boolean } RbacWidgetComponent, DeleteButtonComponent, TooltipIconComponent, - TranslateDatePipe + TranslateDatePipe, + FileSizePipe ], templateUrl: './image-manager.component.html' }) diff --git a/src/Turnierplan.App/Client/src/app/portal/pipes/file-size.pipe.spec.ts b/src/Turnierplan.App/Client/src/app/portal/pipes/file-size.pipe.spec.ts new file mode 100644 index 00000000..ef3656dd --- /dev/null +++ b/src/Turnierplan.App/Client/src/app/portal/pipes/file-size.pipe.spec.ts @@ -0,0 +1,20 @@ +import { FileSizePipe } from './file-size.pipe'; + +describe('FileSizePipe', () => { + it('formats values as expected', () => { + const pipe = new FileSizePipe(); + + expect(pipe.transform(189, 'de')).toBe('189 B'); + expect(pipe.transform(999, 'de')).toBe('999 B'); + expect(pipe.transform(1000, 'de')).toBe('1,0 kB'); + expect(pipe.transform(1495, 'de')).toBe('1,5 kB'); + expect(pipe.transform(1999, 'de')).toBe('2,0 kB'); + expect(pipe.transform(95_456, 'de')).toBe('95,5 kB'); + expect(pipe.transform(822_300, 'de')).toBe('822,3 kB'); + expect(pipe.transform(1_456_488, 'de')).toBe('1,5 MB'); + expect(pipe.transform(141_300_000, 'de')).toBe('141,3 MB'); + expect(pipe.transform(1_000_000_000, 'de')).toBe('1,0 GB'); + expect(pipe.transform(195_000_000_000, 'de')).toBe('195,0 GB'); + expect(pipe.transform(1_000_000_000_000, 'de')).toBe('1.000,0 GB'); + }); +}); diff --git a/src/Turnierplan.App/Client/src/app/portal/pipes/file-size.pipe.ts b/src/Turnierplan.App/Client/src/app/portal/pipes/file-size.pipe.ts new file mode 100644 index 00000000..74f98a57 --- /dev/null +++ b/src/Turnierplan.App/Client/src/app/portal/pipes/file-size.pipe.ts @@ -0,0 +1,26 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { formatNumber } from '@angular/common'; + +@Pipe({ + name: 'fileSize' +}) +export class FileSizePipe implements PipeTransform { + public transform(value: number, locale: string): string { + let suffix = ''; + + if (value >= 1_000_000_000) { + value /= 1_000_000_000; + suffix = 'GB'; + } else if (value >= 1_000_000) { + value /= 1_000_000; + suffix = 'MB'; + } else if (value >= 1_000) { + value /= 1_000; + suffix = 'kB'; + } else { + suffix = 'B'; + } + + return formatNumber(value, locale, '1.1-1') + ' ' + suffix; + } +} From b279e0ce8124ff2dcaea974e7651200c587577e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 16:13:00 +0100 Subject: [PATCH 22/38] Display total images size in org --- src/Turnierplan.App/Client/src/app/i18n/de.ts | 1 + .../view-organization/view-organization.component.html | 3 +++ .../pages/view-organization/view-organization.component.ts | 6 +++++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index d98c3cf6..d3a6e970 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -179,6 +179,7 @@ export const de = { VenueCount: 'Spielstätten', PlanningRealmCount: 'Turnierplaner', ImagesCount: 'Bilder', + ImagesTotalSize: 'Gesamtgröße', ApiKeyCount: 'API-Schlüssel' }, NewTournament: 'Neues Turnier', diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html index 321a3dc7..d79f4e34 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html @@ -144,6 +144,9 @@ } @else {
+ @if (imagesTotalSize !== undefined) { + + }
diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.ts b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.ts index 3608d20f..41634a98 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.ts @@ -45,6 +45,7 @@ import { E2eDirective } from '../../../core/directives/e2e.directive'; import { ImageManagerComponent } from '../../components/image-manager/image-manager.component'; import { getImages } from '../../../api/fn/images/get-images'; import { GetImagesEndpointResponse } from '../../../api/models/get-images-endpoint-response'; +import { FileSizePipe } from '../../pipes/file-size.pipe'; @Component({ templateUrl: './view-organization.component.html', @@ -73,7 +74,8 @@ import { GetImagesEndpointResponse } from '../../../api/models/get-images-endpoi TranslateDatePipe, IdWidgetComponent, E2eDirective, - ImageManagerComponent + ImageManagerComponent, + FileSizePipe ] }) export class ViewOrganizationComponent implements OnInit, OnDestroy { @@ -91,6 +93,7 @@ export class ViewOrganizationComponent implements OnInit, OnDestroy { protected venues?: VenueDto[]; protected planningRealms?: PlanningRealmHeaderDto[]; protected images?: GetImagesEndpointResponse; + protected imagesTotalSize?: number; protected apiKeys?: ApiKeyDto[]; protected displayApiKeyUsage?: string; protected isLoadingVenues = false; @@ -228,6 +231,7 @@ export class ViewOrganizationComponent implements OnInit, OnDestroy { this.turnierplanApi.invoke(getImages, { organizationId: this.organization.id, includeReferences: true }).subscribe({ next: (images) => { this.images = images; + this.imagesTotalSize = images.images.reduce((sum, img) => sum + img.fileSize, 0); this.isLoadingImages = false; }, error: (error) => { From 52e05538107efdb0a13c48de732efad6523b69f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 16:31:05 +0100 Subject: [PATCH 23/38] Add warning if aspect ratio is too large --- src/Turnierplan.App/Client/src/app/i18n/de.ts | 4 +++- .../view-tournament.component.html | 16 ++++++++++++++++ .../view-tournament/view-tournament.component.ts | 7 +++++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index d3a6e970..bfe23d80 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -622,7 +622,9 @@ export const de = { } }, EditImages: { - Title: 'Logos & Bilddateien' + Title: 'Logos & Bilddateien', + BannerAspectRatioWarning: + 'Das aktuelle Banner hat eine Auflösung von {{w}} x {{h}} px und ein Seitenverhältnis von {{ratio}}:1. Für eine optimale Darstellung verwenden Sie ein Bild mit einem Seitenverhältnis von mindestens 3:1' }, MoveToAnotherFolder: { Title: 'Turnier verschieben', diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/view-tournament/view-tournament.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/view-tournament/view-tournament.component.html index b647cfde..127c46c3 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/view-tournament/view-tournament.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/view-tournament/view-tournament.component.html @@ -342,6 +342,22 @@ (imageDelete)="loadTournamentImages()" (apiError)="loadingState = { isLoading: false, error: $event }" />
+ + @if (images.bannerImage) { + @let bannerAspectRatio = images.bannerImage.width / images.bannerImage.height; + @if (bannerAspectRatio < 3) { + + } + } }

diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/view-tournament/view-tournament.component.ts b/src/Turnierplan.App/Client/src/app/portal/pages/view-tournament/view-tournament.component.ts index aa334572..2ce90f7e 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/view-tournament/view-tournament.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/pages/view-tournament/view-tournament.component.ts @@ -26,7 +26,7 @@ import { ActionButtonComponent } from '../../components/action-button/action-but import { IsActionAllowedDirective } from '../../directives/is-action-allowed.directive'; import { SmallSpinnerComponent } from '../../../core/components/small-spinner/small-spinner.component'; import { RenameButtonComponent } from '../../components/rename-button/rename-button.component'; -import { AsyncPipe, NgClass } from '@angular/common'; +import { AsyncPipe, DecimalPipe, NgClass } from '@angular/common'; import { BadgeComponent } from '../../components/badge/badge.component'; import { FormsModule } from '@angular/forms'; import { LoadingIndicatorComponent } from '../../components/loading-indicator/loading-indicator.component'; @@ -108,7 +108,8 @@ import { deleteRankingOverwrite } from '../../../api/fn/ranking-overwrites/delet AsyncPipe, TranslatePipe, TranslateDatePipe, - IdWidgetComponent + IdWidgetComponent, + DecimalPipe ] }) export class ViewTournamentComponent implements OnInit, OnDestroy { @@ -1095,4 +1096,6 @@ export class ViewTournamentComponent implements OnInit, OnDestroy { ); this.processedRankings.sort((a, b) => a.position - b.position); } + + protected readonly Image = Image; } From 059d08ac430e6f71ab9eebe2803d8f8e049c692d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 16:49:08 +0100 Subject: [PATCH 24/38] Configurable image size limit & quality --- .../Endpoints/Images/UploadImageEndpoint.cs | 39 ++++++++++++------- .../Options/TurnierplanOptions.cs | 4 ++ src/Turnierplan.App/appsettings.json | 4 +- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/Turnierplan.App/Endpoints/Images/UploadImageEndpoint.cs b/src/Turnierplan.App/Endpoints/Images/UploadImageEndpoint.cs index a9403e40..e3a3ac47 100644 --- a/src/Turnierplan.App/Endpoints/Images/UploadImageEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Images/UploadImageEndpoint.cs @@ -1,9 +1,11 @@ using FluentValidation; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using SkiaSharp; using Turnierplan.App.Extensions; using Turnierplan.App.Mapping; using Turnierplan.App.Models; +using Turnierplan.App.Options; using Turnierplan.App.Security; using Turnierplan.Core.Image; using Turnierplan.Core.PublicId; @@ -26,10 +28,27 @@ private static async Task Handle( IAccessValidator accessValidator, IImageStorage imageStorage, IImageRepository imageRepository, + IOptions turnierplanOptions, + ILogger logger, IMapper mapper, CancellationToken cancellationToken) { - if (!Validator.Instance.ValidateAndGetResult(request, out var result)) + var maxImageSize = turnierplanOptions.Value.ImageMaxSize; + var imageQuality = turnierplanOptions.Value.ImageQuality; + + if (maxImageSize is null or <= 0) + { + logger.LogError($"The '{nameof(TurnierplanOptions.ImageMaxSize)}' value in '{nameof(TurnierplanOptions)}' must be specified and greater than zero."); + return Results.InternalServerError(); + } + + if (imageQuality is null or <= 0 or > 100) + { + logger.LogError($"The '{nameof(TurnierplanOptions.ImageQuality)}' value in '{nameof(TurnierplanOptions)}' must be specified, greater than zero, and less than or equal to 100."); + return Results.InternalServerError(); + } + + if (!new Validator(maxImageSize.Value).ValidateAndGetResult(request, out var result)) { return result; } @@ -63,15 +82,8 @@ private static async Task Handle( return Results.BadRequest("Could not process image."); } - // TODO: Make this configurable via appsettings as well - const int MaximumImageSizeInPixels = 3000; - if (imageData.Width > MaximumImageSizeInPixels || imageData.Height > MaximumImageSizeInPixels) - { - return Results.BadRequest($"Image is too large (maximum is {MaximumImageSizeInPixels}px along each side)."); - } - var memoryStream = new MemoryStream(); - var encodedData = imageData.Encode(SKEncodedImageFormat.Webp, 80); // TODO: Make the image format & quality configurable via app settings (NOTE: Update file type in Image ctor below) + var encodedData = imageData.Encode(SKEncodedImageFormat.Webp, imageQuality.Value); encodedData.SaveTo(memoryStream); memoryStream.Seek(0, SeekOrigin.Begin); @@ -110,14 +122,11 @@ public sealed record UploadImageEndpointRequest private sealed class Validator : AbstractValidator { - public static readonly Validator Instance = new(); - - private Validator() + public Validator(int maxSize) { - // TODO: Add app configuration for maximum image size RuleFor(x => x.Image.Length) - .LessThanOrEqualTo(8 * 1024 * 1024) - .WithMessage("Image file size must be 8MB or less."); + .LessThanOrEqualTo(maxSize) + .WithMessage($"The maximum allowed image size is {maxSize} bytes."); RuleFor(x => x.ImageName) .NotEmpty(); diff --git a/src/Turnierplan.App/Options/TurnierplanOptions.cs b/src/Turnierplan.App/Options/TurnierplanOptions.cs index aac4dcb2..6ad9ecff 100644 --- a/src/Turnierplan.App/Options/TurnierplanOptions.cs +++ b/src/Turnierplan.App/Options/TurnierplanOptions.cs @@ -12,6 +12,10 @@ internal sealed record TurnierplanOptions public string? PrivacyUrl { get; init; } + public int? ImageMaxSize { get; init; } + + public int? ImageQuality { get; init; } + public string? InitialUserName { get; init; } public string? InitialUserPassword { get; init; } diff --git a/src/Turnierplan.App/appsettings.json b/src/Turnierplan.App/appsettings.json index 868a4bad..78852a72 100644 --- a/src/Turnierplan.App/appsettings.json +++ b/src/Turnierplan.App/appsettings.json @@ -27,6 +27,8 @@ "Turnierplan": { "ApplicationUrl": "", "ImprintUrl": "", - "PrivacyUrl": "" + "PrivacyUrl": "", + "ImageMaxSize": 8388608, + "ImageQuality": 80 } } From 512ee42b4c04799dc962e82847265f6f913b44dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 16:49:19 +0100 Subject: [PATCH 25/38] Cleanup & remove "small" class --- .../components/image-manager/image-manager.component.html | 2 +- .../portal/components/image-manager/image-manager.component.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html index 0405e657..f0e63c3d 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html @@ -40,7 +40,7 @@ } - {{ image.name }} + {{ image.name }} @if (image.referenceCount === 0) { diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts index 41e50790..aca5694a 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.ts @@ -1,7 +1,7 @@ import { Component, EventEmitter, inject, Input, Output } from '@angular/core'; import { ImageDto } from '../../../api/models/image-dto'; import { TranslateDirective } from '@ngx-translate/core'; -import { AsyncPipe, DecimalPipe } from '@angular/common'; +import { AsyncPipe } from '@angular/common'; import { Actions } from '../../../generated/actions'; import { AuthorizationService } from '../../../core/services/authorization.service'; import { RenameButtonComponent } from '../rename-button/rename-button.component'; @@ -25,7 +25,6 @@ type ImageView = ImageDto & { referenceCount?: number; isUpdatingName: boolean } TranslateDirective, AsyncPipe, RenameButtonComponent, - DecimalPipe, SmallSpinnerComponent, IsActionAllowedDirective, RbacWidgetComponent, From 4a2408d1c411419c03be8175af060769d6600b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 16:59:14 +0100 Subject: [PATCH 26/38] Add new values to docs --- docs/pages/installation.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/pages/installation.md b/docs/pages/installation.md index 7d88cb90..1c1cb9e8 100644 --- a/docs/pages/installation.md +++ b/docs/pages/installation.md @@ -66,6 +66,8 @@ The following environment variables can be set if you want to enable specific fe | `Turnierplan__LogoUrl` | The URL of the custom logo to be displayed in the header of the public pages. If not specified, the turnierplan.NET logo will be shown instead. | - | | `Turnierplan__ImprintUrl` | The URL of your external imprint page if you want it to be linked on the public pages. | - | | `Turnierplan__PrivacyUrl` | The URL of your external privacy page if you want it to be linked on the public pages. | - | +| `Turnierplan__ImageMaxSize` | The maximum allowed file size when uploading an image file. The default value equates to 8 MiB (8 · 1024 · 1024) | `8388608` | +| `Turnierplan__ImageQuality` | Uploaded images are re-compressed using the `webp` format with the specified quality. A value of `100` will result in lossless compression being uesd. | `80` | !!! note The token lifetimes must be specified as .NET `TimeSpan` strings. For example `00:03:00` means 3 minutes or `1.00:00.00` means 1 day. From d89e033fd1f8b83132445a4c8d9a446d2853707e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 17:05:22 +0100 Subject: [PATCH 27/38] Fix failing test bc org is not always added --- src/Turnierplan.App.Test.Unit/Security/AccessValidatorTest.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Turnierplan.App.Test.Unit/Security/AccessValidatorTest.cs b/src/Turnierplan.App.Test.Unit/Security/AccessValidatorTest.cs index c1635d74..cf7f5495 100644 --- a/src/Turnierplan.App.Test.Unit/Security/AccessValidatorTest.cs +++ b/src/Turnierplan.App.Test.Unit/Security/AccessValidatorTest.cs @@ -260,12 +260,10 @@ public void AddRolesToResponseHeader___When_Called_Such_That_Entities_Are_Proces var headers = httpContextAccessor.HttpContext!.Response.Headers; var headerValues = headers["X-Turnierplan-Roles"]; - var organizationId = organization.PublicId.ToString(); var tournamentId1 = tournament1.PublicId.ToString(); var tournamentId2 = tournament2.PublicId.ToString(); - headerValues.Should().HaveCount(3); - headerValues.Single(x => x!.StartsWith(organizationId)).Should().Be($"{organizationId}=Reader"); + headerValues.Should().HaveCount(2); headerValues.Single(x => x!.StartsWith(tournamentId1)).Should().Be($"{tournamentId1}=Owner+Reader+Contributor"); headerValues.Single(x => x!.StartsWith(tournamentId2)).Should().Be($"{tournamentId2}=Reader+Contributor"); } From aafe7e0885b4a1b04d1c4900272def5ccd139fa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 17:22:06 +0100 Subject: [PATCH 28/38] Fix test --- .../Client/src/app/portal/pipes/file-size.pipe.ts | 4 +++- src/Turnierplan.App/Client/src/test.ts | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Turnierplan.App/Client/src/app/portal/pipes/file-size.pipe.ts b/src/Turnierplan.App/Client/src/app/portal/pipes/file-size.pipe.ts index 74f98a57..6809f39f 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pipes/file-size.pipe.ts +++ b/src/Turnierplan.App/Client/src/app/portal/pipes/file-size.pipe.ts @@ -7,6 +7,7 @@ import { formatNumber } from '@angular/common'; export class FileSizePipe implements PipeTransform { public transform(value: number, locale: string): string { let suffix = ''; + let digitsFormat = '1.1-1'; if (value >= 1_000_000_000) { value /= 1_000_000_000; @@ -19,8 +20,9 @@ export class FileSizePipe implements PipeTransform { suffix = 'kB'; } else { suffix = 'B'; + digitsFormat = '1.0'; } - return formatNumber(value, locale, '1.1-1') + ' ' + suffix; + return formatNumber(value, locale, digitsFormat) + ' ' + suffix; } } diff --git a/src/Turnierplan.App/Client/src/test.ts b/src/Turnierplan.App/Client/src/test.ts index 09230c64..a8c4d933 100644 --- a/src/Turnierplan.App/Client/src/test.ts +++ b/src/Turnierplan.App/Client/src/test.ts @@ -3,6 +3,11 @@ import 'zone.js/testing'; import { getTestBed } from '@angular/core/testing'; import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; +import { registerLocaleData } from '@angular/common'; +import localeDe from '@angular/common/locales/de'; +import localeDeExtra from '@angular/common/locales/extra/de'; + +registerLocaleData(localeDe, 'de', localeDeExtra); // Initialize the Angular testing environment. getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting(), { From 79aef4b8cc7862436889756f9f77c86af294d225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 18:46:38 +0100 Subject: [PATCH 29/38] Fix import --- src/Turnierplan.Core/PlanningRealm/InvitationLink.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Turnierplan.Core/PlanningRealm/InvitationLink.cs b/src/Turnierplan.Core/PlanningRealm/InvitationLink.cs index 4c623a30..d31eb4e8 100644 --- a/src/Turnierplan.Core/PlanningRealm/InvitationLink.cs +++ b/src/Turnierplan.Core/PlanningRealm/InvitationLink.cs @@ -1,7 +1,6 @@ using System.Text.Json.Serialization; using Turnierplan.Core.Entity; using Turnierplan.Core.Exceptions; -using Turnierplan.Core.Image; namespace Turnierplan.Core.PlanningRealm; From 8bb9634c4867f2d4bce1b4750c5b8af163122398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 18:51:21 +0100 Subject: [PATCH 30/38] Cleanup --- .../portal/pages/view-tournament/view-tournament.component.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/view-tournament/view-tournament.component.ts b/src/Turnierplan.App/Client/src/app/portal/pages/view-tournament/view-tournament.component.ts index 2ce90f7e..0ed0cfa9 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/view-tournament/view-tournament.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/pages/view-tournament/view-tournament.component.ts @@ -1096,6 +1096,4 @@ export class ViewTournamentComponent implements OnInit, OnDestroy { ); this.processedRankings.sort((a, b) => a.position - b.position); } - - protected readonly Image = Image; } From 8db443a74f2fd152490733912f9f10f478d954d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 18:53:35 +0100 Subject: [PATCH 31/38] Simplify --- docs/pages/installation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/pages/installation.md b/docs/pages/installation.md index 1c1cb9e8..7fad538b 100644 --- a/docs/pages/installation.md +++ b/docs/pages/installation.md @@ -67,7 +67,7 @@ The following environment variables can be set if you want to enable specific fe | `Turnierplan__ImprintUrl` | The URL of your external imprint page if you want it to be linked on the public pages. | - | | `Turnierplan__PrivacyUrl` | The URL of your external privacy page if you want it to be linked on the public pages. | - | | `Turnierplan__ImageMaxSize` | The maximum allowed file size when uploading an image file. The default value equates to 8 MiB (8 · 1024 · 1024) | `8388608` | -| `Turnierplan__ImageQuality` | Uploaded images are re-compressed using the `webp` format with the specified quality. A value of `100` will result in lossless compression being uesd. | `80` | +| `Turnierplan__ImageQuality` | Uploaded images are compressed using the `webp` format with the specified quality. A value of `100` will result in lossless compression being uesd. | `80` | !!! note The token lifetimes must be specified as .NET `TimeSpan` strings. For example `00:03:00` means 3 minutes or `1.00:00.00` means 1 day. From a43a4154dd6e89c83d2632a71d6e43d7c0f22c10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 19:02:03 +0100 Subject: [PATCH 32/38] Show more info in image chooser --- src/Turnierplan.App/Client/src/app/i18n/de.ts | 3 ++- .../image-chooser.component.html | 14 +++++++++--- .../image-chooser.component.scss | 22 ++++++++++++++----- .../image-chooser/image-chooser.component.ts | 3 ++- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index bfe23d80..e7e70a71 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -1303,7 +1303,8 @@ export const de = { Title: 'Bild auswählen', Remove: 'Bild entfernen', NoImages: 'In dieser Organisation wurden bisher noch keine Bilder hochgeladen.', - UploadViaOrgPage: 'Neue Bilder können Sie auf der Seite der Organisation hochladen.' + UploadViaOrgPage: 'Neue Bilder können Sie auf der Seite der Organisation hochladen.', + Dimensions: '{{w}} x {{h}} px' }, MultiSelectFilter: { All: 'alle', diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.html b/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.html index 0fb1e21f..84e51d93 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.html @@ -21,11 +21,11 @@
@if (image.id !== currentImageId) { + {{ image.name }} + + {{ image.fileSize | fileSize: 'de' }} }
+
diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.scss b/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.scss index 53ac73ed..37979f86 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.scss +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.scss @@ -2,14 +2,24 @@ max-height: 40em; } +.image-info { + max-width: 9em; + + &.xsmall { + font-size: 0.7em; + } +} + .image-tile { - &:hover, - &.hover-override { - border-color: #333 !important; + &:not(.is-current-image) { + &:hover, + &.hover-override { + border-color: #333 !important; - .hover-buttons { - background: rgb(255 255 255 / 60%); - display: flex !important; + .hover-buttons { + background: rgb(255 255 255 / 85%); + display: flex !important; + } } } diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.ts b/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.ts index 6002740f..441903d7 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.ts @@ -9,6 +9,7 @@ import { NgClass } from '@angular/common'; import { TurnierplanApi } from '../../../api/turnierplan-api'; import { getImages } from '../../../api/fn/images/get-images'; import { ImageDto } from '../../../api/models/image-dto'; +import { FileSizePipe } from '../../pipes/file-size.pipe'; export interface ImageChooserResult { type: 'ImageDeleted' | 'ImageSelected'; @@ -19,7 +20,7 @@ export interface ImageChooserResult { @Component({ templateUrl: './image-chooser.component.html', styleUrls: ['./image-chooser.component.scss'], - imports: [TranslateDirective, LoadingIndicatorComponent, ActionButtonComponent, NgClass] + imports: [TranslateDirective, LoadingIndicatorComponent, ActionButtonComponent, NgClass, FileSizePipe] }) export class ImageChooserComponent { protected readonly Actions = Actions; From 305d1ea51fa84f22e1adf3a84ba886daa3bf6df3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 19:08:58 +0100 Subject: [PATCH 33/38] Sorting --- .../image-chooser/image-chooser.component.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.ts b/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.ts index 441903d7..e633dee7 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.ts @@ -55,11 +55,14 @@ export class ImageChooserComponent { this.turnierplanApi.invoke(getImages, { organizationId: this.organizationId }).subscribe({ next: (response) => { this.existingImages = response.images; - this.existingImages.sort((a, b) => { - if (a.id === this.currentImageId) return -1; - if (b.id === this.currentImageId) return 1; - return new Date(b.createdAt).getDate() - new Date(a.createdAt).getDate(); - }); + + // Keep the original sorting from the response, but always move the current image to the first position + const currentImageIndex = this.existingImages.findIndex((x) => x.id === this.currentImageId); + if (currentImageIndex !== -1) { + const currentImage = this.existingImages.splice(currentImageIndex, 1); + this.existingImages = [currentImage[0], ...this.existingImages]; + } + this.isLoadingImages = false; }, error: (error) => { From ca46dfa035b6ccfd73acaa5e35c183b5b7e7e713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 19:10:05 +0100 Subject: [PATCH 34/38] Always show hover info --- .../image-chooser.component.html | 21 +++++++------------ .../image-chooser.component.scss | 14 ++++++------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.html b/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.html index 84e51d93..aa9773b0 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.html @@ -21,7 +21,7 @@
- {{ image.name }} - - {{ image.fileSize | fileSize: 'de' }} } + {{ image.name }} + + {{ image.fileSize | fileSize: 'de' }}
- +
} @empty { diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.scss b/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.scss index 37979f86..38440487 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.scss +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.scss @@ -11,15 +11,13 @@ } .image-tile { - &:not(.is-current-image) { - &:hover, - &.hover-override { - border-color: #333 !important; + &:hover, + &.hover-override { + border-color: #333 !important; - .hover-buttons { - background: rgb(255 255 255 / 85%); - display: flex !important; - } + .hover-buttons { + background: rgb(255 255 255 / 85%); + display: flex !important; } } From 053ef1b167ca27acfe2077c1ea0c046bd1dd823f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 19:10:35 +0100 Subject: [PATCH 35/38] Fix comment --- .../components/image-chooser/image-chooser.component.html | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.html b/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.html index aa9773b0..698a7ac6 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-chooser/image-chooser.component.html @@ -42,8 +42,6 @@ {{ image.fileSize | fileSize: 'de' }}
-
From 1d9cee2a1dfa042c690238a6691f2df7df41f82e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 19:11:44 +0100 Subject: [PATCH 36/38] Fix colspan --- .../components/image-manager/image-manager.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html index f0e63c3d..8c432b9d 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html @@ -64,7 +64,7 @@ } @empty { - + } From 59ec58be5d0d33b1c4eb3ea7988114b4d26ce43d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 19:12:57 +0100 Subject: [PATCH 37/38] Table layout adjustments --- .../components/image-manager/image-manager.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html index 8c432b9d..8069ec88 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/components/image-manager/image-manager.component.html @@ -5,7 +5,7 @@ - + From ae0caf167a5e06005028900c0304a82eb04c4062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 1 Feb 2026 19:16:27 +0100 Subject: [PATCH 38/38] Trim --- src/Turnierplan.App/Endpoints/Images/SetImageNameEndpoint.cs | 2 +- src/Turnierplan.App/Endpoints/Images/UploadImageEndpoint.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Turnierplan.App/Endpoints/Images/SetImageNameEndpoint.cs b/src/Turnierplan.App/Endpoints/Images/SetImageNameEndpoint.cs index a04c4491..7ed67e2a 100644 --- a/src/Turnierplan.App/Endpoints/Images/SetImageNameEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Images/SetImageNameEndpoint.cs @@ -39,7 +39,7 @@ private static async Task Handle( return Results.Forbid(); } - image.Name = request.Name; + image.Name = request.Name.Trim(); await repository.UnitOfWork.SaveChangesAsync(cancellationToken); diff --git a/src/Turnierplan.App/Endpoints/Images/UploadImageEndpoint.cs b/src/Turnierplan.App/Endpoints/Images/UploadImageEndpoint.cs index e3a3ac47..c511e831 100644 --- a/src/Turnierplan.App/Endpoints/Images/UploadImageEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Images/UploadImageEndpoint.cs @@ -87,7 +87,7 @@ private static async Task Handle( encodedData.SaveTo(memoryStream); memoryStream.Seek(0, SeekOrigin.Begin); - var image = new Image(organization, request.ImageName, "webp", memoryStream.Length, (ushort)imageData.Width, (ushort)imageData.Height); + var image = new Image(organization, request.ImageName.Trim(), "webp", memoryStream.Length, (ushort)imageData.Width, (ushort)imageData.Height); // Dispose here because Image() ctor accesses width and height of imageData imageData.Dispose();