From 43c5ebb02b9a1d0b7a4a73801052aa54436ba887 Mon Sep 17 00:00:00 2001 From: LarsvanDartel Date: Tue, 31 Mar 2026 19:33:04 +0200 Subject: [PATCH 01/58] chore(poster): rename local poster to static poster --- src/database.ts | 4 +-- .../screen/poster/static-poster-handler.ts | 8 ++--- .../static-poster-controller.ts} | 30 +++++++++---------- .../static-poster-service.ts} | 26 ++++++++-------- .../static-poster.ts} | 2 +- src/modules/timed-events/cron-manager.ts | 6 ++-- 6 files changed, 38 insertions(+), 38 deletions(-) rename src/modules/handlers/screen/poster/{local/local-poster-controller.ts => static/static-poster-controller.ts} (84%) rename src/modules/handlers/screen/poster/{local/local-poster-service.ts => static/static-poster-service.ts} (72%) rename src/modules/handlers/screen/poster/{local/local-poster.ts => static/static-poster.ts} (89%) diff --git a/src/database.ts b/src/database.ts index 3857a529..c80503db 100644 --- a/src/database.ts +++ b/src/database.ts @@ -9,7 +9,7 @@ import { Entities as AuditEntities } from './modules/audit/entities'; import { Entities as SpotifyEntities } from './modules/spotify/entities'; import { Entities as LightsEntities } from './modules/lights/entities'; import { Entities as TimedEventsEntities } from './modules/timed-events/entities'; -import LocalPoster from './modules/handlers/screen/poster/local/local-poster'; +import StaticPoster from './modules/handlers/screen/poster/static/static-poster'; const dataSource = new DataSource({ host: process.env.TYPEORM_HOST, @@ -42,7 +42,7 @@ const dataSource = new DataSource({ ...AuditEntities, ...SpotifyEntities, ...LightsEntities, - LocalPoster, + StaticPoster, ], }); diff --git a/src/modules/handlers/screen/poster/static-poster-handler.ts b/src/modules/handlers/screen/poster/static-poster-handler.ts index b966b8d7..d6427fda 100644 --- a/src/modules/handlers/screen/poster/static-poster-handler.ts +++ b/src/modules/handlers/screen/poster/static-poster-handler.ts @@ -1,19 +1,19 @@ import BaseScreenHandler from '../../base-screen-handler'; import { TrackChangeEvent } from '../../../events/music-emitter-events'; -import { LocalPosterResponse } from './local/local-poster-service'; +import { StaticPosterResponse } from './static/static-poster-service'; import { FeatureEnabled } from '../../../server-settings'; const UPDATE_POSTER_EVENT_NAME = 'update_static_poster'; const DEFAULT_CLOCK_VISIBLE = true; export interface StaticPosterHandlerState { - activePoster: LocalPosterResponse | null; + activePoster: StaticPosterResponse | null; clockVisible: boolean; } @FeatureEnabled('Poster') export default class StaticPosterHandler extends BaseScreenHandler { - private activePoster: LocalPosterResponse | null; + private activePoster: StaticPosterResponse | null; private clockVisible = DEFAULT_CLOCK_VISIBLE; @@ -28,7 +28,7 @@ export default class StaticPosterHandler extends BaseScreenHandler { * Change the currently active poster * @param poster */ - setActivePoster(poster: LocalPosterResponse): void { + setActivePoster(poster: StaticPosterResponse): void { this.activePoster = poster; this.sendEvent(UPDATE_POSTER_EVENT_NAME, this.getState()); } diff --git a/src/modules/handlers/screen/poster/local/local-poster-controller.ts b/src/modules/handlers/screen/poster/static/static-poster-controller.ts similarity index 84% rename from src/modules/handlers/screen/poster/local/local-poster-controller.ts rename to src/modules/handlers/screen/poster/static/static-poster-controller.ts index 879fd14a..03c2a92f 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-controller.ts +++ b/src/modules/handlers/screen/poster/static/static-poster-controller.ts @@ -5,7 +5,7 @@ import HandlerManager from '../../../../root/handler-manager'; import { Screen } from '../../../../root/entities'; import { SecurityNames } from '../../../../../helpers/security'; import { securityGroups } from '../../../../../helpers/security-groups'; -import LocalPosterService, { LocalPosterResponse } from './local-poster-service'; +import StaticPosterService, { StaticPosterResponse } from './static-poster-service'; import { Request as ExpressRequest } from 'express'; import logger from '../../../../../logger'; import { HttpStatusCode } from 'axios'; @@ -18,7 +18,7 @@ interface SetClockRequest { @Route('handler/screen/poster/static') @Tags('Handlers') -export class LocalPosterController extends Controller { +export class StaticPosterController extends Controller { private screenHandler: StaticPosterHandler; constructor() { @@ -73,9 +73,9 @@ export class LocalPosterController extends Controller { */ @Security(SecurityNames.LOCAL, securityGroups.poster.base) @Get('items') - public async getAllStaticPosters(): Promise { - const service = new LocalPosterService(); - const posters = await service.getAllLocalPosters(); + public async getAllStaticPosters(): Promise { + const service = new StaticPosterService(); + const posters = await service.getAllStaticPosters(); return posters.map((p) => service.toResponse(p)); } @@ -93,7 +93,7 @@ export class LocalPosterController extends Controller { HttpStatusCode.BadRequest, 'Invalid file type, expected an image or a video.' >, - ): Promise { + ): Promise { const mimeType = lookup(file.originalname); if (!mimeType || !(mimeType.startsWith('image/') || mimeType.startsWith('video'))) { return invalidFileTypeResponse( @@ -102,8 +102,8 @@ export class LocalPosterController extends Controller { ); } - const service = new LocalPosterService(); - const poster = await service.createLocalPoster({ + const service = new StaticPosterService(); + const poster = await service.createStaticPoster({ file: { name: file.originalname, data: file.buffer }, }); return service.toResponse(poster); @@ -115,9 +115,9 @@ export class LocalPosterController extends Controller { */ @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) @Post('items/url') - public async createStaticPosterUrl(@Body() body: { url: string }): Promise { - const service = new LocalPosterService(); - const poster = await service.createLocalPoster({ + public async createStaticPosterUrl(@Body() body: { url: string }): Promise { + const service = new StaticPosterService(); + const poster = await service.createStaticPoster({ uri: body.url, }); return service.toResponse(poster); @@ -130,8 +130,8 @@ export class LocalPosterController extends Controller { @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) @Delete('items/{id}') public async deleteStaticPoster(id: number): Promise { - const service = new LocalPosterService(); - await service.deleteLocalPoster(id); + const service = new StaticPosterService(); + await service.deleteStaticPoster(id); } /** @@ -142,8 +142,8 @@ export class LocalPosterController extends Controller { @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) @Post('items/{id}/show') public async showStaticPoster(id: number, @Request() req: ExpressRequest): Promise { - const service = new LocalPosterService(); - const poster = await service.getSingleLocalPoster(id); + const service = new StaticPosterService(); + const poster = await service.getSingleStaticPoster(id); const posterResponse = service.toResponse(poster); logger.audit(req.user, `Show static poster (id: ${id}).`); diff --git a/src/modules/handlers/screen/poster/local/local-poster-service.ts b/src/modules/handlers/screen/poster/static/static-poster-service.ts similarity index 72% rename from src/modules/handlers/screen/poster/local/local-poster-service.ts rename to src/modules/handlers/screen/poster/static/static-poster-service.ts index 1789ed58..f45941f6 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-service.ts +++ b/src/modules/handlers/screen/poster/static/static-poster-service.ts @@ -1,6 +1,6 @@ import { FileStorage } from '../../../../files/storage/file-storage'; import { DiskStorage } from '../../../../files/storage'; -import LocalPoster from './local-poster'; +import StaticPoster from './static-poster'; import { Repository } from 'typeorm'; import dataSource from '../../../../../database'; import { HttpApiException } from '../../../../../helpers/custom-error'; @@ -8,7 +8,7 @@ import { HttpStatusCode } from 'axios'; import { File } from '../../../../files/entities'; import FileResponse from '../../../../files/entities/file-response'; -export interface LocalPosterResponse { +export interface StaticPosterResponse { id: number; createdAt: string; updatedAt: string; @@ -16,7 +16,7 @@ export interface LocalPosterResponse { uri?: string; } -export interface LocalPosterRequest { +export interface StaticPosterRequest { file?: { name: string; data: Buffer; @@ -24,21 +24,21 @@ export interface LocalPosterRequest { uri?: string; } -export default class LocalPosterService { +export default class StaticPosterService { private storage: FileStorage; - private repo: Repository; + private repo: Repository; private fileRepo: Repository; constructor() { this.storage = new DiskStorage('local-posters'); - this.repo = dataSource.getRepository(LocalPoster); + this.repo = dataSource.getRepository(StaticPoster); this.fileRepo = dataSource.getRepository(File); } - public toResponse(poster: LocalPoster): LocalPosterResponse { - let file: LocalPosterResponse['file']; + public toResponse(poster: StaticPoster): StaticPosterResponse { + let file: StaticPosterResponse['file']; if (poster.file) { const location = this.storage.getPublicFileUri(poster.file); if (location) { @@ -58,11 +58,11 @@ export default class LocalPosterService { }; } - public async getAllLocalPosters(): Promise { + public async getAllStaticPosters(): Promise { return this.repo.find(); } - public async getSingleLocalPoster(id: number): Promise { + public async getSingleStaticPoster(id: number): Promise { const poster = await this.repo.findOne({ where: { id } }); if (poster == null) { throw new HttpApiException(HttpStatusCode.NotFound, `Poster with ID "${id}" not found.`); @@ -70,7 +70,7 @@ export default class LocalPosterService { return poster; } - public async createLocalPoster(poster: LocalPosterRequest): Promise { + public async createStaticPoster(poster: StaticPosterRequest): Promise { let file: File | undefined; if (poster.file) { const fileParams = await this.storage.saveFile(poster.file.name, poster.file.data); @@ -80,8 +80,8 @@ export default class LocalPosterService { return this.repo.save({ file, uri: poster.uri }); } - public async deleteLocalPoster(id: number): Promise { - const poster = await this.getSingleLocalPoster(id); + public async deleteStaticPoster(id: number): Promise { + const poster = await this.getSingleStaticPoster(id); if (poster.file) { await this.fileRepo.delete(poster.file.id); await this.storage.deleteFile(poster.file); diff --git a/src/modules/handlers/screen/poster/local/local-poster.ts b/src/modules/handlers/screen/poster/static/static-poster.ts similarity index 89% rename from src/modules/handlers/screen/poster/local/local-poster.ts rename to src/modules/handlers/screen/poster/static/static-poster.ts index 649edd95..a7f5bfa4 100644 --- a/src/modules/handlers/screen/poster/local/local-poster.ts +++ b/src/modules/handlers/screen/poster/static/static-poster.ts @@ -3,7 +3,7 @@ import { File } from '../../../../files/entities'; import { Column, Entity, JoinColumn, OneToOne } from 'typeorm'; @Entity() -export default class LocalPoster extends BaseEntity { +export default class StaticPoster extends BaseEntity { /** * File details of the poster, if locally stored on disk */ diff --git a/src/modules/timed-events/cron-manager.ts b/src/modules/timed-events/cron-manager.ts index c4af39e7..cb4516eb 100644 --- a/src/modules/timed-events/cron-manager.ts +++ b/src/modules/timed-events/cron-manager.ts @@ -7,7 +7,7 @@ import HandlerManager from '../root/handler-manager'; import RootAudioService from '../root/root-audio-service'; import RootLightsService from '../root/root-lights-service'; import RootScreenService from '../root/root-screen-service'; -import LocalPosterService from '../handlers/screen/poster/local/local-poster-service'; +import StaticPosterService from '../handlers/screen/poster/static/static-poster-service'; import { Screen } from '../root/entities'; import { StaticPosterHandler } from '../handlers/screen'; @@ -157,8 +157,8 @@ export default class CronManager { return; case 'timed-event-set-static-poster': { - const service = new LocalPosterService(); - const poster = await service.getSingleLocalPoster(spec.params.posterId); + const service = new StaticPosterService(); + const poster = await service.getSingleStaticPoster(spec.params.posterId); const handler = HandlerManager.getInstance() .getHandlers(Screen) .filter( From f81a44e084d28fb526385aa8e1906b15341e1599 Mon Sep 17 00:00:00 2001 From: LarsvanDartel Date: Tue, 31 Mar 2026 21:18:42 +0200 Subject: [PATCH 02/58] feat(poster): added database entitites for local posters --- src/database.ts | 5 +++ .../screen/poster/local/local-carousel.ts | 27 ++++++++++++ .../screen/poster/local/local-poster.ts | 42 +++++++++++++++++++ src/seed/index.ts | 3 +- src/seed/seedGewis.ts | 19 +++++++++ 5 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 src/modules/handlers/screen/poster/local/local-carousel.ts create mode 100644 src/modules/handlers/screen/poster/local/local-poster.ts diff --git a/src/database.ts b/src/database.ts index c80503db..8f585145 100644 --- a/src/database.ts +++ b/src/database.ts @@ -10,6 +10,8 @@ import { Entities as SpotifyEntities } from './modules/spotify/entities'; import { Entities as LightsEntities } from './modules/lights/entities'; import { Entities as TimedEventsEntities } from './modules/timed-events/entities'; import StaticPoster from './modules/handlers/screen/poster/static/static-poster'; +import LocalPoster from './modules/handlers/screen/poster/local/local-poster'; +import Carousel, { CarouselPoster } from './modules/handlers/screen/poster/local/local-carousel'; const dataSource = new DataSource({ host: process.env.TYPEORM_HOST, @@ -43,6 +45,9 @@ const dataSource = new DataSource({ ...SpotifyEntities, ...LightsEntities, StaticPoster, + LocalPoster, + Carousel, + CarouselPoster, ], }); diff --git a/src/modules/handlers/screen/poster/local/local-carousel.ts b/src/modules/handlers/screen/poster/local/local-carousel.ts new file mode 100644 index 00000000..b07b5454 --- /dev/null +++ b/src/modules/handlers/screen/poster/local/local-carousel.ts @@ -0,0 +1,27 @@ +import { Entity, Column, OneToMany, ManyToOne } from 'typeorm'; +import BaseEntity from '../../../../root/entities/base-entity'; +import LocalPoster from './local-poster'; + +@Entity() +export default class Carousel extends BaseEntity { + @Column() + name: string; + + @Column({ default: true }) + active: boolean; + + @OneToMany(() => CarouselPoster, (cp) => cp.carousel) + posters: CarouselPoster[]; +} + +@Entity() +export class CarouselPoster extends BaseEntity { + @ManyToOne(() => Carousel, (c) => c.posters, { onDelete: 'CASCADE' }) + carousel: Carousel; + + @ManyToOne(() => LocalPoster, { onDelete: 'CASCADE' }) + poster: LocalPoster; + + @Column({ nullable: true }) + customTimeout?: number; +} diff --git a/src/modules/handlers/screen/poster/local/local-poster.ts b/src/modules/handlers/screen/poster/local/local-poster.ts new file mode 100644 index 00000000..74e0fc55 --- /dev/null +++ b/src/modules/handlers/screen/poster/local/local-poster.ts @@ -0,0 +1,42 @@ +import { Entity, Column } from 'typeorm'; +import BaseEntity from '../../../../root/entities/base-entity'; + +export enum FooterSize { + FULL = 'full', + MINIMAL = 'minimal', + HIDDEN = 'hidden', +} + +@Entity() +export default class LocalPoster extends BaseEntity { + @Column() + name: string; + + @Column() + type: string; + + @Column({ nullable: true }) + expirationDate?: Date; + + @Column({ nullable: true }) + accentColor?: string; + + @Column({ default: true }) + active: boolean; + + @Column({ default: false }) + protected: boolean; + + @Column({ + type: 'text', + enum: FooterSize, + default: FooterSize.FULL, + }) + footerSize: FooterSize; + + @Column({ default: 15 }) + defaultTimeout: number; + + @Column({ type: 'simple-array', nullable: true }) + sources?: string[]; +} diff --git a/src/seed/index.ts b/src/seed/index.ts index d1e129fa..39004b69 100644 --- a/src/seed/index.ts +++ b/src/seed/index.ts @@ -1,7 +1,7 @@ import '../env'; import { Command, Option } from 'commander'; import dataSource from '../database'; -import seedDatabase, { seedBorrelLights, seedOpeningSequence } from './seedGewis'; +import seedDatabase, { seedBorrelLights, seedOpeningSequence, seedPosters } from './seedGewis'; import logger from '../logger'; import seedDatabaseHubble from './seedHubble'; import { seedDiscoFloor } from './seedDiscoFloor'; @@ -48,6 +48,7 @@ async function createSeeder() { const [room, bar, lounge, movingHeadsGEWIS, movingHeadsRoy] = await seedDatabase(); await seedBorrelLights(room!, bar!, lounge!, movingHeadsGEWIS!); await seedOpeningSequence(room!, bar!, movingHeadsGEWIS!, movingHeadsRoy!); + await seedPosters(); } else if (program.opts().discoFloor) { console.info('Seeding database for disco floor'); const { width, height, channelOrder } = program.opts(); diff --git a/src/seed/seedGewis.ts b/src/seed/seedGewis.ts index 0fe11978..6e7682c7 100644 --- a/src/seed/seedGewis.ts +++ b/src/seed/seedGewis.ts @@ -22,6 +22,7 @@ import { MovementEffects } from '../modules/lights/effects/movement/movement-eff import { TimedEvent } from '../modules/timed-events/entities'; import AuthService from '../modules/auth/auth-service'; import { IntegrationUserService } from '../modules/auth/integration'; +import LocalPoster from '../modules/handlers/screen/poster/local/local-poster'; export default async function seedDatabase() { const timedEventsRepo = dataSource.getRepository(TimedEvent); @@ -775,3 +776,21 @@ export async function seedOpeningSequence( await addStep(borrelRuimte, 285000, 15000, [room]); await addStep(borrelBar, 285000, 15000, [bar]); } + +export async function seedPosters() { + const repo = dataSource.getRepository(LocalPoster); + + const addPoster = async (name: string) => { + await repo.save({ + name, + type: name, + protected: true, + } as LocalPoster); + }; + + await Promise.all( + ['logo', 'borrel-logo', 'borrel-wall-of-shame', 'borrel-price-list', 'train', 'olympics'].map( + addPoster, + ), + ); +} From 2eb5e4fe2490feeff3cb78156ca62a93a88ac74c Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 15 Apr 2026 10:45:22 +0200 Subject: [PATCH 03/58] feat(poster): added borrelMode property to local poster entities --- src/modules/handlers/screen/poster/local/local-poster.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/modules/handlers/screen/poster/local/local-poster.ts b/src/modules/handlers/screen/poster/local/local-poster.ts index 74e0fc55..48496af4 100644 --- a/src/modules/handlers/screen/poster/local/local-poster.ts +++ b/src/modules/handlers/screen/poster/local/local-poster.ts @@ -27,6 +27,9 @@ export default class LocalPoster extends BaseEntity { @Column({ default: false }) protected: boolean; + @Column({default: false}) + borrelMode: boolean; + @Column({ type: 'text', enum: FooterSize, From bcb853690109f4be6776633de0134f4e2d1ad8a1 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 15 Apr 2026 12:21:57 +0200 Subject: [PATCH 04/58] chore(formatting): ran prettier --- src/modules/handlers/screen/poster/local/local-poster.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/handlers/screen/poster/local/local-poster.ts b/src/modules/handlers/screen/poster/local/local-poster.ts index 48496af4..12e518f1 100644 --- a/src/modules/handlers/screen/poster/local/local-poster.ts +++ b/src/modules/handlers/screen/poster/local/local-poster.ts @@ -27,7 +27,7 @@ export default class LocalPoster extends BaseEntity { @Column({ default: false }) protected: boolean; - @Column({default: false}) + @Column({ default: false }) borrelMode: boolean; @Column({ From 28dae0e3143eaa38c69e5a3a6610d5dff399bbcd Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 15 Apr 2026 12:23:13 +0200 Subject: [PATCH 05/58] feat(poster): added API stubs for local poster management --- .../poster/local/local-poster-controller.ts | 119 ++++++++++++++++++ .../poster/local/local-poster-service.ts | 1 + 2 files changed, 120 insertions(+) create mode 100644 src/modules/handlers/screen/poster/local/local-poster-controller.ts create mode 100644 src/modules/handlers/screen/poster/local/local-poster-service.ts diff --git a/src/modules/handlers/screen/poster/local/local-poster-controller.ts b/src/modules/handlers/screen/poster/local/local-poster-controller.ts new file mode 100644 index 00000000..41bbe06c --- /dev/null +++ b/src/modules/handlers/screen/poster/local/local-poster-controller.ts @@ -0,0 +1,119 @@ +import { Controller, Patch, TsoaResponse, UploadedFile } from '@tsoa/runtime'; +import { Body, Delete, Get, Post, Res, Route, Security, Tags } from 'tsoa'; +import { SecurityNames } from '../../../../../helpers/security'; +import { securityGroups } from '../../../../../helpers/security-groups'; +import { HttpStatusCode } from 'axios'; +import { LocalPosterResponse } from './local-poster-service'; + +interface BasePosterParams { + name: string; + expirationDate?: Date; + accentColor?: string; + footerSize?: string; + defaultTimeout?: number; + borrelMode?: boolean; +} + +interface MediaPosterParams extends BasePosterParams { + type: 'media'; +} + +interface UrlPosterParams extends BasePosterParams { + type: 'url'; + url: string; +} + +interface PhotoPosterParams extends BasePosterParams { + type: 'photo'; + album: number; +} + +export type CreatePosterParams = MediaPosterParams | UrlPosterParams | PhotoPosterParams; + +@Route('/handler/screen/poster') +@Tags('Handlers') +export class LocalPosterController extends Controller { + /** + * Get all posters from the database. + */ + @Security(SecurityNames.LOCAL, securityGroups.poster.base) + @Get('items') + public async getAllPosters(): Promise { + this.setStatus(501); + return [] as LocalPosterResponse[]; + } + + /** + * Gets a single poster from the database. + * @param id The id of the poster to get. + */ + @Security(SecurityNames.LOCAL, securityGroups.poster.base) + @Get('items/{id}') + public async getPoster(id: number): Promise { + this.setStatus(501); + return {} as LocalPosterResponse; + } + + /** + * Creates a new poster of the given type. + * @param body Body specifying the poster to be created. + */ + @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) + @Post('items') + public async createPoster(@Body() body: CreatePosterParams): Promise { + this.setStatus(501); + return {} as LocalPosterResponse; + } + + /** + * Attaches uploaded file to existing media poster. + * @param id Id of the poster. + * @param file File to be attached, has to be an image or video. + * @param invalidFileTypeResponse + * @param invalidPosterTypeResponse + */ + @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) + @Post('/items/{id}/media') + public async attachMedia( + id: number, + @UploadedFile() file: Express.Multer.File, + @Res() + invalidFileTypeResponse: TsoaResponse< + HttpStatusCode.UnsupportedMediaType, + 'Invalid file type, expected an image or a video.' + >, + @Res() + invalidPosterTypeResponse: TsoaResponse< + HttpStatusCode.BadRequest, + 'Requested poster is not a media poster.' + >, + ): Promise { + this.setStatus(501); + return {} as LocalPosterResponse; + } + + /** + * Deletes a specific poster from the database. + * @param id Indicates the poster to be deleted. + */ + @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) + @Delete('items/{id}') + public async deletePoster(id: number): Promise { + this.setStatus(501); + } + + /** + * Updates the updatable fields of a specific poster. + * @param id The id of the to be updated poster. + * @param body The new values of the fields to be changed as specified in UpdatePosterParams. + */ + @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) + @Patch('items/{id}') + public async updatePoster( + id: number, + @Body() body: BasePosterParams, + ): Promise { + this.setStatus(501); + return {} as LocalPosterResponse; + } +} diff --git a/src/modules/handlers/screen/poster/local/local-poster-service.ts b/src/modules/handlers/screen/poster/local/local-poster-service.ts new file mode 100644 index 00000000..fe962e27 --- /dev/null +++ b/src/modules/handlers/screen/poster/local/local-poster-service.ts @@ -0,0 +1 @@ +export interface LocalPosterResponse {} From 976a53ea45dd5a880e406f8a6f31de8675e13565 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 15 Apr 2026 17:00:46 +0200 Subject: [PATCH 06/58] feat(poster): added stub functions in local poster service --- .../poster/local/local-poster-controller.ts | 17 ++++-- .../poster/local/local-poster-service.ts | 61 +++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/modules/handlers/screen/poster/local/local-poster-controller.ts b/src/modules/handlers/screen/poster/local/local-poster-controller.ts index 41bbe06c..70a5ddf7 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-controller.ts +++ b/src/modules/handlers/screen/poster/local/local-poster-controller.ts @@ -14,22 +14,31 @@ interface BasePosterParams { borrelMode?: boolean; } -interface MediaPosterParams extends BasePosterParams { +export interface MediaPosterParams extends BasePosterParams { type: 'media'; } -interface UrlPosterParams extends BasePosterParams { +export interface UrlPosterParams extends BasePosterParams { type: 'url'; url: string; } -interface PhotoPosterParams extends BasePosterParams { +export interface PhotoPosterParams extends BasePosterParams { type: 'photo'; album: number; } export type CreatePosterParams = MediaPosterParams | UrlPosterParams | PhotoPosterParams; +export interface UpdatePosterParams { + name?: string; + expirationDate?: Date; + accentColor?: string; + footerSize?: string; + defaultTimeout?: number; + borrelMode?: boolean; +} + @Route('/handler/screen/poster') @Tags('Handlers') export class LocalPosterController extends Controller { @@ -111,7 +120,7 @@ export class LocalPosterController extends Controller { @Patch('items/{id}') public async updatePoster( id: number, - @Body() body: BasePosterParams, + @Body() body: UpdatePosterParams, ): Promise { this.setStatus(501); return {} as LocalPosterResponse; diff --git a/src/modules/handlers/screen/poster/local/local-poster-service.ts b/src/modules/handlers/screen/poster/local/local-poster-service.ts index fe962e27..baad1e51 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-service.ts +++ b/src/modules/handlers/screen/poster/local/local-poster-service.ts @@ -1 +1,62 @@ +import { FileStorage } from '../../../../files/storage/file-storage'; +import { Repository } from 'typeorm'; +import { File } from '../../../../files/entities'; +import LocalPoster from './local-poster'; +import { DiskStorage } from '../../../../files/storage'; +import dataSource from '../../../../../database'; +import { + MediaPosterParams, + PhotoPosterParams, + UpdatePosterParams, + UrlPosterParams, +} from './local-poster-controller'; + export interface LocalPosterResponse {} + +export default class LocalPosterService { + private storage: FileStorage; + + private repo: Repository; + + private fileRepo: Repository; + + constructor() { + this.storage = new DiskStorage('posters'); + this.repo = dataSource.getRepository(LocalPoster); + this.fileRepo = dataSource.getRepository(File); + } + + public toResponse(poster: LocalPoster): LocalPosterResponse { + return {} as LocalPosterResponse; + } + + public async getAllLocalPosters(): Promise { + return [] as LocalPoster[]; + } + + public async getSingleLocalPoster(id: number): Promise { + return {} as LocalPoster; + } + + public async createMediaPoster(params: MediaPosterParams): Promise { + return {} as LocalPoster; + } + + public async attachMedia(id: number, file: { name: string; data: Buffer }): Promise { + return {} as LocalPoster; + } + + public async createUrlPoster(params: UrlPosterParams): Promise { + return {} as LocalPoster; + } + + public async createPhotoPoster(params: PhotoPosterParams): Promise { + return {} as LocalPoster; + } + + public async deleteLocalPoster(id: number): Promise {} + + public async updateLocalPoster(id: number, params: UpdatePosterParams): Promise { + return {} as LocalPoster; + } +} From df9766149482046d161f0d5b2988d06d7d77b93d Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 15 Apr 2026 19:08:12 +0200 Subject: [PATCH 07/58] feat(poster): implemented controller functions --- .../poster/local/local-poster-controller.ts | 105 +++++++++--------- .../poster/local/local-poster-service.ts | 42 +++++-- 2 files changed, 85 insertions(+), 62 deletions(-) diff --git a/src/modules/handlers/screen/poster/local/local-poster-controller.ts b/src/modules/handlers/screen/poster/local/local-poster-controller.ts index 70a5ddf7..61f06ed0 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-controller.ts +++ b/src/modules/handlers/screen/poster/local/local-poster-controller.ts @@ -1,55 +1,29 @@ import { Controller, Patch, TsoaResponse, UploadedFile } from '@tsoa/runtime'; -import { Body, Delete, Get, Post, Res, Route, Security, Tags } from 'tsoa'; +import { Body, Delete, Get, Post, Put, Res, Route, Security, Tags } from 'tsoa'; import { SecurityNames } from '../../../../../helpers/security'; import { securityGroups } from '../../../../../helpers/security-groups'; import { HttpStatusCode } from 'axios'; -import { LocalPosterResponse } from './local-poster-service'; - -interface BasePosterParams { - name: string; - expirationDate?: Date; - accentColor?: string; - footerSize?: string; - defaultTimeout?: number; - borrelMode?: boolean; -} - -export interface MediaPosterParams extends BasePosterParams { - type: 'media'; -} - -export interface UrlPosterParams extends BasePosterParams { - type: 'url'; - url: string; -} - -export interface PhotoPosterParams extends BasePosterParams { - type: 'photo'; - album: number; -} - -export type CreatePosterParams = MediaPosterParams | UrlPosterParams | PhotoPosterParams; - -export interface UpdatePosterParams { - name?: string; - expirationDate?: Date; - accentColor?: string; - footerSize?: string; - defaultTimeout?: number; - borrelMode?: boolean; -} +import LocalPosterService, { + CreatePosterParams, + LocalPosterResponse, + UpdatePosterParams, +} from './local-poster-service'; +import LocalPoster from './local-poster'; +import { lookup } from 'mime-types'; @Route('/handler/screen/poster') @Tags('Handlers') export class LocalPosterController extends Controller { + private service = new LocalPosterService(); + /** * Get all posters from the database. */ @Security(SecurityNames.LOCAL, securityGroups.poster.base) @Get('items') public async getAllPosters(): Promise { - this.setStatus(501); - return [] as LocalPosterResponse[]; + const posters = await this.service.getAllLocalPosters(); + return posters.map((poster) => this.service.toResponse(poster)); } /** @@ -59,19 +33,38 @@ export class LocalPosterController extends Controller { @Security(SecurityNames.LOCAL, securityGroups.poster.base) @Get('items/{id}') public async getPoster(id: number): Promise { - this.setStatus(501); - return {} as LocalPosterResponse; + const poster = await this.service.getSingleLocalPoster(id); + return this.service.toResponse(poster); } /** * Creates a new poster of the given type. * @param body Body specifying the poster to be created. + * @param invalidPosterTypeResponse */ @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) @Post('items') - public async createPoster(@Body() body: CreatePosterParams): Promise { - this.setStatus(501); - return {} as LocalPosterResponse; + public async createPoster( + @Body() body: CreatePosterParams, + @Res() + invalidPosterTypeResponse: TsoaResponse, + ): Promise { + let poster: LocalPoster; + switch (body.type) { + case 'media': + poster = await this.service.createMediaPoster(body); + break; + case 'url': + poster = await this.service.createUrlPoster(body); + break; + case 'photo': + poster = await this.service.createPhotoPoster(body); + break; + default: { + return invalidPosterTypeResponse(HttpStatusCode.BadRequest, 'Unknown Poster Type'); + } + } + return this.service.toResponse(poster); } /** @@ -79,10 +72,9 @@ export class LocalPosterController extends Controller { * @param id Id of the poster. * @param file File to be attached, has to be an image or video. * @param invalidFileTypeResponse - * @param invalidPosterTypeResponse */ @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) - @Post('/items/{id}/media') + @Put('/items/{id}/media') public async attachMedia( id: number, @UploadedFile() file: Express.Multer.File, @@ -91,14 +83,17 @@ export class LocalPosterController extends Controller { HttpStatusCode.UnsupportedMediaType, 'Invalid file type, expected an image or a video.' >, - @Res() - invalidPosterTypeResponse: TsoaResponse< - HttpStatusCode.BadRequest, - 'Requested poster is not a media poster.' - >, ): Promise { - this.setStatus(501); - return {} as LocalPosterResponse; + const mimeType = lookup(file.originalname); + if (!mimeType || !(mimeType.startsWith('image/') || mimeType.startsWith('video'))) { + return invalidFileTypeResponse( + HttpStatusCode.UnsupportedMediaType, + 'Invalid file type, expected an image or a video.', + ); + } + + const poster = await this.service.attachMedia(id, file.originalname, file.buffer); + return this.service.toResponse(poster); } /** @@ -108,7 +103,7 @@ export class LocalPosterController extends Controller { @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) @Delete('items/{id}') public async deletePoster(id: number): Promise { - this.setStatus(501); + await this.service.deleteLocalPoster(id); } /** @@ -122,7 +117,7 @@ export class LocalPosterController extends Controller { id: number, @Body() body: UpdatePosterParams, ): Promise { - this.setStatus(501); - return {} as LocalPosterResponse; + const poster = await this.service.updateLocalPoster(id, body); + return this.service.toResponse(poster); } } diff --git a/src/modules/handlers/screen/poster/local/local-poster-service.ts b/src/modules/handlers/screen/poster/local/local-poster-service.ts index baad1e51..06fbc14e 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-service.ts +++ b/src/modules/handlers/screen/poster/local/local-poster-service.ts @@ -4,12 +4,40 @@ import { File } from '../../../../files/entities'; import LocalPoster from './local-poster'; import { DiskStorage } from '../../../../files/storage'; import dataSource from '../../../../../database'; -import { - MediaPosterParams, - PhotoPosterParams, - UpdatePosterParams, - UrlPosterParams, -} from './local-poster-controller'; + +interface BasePosterParams { + name: string; + expirationDate?: Date; + accentColor?: string; + footerSize?: string; + defaultTimeout?: number; + borrelMode?: boolean; +} + +export interface MediaPosterParams extends BasePosterParams { + type: 'media'; +} + +export interface UrlPosterParams extends BasePosterParams { + type: 'url'; + url: string; +} + +export interface PhotoPosterParams extends BasePosterParams { + type: 'photo'; + album: number; +} + +export type CreatePosterParams = MediaPosterParams | UrlPosterParams | PhotoPosterParams; + +export interface UpdatePosterParams { + name?: string; + expirationDate?: Date; + accentColor?: string; + footerSize?: string; + defaultTimeout?: number; + borrelMode?: boolean; +} export interface LocalPosterResponse {} @@ -42,7 +70,7 @@ export default class LocalPosterService { return {} as LocalPoster; } - public async attachMedia(id: number, file: { name: string; data: Buffer }): Promise { + public async attachMedia(id: number, filename: string, filedata: Buffer): Promise { return {} as LocalPoster; } From b2bc01468938abcd24b560e015d6022b0e86622b Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 15 Apr 2026 19:46:23 +0200 Subject: [PATCH 08/58] chore: added comments to functions in local poster service and fixed route inconsistency in local poster controller --- .../poster/local/local-poster-controller.ts | 2 +- .../poster/local/local-poster-service.ts | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/modules/handlers/screen/poster/local/local-poster-controller.ts b/src/modules/handlers/screen/poster/local/local-poster-controller.ts index 61f06ed0..16cf58ec 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-controller.ts +++ b/src/modules/handlers/screen/poster/local/local-poster-controller.ts @@ -74,7 +74,7 @@ export class LocalPosterController extends Controller { * @param invalidFileTypeResponse */ @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) - @Put('/items/{id}/media') + @Put('items/{id}/media') public async attachMedia( id: number, @UploadedFile() file: Express.Multer.File, diff --git a/src/modules/handlers/screen/poster/local/local-poster-service.ts b/src/modules/handlers/screen/poster/local/local-poster-service.ts index 06fbc14e..ac063323 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-service.ts +++ b/src/modules/handlers/screen/poster/local/local-poster-service.ts @@ -54,36 +54,75 @@ export default class LocalPosterService { this.fileRepo = dataSource.getRepository(File); } + /** + * Converts a poster entity to the LocalPosterResponse format. + * @param poster The poster to be converted. + */ public toResponse(poster: LocalPoster): LocalPosterResponse { return {} as LocalPosterResponse; } + /** + * Fetches all Local Posters from the database. + */ public async getAllLocalPosters(): Promise { return [] as LocalPoster[]; } + /** + * Gets a specific Local Poster from the database. + * @param id The id of the poster to fetch. + */ public async getSingleLocalPoster(id: number): Promise { return {} as LocalPoster; } + /** + * Creates a new Local Poster with the media type in the database. + * This does not yet contain the actual image or video of the poster. + * @param params Metadata of the poster as specified in the MediaPosterParams interface. + */ public async createMediaPoster(params: MediaPosterParams): Promise { return {} as LocalPoster; } + /** + * Adds the given image or video to the database entry for the specified poster. + * @param id Id of the poster to add the media to. + * @param filename Original filename of the media file. + * @param filedata Buffer containing the file. + */ public async attachMedia(id: number, filename: string, filedata: Buffer): Promise { return {} as LocalPoster; } + /** + * Creates a new Local Poster of the url type. + * @param params The specifics of the poster as specified in the UrlPosterParams interface. + */ public async createUrlPoster(params: UrlPosterParams): Promise { return {} as LocalPoster; } + /** + * Creates a new Local Poster of the photo type. + * @param params The specifics of the poster as specified in the PhotoPosterParams interface. + */ public async createPhotoPoster(params: PhotoPosterParams): Promise { return {} as LocalPoster; } + /** + * Deletes the given poster from the database and storage. + * @param id The id of the poster to be deleted. + */ public async deleteLocalPoster(id: number): Promise {} + /** + * Updates the given fields in the database entry of the given poster. + * @param id The id of the poster to be updated. + * @param params The fields of the poster to be updated as specified in UpdatePosterParams. + */ public async updateLocalPoster(id: number, params: UpdatePosterParams): Promise { return {} as LocalPoster; } From 13443c8ec76c6e68c0a41b43f7d685e42cd9e10d Mon Sep 17 00:00:00 2001 From: Wanderer Date: Thu, 16 Apr 2026 12:13:53 +0200 Subject: [PATCH 09/58] feat(poster): Revised LocalPoster entity to use PosterType. --- .../screen/poster/local/local-poster.ts | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/modules/handlers/screen/poster/local/local-poster.ts b/src/modules/handlers/screen/poster/local/local-poster.ts index 12e518f1..6caee15d 100644 --- a/src/modules/handlers/screen/poster/local/local-poster.ts +++ b/src/modules/handlers/screen/poster/local/local-poster.ts @@ -1,19 +1,18 @@ -import { Entity, Column } from 'typeorm'; +import { Entity, Column, OneToOne, JoinColumn } from 'typeorm'; import BaseEntity from '../../../../root/entities/base-entity'; - -export enum FooterSize { - FULL = 'full', - MINIMAL = 'minimal', - HIDDEN = 'hidden', -} +import { File } from '../../../../files/entities'; +import { FooterSize, PosterType } from '../poster'; @Entity() export default class LocalPoster extends BaseEntity { @Column() name: string; - @Column() - type: string; + @Column({ + type:'text', + enum: PosterType + }) + type: PosterType; @Column({ nullable: true }) expirationDate?: Date; @@ -40,6 +39,13 @@ export default class LocalPoster extends BaseEntity { @Column({ default: 15 }) defaultTimeout: number; + @Column({ nullable: true }) + uri?: string; + @Column({ type: 'simple-array', nullable: true }) - sources?: string[]; + albums?: number[]; + + @OneToOne(() => File, { nullable: true, eager: true, onDelete: 'SET NULL' }) + @JoinColumn() + file?: File; } From 5867e9121e198e6294f9a7cf5a22957e36246ad1 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Thu, 16 Apr 2026 12:22:32 +0200 Subject: [PATCH 10/58] feat(poster): Changed service / controller to use PosterType --- .../poster/local/local-poster-controller.ts | 7 ++-- .../poster/local/local-poster-service.ts | 33 ++++++++++++++----- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/modules/handlers/screen/poster/local/local-poster-controller.ts b/src/modules/handlers/screen/poster/local/local-poster-controller.ts index 16cf58ec..43f014f1 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-controller.ts +++ b/src/modules/handlers/screen/poster/local/local-poster-controller.ts @@ -10,6 +10,7 @@ import LocalPosterService, { } from './local-poster-service'; import LocalPoster from './local-poster'; import { lookup } from 'mime-types'; +import { PosterType } from '../poster'; @Route('/handler/screen/poster') @Tags('Handlers') @@ -51,13 +52,13 @@ export class LocalPosterController extends Controller { ): Promise { let poster: LocalPoster; switch (body.type) { - case 'media': + case PosterType.VIDEO || PosterType.IMAGE: poster = await this.service.createMediaPoster(body); break; - case 'url': + case PosterType.EXTERNAL: poster = await this.service.createUrlPoster(body); break; - case 'photo': + case PosterType.PHOTO: poster = await this.service.createPhotoPoster(body); break; default: { diff --git a/src/modules/handlers/screen/poster/local/local-poster-service.ts b/src/modules/handlers/screen/poster/local/local-poster-service.ts index ac063323..85e52c3f 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-service.ts +++ b/src/modules/handlers/screen/poster/local/local-poster-service.ts @@ -4,27 +4,30 @@ import { File } from '../../../../files/entities'; import LocalPoster from './local-poster'; import { DiskStorage } from '../../../../files/storage'; import dataSource from '../../../../../database'; +import { HttpApiException } from '../../../../../helpers/custom-error'; +import { HttpStatusCode } from 'axios'; +import { FooterSize, PosterType } from '../poster'; interface BasePosterParams { name: string; expirationDate?: Date; accentColor?: string; - footerSize?: string; + footerSize?: FooterSize; defaultTimeout?: number; borrelMode?: boolean; } export interface MediaPosterParams extends BasePosterParams { - type: 'media'; + type: PosterType.IMAGE | PosterType.VIDEO; } export interface UrlPosterParams extends BasePosterParams { - type: 'url'; + type: PosterType.EXTERNAL; url: string; } export interface PhotoPosterParams extends BasePosterParams { - type: 'photo'; + type: PosterType.PHOTO; album: number; } @@ -34,7 +37,7 @@ export interface UpdatePosterParams { name?: string; expirationDate?: Date; accentColor?: string; - footerSize?: string; + footerSize?: FooterSize; defaultTimeout?: number; borrelMode?: boolean; } @@ -66,7 +69,7 @@ export default class LocalPosterService { * Fetches all Local Posters from the database. */ public async getAllLocalPosters(): Promise { - return [] as LocalPoster[]; + return this.repo.find(); } /** @@ -74,7 +77,11 @@ export default class LocalPosterService { * @param id The id of the poster to fetch. */ public async getSingleLocalPoster(id: number): Promise { - return {} as LocalPoster; + const poster = await this.repo.findOne({ where: { id } }); + if (poster == null) { + throw new HttpApiException(HttpStatusCode.NotFound, `Poster with ID "${id}" not found.`); + } + return poster; } /** @@ -83,7 +90,17 @@ export default class LocalPosterService { * @param params Metadata of the poster as specified in the MediaPosterParams interface. */ public async createMediaPoster(params: MediaPosterParams): Promise { - return {} as LocalPoster; + const { name, type, expirationDate, accentColor, footerSize, defaultTimeout, borrelMode } = + params; + return this.repo.save({ + name, + type, + expirationDate, + accentColor, + footerSize, + defaultTimeout, + borrelMode, + }); } /** From 3ce0b830427ad95c9f3193d29a198b2887ab7c66 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Thu, 16 Apr 2026 13:55:58 +0200 Subject: [PATCH 11/58] feat(poster): Implemented LocalPosterService functions --- .../poster/local/local-poster-service.ts | 63 ++++++++++++++++--- .../screen/poster/local/local-poster.ts | 4 +- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/src/modules/handlers/screen/poster/local/local-poster-service.ts b/src/modules/handlers/screen/poster/local/local-poster-service.ts index 85e52c3f..88e7c044 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-service.ts +++ b/src/modules/handlers/screen/poster/local/local-poster-service.ts @@ -23,12 +23,12 @@ export interface MediaPosterParams extends BasePosterParams { export interface UrlPosterParams extends BasePosterParams { type: PosterType.EXTERNAL; - url: string; + uri: string; } export interface PhotoPosterParams extends BasePosterParams { type: PosterType.PHOTO; - album: number; + albums: number[]; } export type CreatePosterParams = MediaPosterParams | UrlPosterParams | PhotoPosterParams; @@ -110,7 +110,17 @@ export default class LocalPosterService { * @param filedata Buffer containing the file. */ public async attachMedia(id: number, filename: string, filedata: Buffer): Promise { - return {} as LocalPoster; + const poster = await this.getSingleLocalPoster(id); + if (poster.type != PosterType.IMAGE && poster.type != PosterType.VIDEO) { + throw new HttpApiException( + HttpStatusCode.BadRequest, + `Poster with ID "${id}" is not a media poster.`, + ); + } + + const fileParams = await this.storage.saveFile(filename, filedata); + poster.file = await this.fileRepo.save(fileParams); + return this.repo.save(poster); } /** @@ -118,7 +128,18 @@ export default class LocalPosterService { * @param params The specifics of the poster as specified in the UrlPosterParams interface. */ public async createUrlPoster(params: UrlPosterParams): Promise { - return {} as LocalPoster; + const { name, type, expirationDate, accentColor, footerSize, defaultTimeout, borrelMode, uri } = + params; + return this.repo.save({ + name, + type, + expirationDate, + accentColor, + footerSize, + defaultTimeout, + borrelMode, + uri, + }); } /** @@ -126,14 +147,40 @@ export default class LocalPosterService { * @param params The specifics of the poster as specified in the PhotoPosterParams interface. */ public async createPhotoPoster(params: PhotoPosterParams): Promise { - return {} as LocalPoster; + const { + name, + type, + expirationDate, + accentColor, + footerSize, + defaultTimeout, + borrelMode, + albums, + } = params; + return this.repo.save({ + name, + type, + expirationDate, + accentColor, + footerSize, + defaultTimeout, + borrelMode, + albums, + }); } /** * Deletes the given poster from the database and storage. * @param id The id of the poster to be deleted. */ - public async deleteLocalPoster(id: number): Promise {} + public async deleteLocalPoster(id: number): Promise { + const poster = await this.getSingleLocalPoster(id); + if (poster.file) { + await this.fileRepo.delete(poster.file.id); + await this.storage.deleteFile(poster.file); + } + await this.repo.remove(poster); + } /** * Updates the given fields in the database entry of the given poster. @@ -141,6 +188,8 @@ export default class LocalPosterService { * @param params The fields of the poster to be updated as specified in UpdatePosterParams. */ public async updateLocalPoster(id: number, params: UpdatePosterParams): Promise { - return {} as LocalPoster; + const poster = await this.getSingleLocalPoster(id); + Object.assign(poster, params); + return this.repo.save(poster); } } diff --git a/src/modules/handlers/screen/poster/local/local-poster.ts b/src/modules/handlers/screen/poster/local/local-poster.ts index 6caee15d..fa46af4e 100644 --- a/src/modules/handlers/screen/poster/local/local-poster.ts +++ b/src/modules/handlers/screen/poster/local/local-poster.ts @@ -9,8 +9,8 @@ export default class LocalPoster extends BaseEntity { name: string; @Column({ - type:'text', - enum: PosterType + type: 'text', + enum: PosterType, }) type: PosterType; From ce22bd1c348b452729b3296259bf28aed3150b87 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Thu, 16 Apr 2026 14:04:54 +0200 Subject: [PATCH 12/58] Chore: Renamed some variables to be more consistent --- .../poster/local/local-poster-controller.ts | 10 +++++----- .../poster/local/local-poster-service.ts | 18 +++++++++--------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/modules/handlers/screen/poster/local/local-poster-controller.ts b/src/modules/handlers/screen/poster/local/local-poster-controller.ts index 43f014f1..bc4c1a36 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-controller.ts +++ b/src/modules/handlers/screen/poster/local/local-poster-controller.ts @@ -4,9 +4,9 @@ import { SecurityNames } from '../../../../../helpers/security'; import { securityGroups } from '../../../../../helpers/security-groups'; import { HttpStatusCode } from 'axios'; import LocalPosterService, { - CreatePosterParams, + CreatePosterRequest, LocalPosterResponse, - UpdatePosterParams, + UpdatePosterRequest, } from './local-poster-service'; import LocalPoster from './local-poster'; import { lookup } from 'mime-types'; @@ -46,7 +46,7 @@ export class LocalPosterController extends Controller { @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) @Post('items') public async createPoster( - @Body() body: CreatePosterParams, + @Body() body: CreatePosterRequest, @Res() invalidPosterTypeResponse: TsoaResponse, ): Promise { @@ -56,7 +56,7 @@ export class LocalPosterController extends Controller { poster = await this.service.createMediaPoster(body); break; case PosterType.EXTERNAL: - poster = await this.service.createUrlPoster(body); + poster = await this.service.createExternalPoster(body); break; case PosterType.PHOTO: poster = await this.service.createPhotoPoster(body); @@ -116,7 +116,7 @@ export class LocalPosterController extends Controller { @Patch('items/{id}') public async updatePoster( id: number, - @Body() body: UpdatePosterParams, + @Body() body: UpdatePosterRequest, ): Promise { const poster = await this.service.updateLocalPoster(id, body); return this.service.toResponse(poster); diff --git a/src/modules/handlers/screen/poster/local/local-poster-service.ts b/src/modules/handlers/screen/poster/local/local-poster-service.ts index 88e7c044..e8dd56bb 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-service.ts +++ b/src/modules/handlers/screen/poster/local/local-poster-service.ts @@ -17,23 +17,23 @@ interface BasePosterParams { borrelMode?: boolean; } -export interface MediaPosterParams extends BasePosterParams { +export interface MediaPosterRequest extends BasePosterParams { type: PosterType.IMAGE | PosterType.VIDEO; } -export interface UrlPosterParams extends BasePosterParams { +export interface ExternalPosterRequest extends BasePosterParams { type: PosterType.EXTERNAL; uri: string; } -export interface PhotoPosterParams extends BasePosterParams { +export interface PhotoPosterRequest extends BasePosterParams { type: PosterType.PHOTO; albums: number[]; } -export type CreatePosterParams = MediaPosterParams | UrlPosterParams | PhotoPosterParams; +export type CreatePosterRequest = MediaPosterRequest | ExternalPosterRequest | PhotoPosterRequest; -export interface UpdatePosterParams { +export interface UpdatePosterRequest { name?: string; expirationDate?: Date; accentColor?: string; @@ -89,7 +89,7 @@ export default class LocalPosterService { * This does not yet contain the actual image or video of the poster. * @param params Metadata of the poster as specified in the MediaPosterParams interface. */ - public async createMediaPoster(params: MediaPosterParams): Promise { + public async createMediaPoster(params: MediaPosterRequest): Promise { const { name, type, expirationDate, accentColor, footerSize, defaultTimeout, borrelMode } = params; return this.repo.save({ @@ -127,7 +127,7 @@ export default class LocalPosterService { * Creates a new Local Poster of the url type. * @param params The specifics of the poster as specified in the UrlPosterParams interface. */ - public async createUrlPoster(params: UrlPosterParams): Promise { + public async createExternalPoster(params: ExternalPosterRequest): Promise { const { name, type, expirationDate, accentColor, footerSize, defaultTimeout, borrelMode, uri } = params; return this.repo.save({ @@ -146,7 +146,7 @@ export default class LocalPosterService { * Creates a new Local Poster of the photo type. * @param params The specifics of the poster as specified in the PhotoPosterParams interface. */ - public async createPhotoPoster(params: PhotoPosterParams): Promise { + public async createPhotoPoster(params: PhotoPosterRequest): Promise { const { name, type, @@ -187,7 +187,7 @@ export default class LocalPosterService { * @param id The id of the poster to be updated. * @param params The fields of the poster to be updated as specified in UpdatePosterParams. */ - public async updateLocalPoster(id: number, params: UpdatePosterParams): Promise { + public async updateLocalPoster(id: number, params: UpdatePosterRequest): Promise { const poster = await this.getSingleLocalPoster(id); Object.assign(poster, params); return this.repo.save(poster); From d8568929567ca9578c7588b4719b26a61bf0e06f Mon Sep 17 00:00:00 2001 From: Wanderer Date: Thu, 16 Apr 2026 14:29:59 +0200 Subject: [PATCH 13/58] fix(poster): fixed incorrect handling local poster types in controller --- .../handlers/screen/poster/local/local-poster-controller.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/modules/handlers/screen/poster/local/local-poster-controller.ts b/src/modules/handlers/screen/poster/local/local-poster-controller.ts index bc4c1a36..47139fba 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-controller.ts +++ b/src/modules/handlers/screen/poster/local/local-poster-controller.ts @@ -52,7 +52,8 @@ export class LocalPosterController extends Controller { ): Promise { let poster: LocalPoster; switch (body.type) { - case PosterType.VIDEO || PosterType.IMAGE: + case PosterType.IMAGE: + case PosterType.VIDEO: poster = await this.service.createMediaPoster(body); break; case PosterType.EXTERNAL: @@ -86,7 +87,7 @@ export class LocalPosterController extends Controller { >, ): Promise { const mimeType = lookup(file.originalname); - if (!mimeType || !(mimeType.startsWith('image/') || mimeType.startsWith('video'))) { + if (!mimeType || !(mimeType.startsWith('image/') || mimeType.startsWith('video/'))) { return invalidFileTypeResponse( HttpStatusCode.UnsupportedMediaType, 'Invalid file type, expected an image or a video.', From f5fedbd16d8ce8b0a1d04bd731cb8caa5a2d6e30 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Thu, 16 Apr 2026 15:55:57 +0200 Subject: [PATCH 14/58] feat(poster): removed notion of enabled We wanted to put enabled as part of the carousel system right? there is no global notion of enabled or not. --- src/modules/handlers/screen/poster/local/local-poster.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/modules/handlers/screen/poster/local/local-poster.ts b/src/modules/handlers/screen/poster/local/local-poster.ts index fa46af4e..c4c46fc2 100644 --- a/src/modules/handlers/screen/poster/local/local-poster.ts +++ b/src/modules/handlers/screen/poster/local/local-poster.ts @@ -20,9 +20,6 @@ export default class LocalPoster extends BaseEntity { @Column({ nullable: true }) accentColor?: string; - @Column({ default: true }) - active: boolean; - @Column({ default: false }) protected: boolean; From 3c2f22104377ea45e5a017ccae482170fc743cfd Mon Sep 17 00:00:00 2001 From: Wanderer Date: Thu, 16 Apr 2026 15:57:33 +0200 Subject: [PATCH 15/58] feat(poster): Added response for CRUD requests Basic CRUD done! --- .../poster/local/local-poster-service.ts | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/modules/handlers/screen/poster/local/local-poster-service.ts b/src/modules/handlers/screen/poster/local/local-poster-service.ts index e8dd56bb..d0060020 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-service.ts +++ b/src/modules/handlers/screen/poster/local/local-poster-service.ts @@ -7,6 +7,7 @@ import dataSource from '../../../../../database'; import { HttpApiException } from '../../../../../helpers/custom-error'; import { HttpStatusCode } from 'axios'; import { FooterSize, PosterType } from '../poster'; +import FileResponse from '../../../../files/entities/file-response'; interface BasePosterParams { name: string; @@ -42,7 +43,21 @@ export interface UpdatePosterRequest { borrelMode?: boolean; } -export interface LocalPosterResponse {} +export interface LocalPosterResponse { + id: number; + name: string; + createdAt: string; + updatedAt: string; + expirationDate?: Date; + accentColor?: string; + footerSize: FooterSize; + defaultTimeout: number; + borrelMode: boolean; + protected: boolean; + uri?: string; + albums?: number[]; + file?: FileResponse; +} export default class LocalPosterService { private storage: FileStorage; @@ -62,7 +77,32 @@ export default class LocalPosterService { * @param poster The poster to be converted. */ public toResponse(poster: LocalPoster): LocalPosterResponse { - return {} as LocalPosterResponse; + let file: LocalPosterResponse['file']; + if (poster.file) { + const location = this.storage.getPublicFileUri(poster.file); + if (location) { + file = { + location, + name: poster.file.originalName, + }; + } + } + + return { + id: poster.id, + name: poster.name, + createdAt: poster.createdAt.toISOString(), + updatedAt: poster.updatedAt.toISOString(), + expirationDate: poster.expirationDate ?? undefined, + accentColor: poster.accentColor ?? undefined, + footerSize: poster.footerSize, + defaultTimeout: poster.defaultTimeout, + borrelMode: poster.borrelMode, + protected: poster.protected, + uri: poster.uri ?? undefined, + albums: poster.albums ?? undefined, + file: file, + }; } /** From 7139afba89f19011c4e80a7bf3d55f3c16f4aeec Mon Sep 17 00:00:00 2001 From: Wanderer Date: Sat, 18 Apr 2026 17:25:16 +0200 Subject: [PATCH 16/58] fix: added poster type to response --- .../handlers/screen/poster/local/local-poster-service.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/modules/handlers/screen/poster/local/local-poster-service.ts b/src/modules/handlers/screen/poster/local/local-poster-service.ts index d0060020..00b8431b 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-service.ts +++ b/src/modules/handlers/screen/poster/local/local-poster-service.ts @@ -46,6 +46,7 @@ export interface UpdatePosterRequest { export interface LocalPosterResponse { id: number; name: string; + type: PosterType; createdAt: string; updatedAt: string; expirationDate?: Date; @@ -91,6 +92,7 @@ export default class LocalPosterService { return { id: poster.id, name: poster.name, + type: poster.type, createdAt: poster.createdAt.toISOString(), updatedAt: poster.updatedAt.toISOString(), expirationDate: poster.expirationDate ?? undefined, From e5721e2509a8104e217ebc26f9eaaec6587db2ad Mon Sep 17 00:00:00 2001 From: Wanderer Date: Mon, 20 Apr 2026 21:56:09 +0200 Subject: [PATCH 17/58] feat(poster): added label to LocalPoster entity --- .../screen/poster/local/local-poster-service.ts | 12 ++++++++++-- .../handlers/screen/poster/local/local-poster.ts | 3 +++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/modules/handlers/screen/poster/local/local-poster-service.ts b/src/modules/handlers/screen/poster/local/local-poster-service.ts index 00b8431b..64a26fde 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-service.ts +++ b/src/modules/handlers/screen/poster/local/local-poster-service.ts @@ -11,6 +11,7 @@ import FileResponse from '../../../../files/entities/file-response'; interface BasePosterParams { name: string; + label?: string; expirationDate?: Date; accentColor?: string; footerSize?: FooterSize; @@ -36,6 +37,7 @@ export type CreatePosterRequest = MediaPosterRequest | ExternalPosterRequest | P export interface UpdatePosterRequest { name?: string; + label?: string; expirationDate?: Date; accentColor?: string; footerSize?: FooterSize; @@ -46,6 +48,7 @@ export interface UpdatePosterRequest { export interface LocalPosterResponse { id: number; name: string; + label?: string; type: PosterType; createdAt: string; updatedAt: string; @@ -92,6 +95,7 @@ export default class LocalPosterService { return { id: poster.id, name: poster.name, + label: poster.label ?? undefined, type: poster.type, createdAt: poster.createdAt.toISOString(), updatedAt: poster.updatedAt.toISOString(), @@ -132,10 +136,11 @@ export default class LocalPosterService { * @param params Metadata of the poster as specified in the MediaPosterParams interface. */ public async createMediaPoster(params: MediaPosterRequest): Promise { - const { name, type, expirationDate, accentColor, footerSize, defaultTimeout, borrelMode } = + const { name, label, type, expirationDate, accentColor, footerSize, defaultTimeout, borrelMode } = params; return this.repo.save({ name, + label, type, expirationDate, accentColor, @@ -170,10 +175,11 @@ export default class LocalPosterService { * @param params The specifics of the poster as specified in the UrlPosterParams interface. */ public async createExternalPoster(params: ExternalPosterRequest): Promise { - const { name, type, expirationDate, accentColor, footerSize, defaultTimeout, borrelMode, uri } = + const { name, label, type, expirationDate, accentColor, footerSize, defaultTimeout, borrelMode, uri } = params; return this.repo.save({ name, + label, type, expirationDate, accentColor, @@ -191,6 +197,7 @@ export default class LocalPosterService { public async createPhotoPoster(params: PhotoPosterRequest): Promise { const { name, + label, type, expirationDate, accentColor, @@ -201,6 +208,7 @@ export default class LocalPosterService { } = params; return this.repo.save({ name, + label, type, expirationDate, accentColor, diff --git a/src/modules/handlers/screen/poster/local/local-poster.ts b/src/modules/handlers/screen/poster/local/local-poster.ts index c4c46fc2..ba05e03b 100644 --- a/src/modules/handlers/screen/poster/local/local-poster.ts +++ b/src/modules/handlers/screen/poster/local/local-poster.ts @@ -14,6 +14,9 @@ export default class LocalPoster extends BaseEntity { }) type: PosterType; + @Column({ nullable: true }) + label?: string; + @Column({ nullable: true }) expirationDate?: Date; From 8aed43897da91462203d50c96e4ffd107bed31b8 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 22 Apr 2026 13:49:59 +0200 Subject: [PATCH 18/58] feat(poster): Moved poster-handlers to LocalPoster model This change does break the client as it still expects the Poster model --- .../poster/carousel-poster-controller.ts | 31 +++---- .../screen/poster/carousel-poster-handler.ts | 7 +- .../poster/local/local-poster-service.ts | 25 ++++- .../screen/poster/static-poster-handler.ts | 8 +- .../poster/static/static-poster-controller.ts | 76 +--------------- .../poster/static/static-poster-service.ts | 91 ------------------- src/modules/timed-events/cron-manager.ts | 6 +- 7 files changed, 46 insertions(+), 198 deletions(-) delete mode 100644 src/modules/handlers/screen/poster/static/static-poster-service.ts diff --git a/src/modules/handlers/screen/poster/carousel-poster-controller.ts b/src/modules/handlers/screen/poster/carousel-poster-controller.ts index b3c034ac..b78648b6 100644 --- a/src/modules/handlers/screen/poster/carousel-poster-controller.ts +++ b/src/modules/handlers/screen/poster/carousel-poster-controller.ts @@ -13,6 +13,8 @@ import { FeatureEnabled, ServerSettingsStore } from '../../../server-settings'; import { Controller } from '@tsoa/runtime'; import { Poster } from './poster'; import { ISettings } from '../../../server-settings/server-setting'; +import { LocalPosterResponse } from './local/local-poster-service'; +import LocalPoster from './local/local-poster'; export interface BorrelModeParams { enabled: boolean; @@ -23,7 +25,7 @@ export interface BorrelModeResponse extends BorrelModeParams { } export interface PosterResponse { - posters: Poster[]; + posters: LocalPosterResponse[]; borrelMode: boolean; } @@ -43,33 +45,22 @@ export class CarouselPosterController extends Controller { @Security(SecurityNames.LOCAL, securityGroups.poster.base) @Get('') public async getPosters(@Query() alwaysReturnBorrelPosters?: boolean): Promise { - if (!this.screenHandler.posterManager.posters) { - try { - await this.screenHandler.posterManager.fetchPosters(); - } catch (e) { - logger.error(e); - } - } - const posters = this.screenHandler.posterManager.posters ?? []; - - if (alwaysReturnBorrelPosters || this.screenHandler.borrelMode) { - return { - posters: posters, - borrelMode: this.screenHandler.borrelMode, - }; - } + const posters = await this.screenHandler.posterService.getAllLocalPosters(); + const visible = + alwaysReturnBorrelPosters || this.screenHandler.borrelMode + ? posters + : posters.filter((p) => !p.borrelMode); return { - posters: posters.filter((p) => !p.borrelMode), - borrelMode: false, + posters: visible.map((p) => this.screenHandler.posterService.toResponse(p)), + borrelMode: this.screenHandler.borrelMode, }; } @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) @Post('force-update') public async forceUpdatePosters(@Request() req: ExpressRequest): Promise { - logger.audit(req.user, 'Force fetch posters from source.'); - await this.screenHandler.posterManager.fetchPosters(); + logger.audit(req.user, 'Force refresh carousel on screens.'); this.screenHandler.forceUpdate(); } diff --git a/src/modules/handlers/screen/poster/carousel-poster-handler.ts b/src/modules/handlers/screen/poster/carousel-poster-handler.ts index 5f990579..322c3330 100644 --- a/src/modules/handlers/screen/poster/carousel-poster-handler.ts +++ b/src/modules/handlers/screen/poster/carousel-poster-handler.ts @@ -1,14 +1,13 @@ import BaseScreenHandler from '../../base-screen-handler'; import { Namespace } from 'socket.io'; import { FeatureEnabled, ServerSettingsStore } from '../../../server-settings'; -import { TrelloPosterManager } from './trello/trello-poster-manager'; import { TrackChangeEvent } from '../../../events/music-emitter-events'; -import { PosterManager } from './poster-manager'; import { ISettings } from '../../../server-settings/server-setting'; +import LocalPosterService from './local/local-poster-service'; @FeatureEnabled('Poster') export default class CarouselPosterHandler extends BaseScreenHandler { - public posterManager: PosterManager; + public posterService: LocalPosterService; private borrelModeDay: number | undefined; @@ -21,7 +20,7 @@ export default class CarouselPosterHandler extends BaseScreenHandler { // Check whether we need to enable/disable borrel mode this.borrelModeInterval = setInterval(this.checkBorrelMode.bind(this), 60 * 1000); } - this.posterManager = new TrelloPosterManager(); + this.posterService = new LocalPosterService(); } forceUpdate(): void { diff --git a/src/modules/handlers/screen/poster/local/local-poster-service.ts b/src/modules/handlers/screen/poster/local/local-poster-service.ts index 64a26fde..d9c63e8f 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-service.ts +++ b/src/modules/handlers/screen/poster/local/local-poster-service.ts @@ -136,8 +136,16 @@ export default class LocalPosterService { * @param params Metadata of the poster as specified in the MediaPosterParams interface. */ public async createMediaPoster(params: MediaPosterRequest): Promise { - const { name, label, type, expirationDate, accentColor, footerSize, defaultTimeout, borrelMode } = - params; + const { + name, + label, + type, + expirationDate, + accentColor, + footerSize, + defaultTimeout, + borrelMode, + } = params; return this.repo.save({ name, label, @@ -175,8 +183,17 @@ export default class LocalPosterService { * @param params The specifics of the poster as specified in the UrlPosterParams interface. */ public async createExternalPoster(params: ExternalPosterRequest): Promise { - const { name, label, type, expirationDate, accentColor, footerSize, defaultTimeout, borrelMode, uri } = - params; + const { + name, + label, + type, + expirationDate, + accentColor, + footerSize, + defaultTimeout, + borrelMode, + uri, + } = params; return this.repo.save({ name, label, diff --git a/src/modules/handlers/screen/poster/static-poster-handler.ts b/src/modules/handlers/screen/poster/static-poster-handler.ts index d6427fda..5226b1c5 100644 --- a/src/modules/handlers/screen/poster/static-poster-handler.ts +++ b/src/modules/handlers/screen/poster/static-poster-handler.ts @@ -1,19 +1,19 @@ import BaseScreenHandler from '../../base-screen-handler'; import { TrackChangeEvent } from '../../../events/music-emitter-events'; -import { StaticPosterResponse } from './static/static-poster-service'; import { FeatureEnabled } from '../../../server-settings'; +import { LocalPosterResponse } from './local/local-poster-service'; const UPDATE_POSTER_EVENT_NAME = 'update_static_poster'; const DEFAULT_CLOCK_VISIBLE = true; export interface StaticPosterHandlerState { - activePoster: StaticPosterResponse | null; + activePoster: LocalPosterResponse | null; clockVisible: boolean; } @FeatureEnabled('Poster') export default class StaticPosterHandler extends BaseScreenHandler { - private activePoster: StaticPosterResponse | null; + private activePoster: LocalPosterResponse | null; private clockVisible = DEFAULT_CLOCK_VISIBLE; @@ -28,7 +28,7 @@ export default class StaticPosterHandler extends BaseScreenHandler { * Change the currently active poster * @param poster */ - setActivePoster(poster: StaticPosterResponse): void { + setActivePoster(poster: LocalPosterResponse): void { this.activePoster = poster; this.sendEvent(UPDATE_POSTER_EVENT_NAME, this.getState()); } diff --git a/src/modules/handlers/screen/poster/static/static-poster-controller.ts b/src/modules/handlers/screen/poster/static/static-poster-controller.ts index 03c2a92f..15bfe463 100644 --- a/src/modules/handlers/screen/poster/static/static-poster-controller.ts +++ b/src/modules/handlers/screen/poster/static/static-poster-controller.ts @@ -5,12 +5,10 @@ import HandlerManager from '../../../../root/handler-manager'; import { Screen } from '../../../../root/entities'; import { SecurityNames } from '../../../../../helpers/security'; import { securityGroups } from '../../../../../helpers/security-groups'; -import StaticPosterService, { StaticPosterResponse } from './static-poster-service'; import { Request as ExpressRequest } from 'express'; import logger from '../../../../../logger'; -import { HttpStatusCode } from 'axios'; import { StaticPosterHandlerState } from '../static-poster-handler'; -import { lookup } from 'mime-types'; +import LocalPosterService from '../local/local-poster-service'; interface SetClockRequest { visible: boolean; @@ -50,7 +48,7 @@ export class StaticPosterController extends Controller { } /** - * Chang the visibility of the clock on-screen + * Change the visibility of the clock on-screen */ @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) @Post('clock') @@ -68,72 +66,6 @@ export class StaticPosterController extends Controller { this.screenHandler.setClockVisibility(visible); } - /** - * Get all static posters from the database. - */ - @Security(SecurityNames.LOCAL, securityGroups.poster.base) - @Get('items') - public async getAllStaticPosters(): Promise { - const service = new StaticPosterService(); - const posters = await service.getAllStaticPosters(); - return posters.map((p) => service.toResponse(p)); - } - - /** - * Create a new static poster based on a file (image or video). - * @param file - * @param invalidFileTypeResponse - */ - @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) - @Post('items/file') - public async createStaticPosterFile( - @UploadedFile() file: Express.Multer.File, - @Res() - invalidFileTypeResponse: TsoaResponse< - HttpStatusCode.BadRequest, - 'Invalid file type, expected an image or a video.' - >, - ): Promise { - const mimeType = lookup(file.originalname); - if (!mimeType || !(mimeType.startsWith('image/') || mimeType.startsWith('video'))) { - return invalidFileTypeResponse( - HttpStatusCode.BadRequest, - 'Invalid file type, expected an image or a video.', - ); - } - - const service = new StaticPosterService(); - const poster = await service.createStaticPoster({ - file: { name: file.originalname, data: file.buffer }, - }); - return service.toResponse(poster); - } - - /** - * Create a new static poster based on a URL - * @param body - */ - @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) - @Post('items/url') - public async createStaticPosterUrl(@Body() body: { url: string }): Promise { - const service = new StaticPosterService(); - const poster = await service.createStaticPoster({ - uri: body.url, - }); - return service.toResponse(poster); - } - - /** - * Permanently delete a static poster. - * @param id - */ - @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) - @Delete('items/{id}') - public async deleteStaticPoster(id: number): Promise { - const service = new StaticPosterService(); - await service.deleteStaticPoster(id); - } - /** * Show the given static poster on all screens using the StaticPosterHandler. * @param id @@ -142,8 +74,8 @@ export class StaticPosterController extends Controller { @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) @Post('items/{id}/show') public async showStaticPoster(id: number, @Request() req: ExpressRequest): Promise { - const service = new StaticPosterService(); - const poster = await service.getSingleStaticPoster(id); + const service = new LocalPosterService(); + const poster = await service.getSingleLocalPoster(id); const posterResponse = service.toResponse(poster); logger.audit(req.user, `Show static poster (id: ${id}).`); diff --git a/src/modules/handlers/screen/poster/static/static-poster-service.ts b/src/modules/handlers/screen/poster/static/static-poster-service.ts deleted file mode 100644 index f45941f6..00000000 --- a/src/modules/handlers/screen/poster/static/static-poster-service.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { FileStorage } from '../../../../files/storage/file-storage'; -import { DiskStorage } from '../../../../files/storage'; -import StaticPoster from './static-poster'; -import { Repository } from 'typeorm'; -import dataSource from '../../../../../database'; -import { HttpApiException } from '../../../../../helpers/custom-error'; -import { HttpStatusCode } from 'axios'; -import { File } from '../../../../files/entities'; -import FileResponse from '../../../../files/entities/file-response'; - -export interface StaticPosterResponse { - id: number; - createdAt: string; - updatedAt: string; - file?: FileResponse; - uri?: string; -} - -export interface StaticPosterRequest { - file?: { - name: string; - data: Buffer; - }; - uri?: string; -} - -export default class StaticPosterService { - private storage: FileStorage; - - private repo: Repository; - - private fileRepo: Repository; - - constructor() { - this.storage = new DiskStorage('local-posters'); - this.repo = dataSource.getRepository(StaticPoster); - this.fileRepo = dataSource.getRepository(File); - } - - public toResponse(poster: StaticPoster): StaticPosterResponse { - let file: StaticPosterResponse['file']; - if (poster.file) { - const location = this.storage.getPublicFileUri(poster.file); - if (location) { - file = { - location, - name: poster.file.originalName, - }; - } - } - - return { - id: poster.id, - createdAt: poster.createdAt.toISOString(), - updatedAt: poster.updatedAt.toISOString(), - file, - uri: poster.uri ?? undefined, - }; - } - - public async getAllStaticPosters(): Promise { - return this.repo.find(); - } - - public async getSingleStaticPoster(id: number): Promise { - const poster = await this.repo.findOne({ where: { id } }); - if (poster == null) { - throw new HttpApiException(HttpStatusCode.NotFound, `Poster with ID "${id}" not found.`); - } - return poster; - } - - public async createStaticPoster(poster: StaticPosterRequest): Promise { - let file: File | undefined; - if (poster.file) { - const fileParams = await this.storage.saveFile(poster.file.name, poster.file.data); - file = await this.fileRepo.save(fileParams); - } - - return this.repo.save({ file, uri: poster.uri }); - } - - public async deleteStaticPoster(id: number): Promise { - const poster = await this.getSingleStaticPoster(id); - if (poster.file) { - await this.fileRepo.delete(poster.file.id); - await this.storage.deleteFile(poster.file); - } - await this.repo.remove(poster); - } -} diff --git a/src/modules/timed-events/cron-manager.ts b/src/modules/timed-events/cron-manager.ts index cb4516eb..b35e1b28 100644 --- a/src/modules/timed-events/cron-manager.ts +++ b/src/modules/timed-events/cron-manager.ts @@ -7,9 +7,9 @@ import HandlerManager from '../root/handler-manager'; import RootAudioService from '../root/root-audio-service'; import RootLightsService from '../root/root-lights-service'; import RootScreenService from '../root/root-screen-service'; -import StaticPosterService from '../handlers/screen/poster/static/static-poster-service'; import { Screen } from '../root/entities'; import { StaticPosterHandler } from '../handlers/screen'; +import LocalPosterService from '../handlers/screen/poster/local/local-poster-service'; export class CronExpressionError extends Error {} @@ -157,8 +157,8 @@ export default class CronManager { return; case 'timed-event-set-static-poster': { - const service = new StaticPosterService(); - const poster = await service.getSingleStaticPoster(spec.params.posterId); + const service = new LocalPosterService(); + const poster = await service.getSingleLocalPoster(spec.params.posterId); const handler = HandlerManager.getInstance() .getHandlers(Screen) .filter( From 13b499ed9d3c43c699f935aa1bf144d2ca626ff8 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Thu, 23 Apr 2026 14:09:29 +0200 Subject: [PATCH 19/58] feat(poster): added enabled field to LocalPoster Entitiy --- .../screen/poster/carousel-poster-controller.ts | 11 +++++++++++ .../screen/poster/local/local-poster-service.ts | 13 +++++++++++++ .../handlers/screen/poster/local/local-poster.ts | 3 +++ .../poster/static/static-poster-controller.ts | 4 ++-- 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/modules/handlers/screen/poster/carousel-poster-controller.ts b/src/modules/handlers/screen/poster/carousel-poster-controller.ts index b78648b6..6fa9d581 100644 --- a/src/modules/handlers/screen/poster/carousel-poster-controller.ts +++ b/src/modules/handlers/screen/poster/carousel-poster-controller.ts @@ -24,6 +24,10 @@ export interface BorrelModeResponse extends BorrelModeParams { present: boolean; } +export interface EnabledParams { + enabled: boolean; +} + export interface PosterResponse { posters: LocalPosterResponse[]; borrelMode: boolean; @@ -87,6 +91,13 @@ export class CarouselPosterController extends Controller { this.screenHandler.setBorrelModeEnabled(body.enabled); } + @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) + @Post('{id}/enabled') + public async togglePoster(id: number, @Body() body: EnabledParams): Promise { + const poster = await this.screenHandler.posterService.togglePosterEnable(id, body.enabled); + return this.screenHandler.posterService.toResponse(poster); + } + @Security(SecurityNames.LOCAL, securityGroups.poster.base) @Get('train-departures') public async getTrains(): Promise { diff --git a/src/modules/handlers/screen/poster/local/local-poster-service.ts b/src/modules/handlers/screen/poster/local/local-poster-service.ts index d9c63e8f..64210425 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-service.ts +++ b/src/modules/handlers/screen/poster/local/local-poster-service.ts @@ -50,6 +50,7 @@ export interface LocalPosterResponse { name: string; label?: string; type: PosterType; + enabled: boolean; createdAt: string; updatedAt: string; expirationDate?: Date; @@ -97,6 +98,7 @@ export default class LocalPosterService { name: poster.name, label: poster.label ?? undefined, type: poster.type, + enabled: poster.enabled, createdAt: poster.createdAt.toISOString(), updatedAt: poster.updatedAt.toISOString(), expirationDate: poster.expirationDate ?? undefined, @@ -259,4 +261,15 @@ export default class LocalPosterService { Object.assign(poster, params); return this.repo.save(poster); } + + /** + * Changes the enabled status of the given poster. + * @param id The id of the poster to enable/disable. + * @param enabled The state to put the poster in. + */ + public async togglePosterEnable(id: number, enabled: boolean): Promise { + const poster = await this.getSingleLocalPoster(id); + poster.enabled = enabled; + return this.repo.save(poster); + } } diff --git a/src/modules/handlers/screen/poster/local/local-poster.ts b/src/modules/handlers/screen/poster/local/local-poster.ts index ba05e03b..d6cd6469 100644 --- a/src/modules/handlers/screen/poster/local/local-poster.ts +++ b/src/modules/handlers/screen/poster/local/local-poster.ts @@ -14,6 +14,9 @@ export default class LocalPoster extends BaseEntity { }) type: PosterType; + @Column({ default: true }) + enabled: boolean; + @Column({ nullable: true }) label?: string; diff --git a/src/modules/handlers/screen/poster/static/static-poster-controller.ts b/src/modules/handlers/screen/poster/static/static-poster-controller.ts index 15bfe463..389abdd6 100644 --- a/src/modules/handlers/screen/poster/static/static-poster-controller.ts +++ b/src/modules/handlers/screen/poster/static/static-poster-controller.ts @@ -1,5 +1,5 @@ -import { Controller, TsoaResponse, UploadedFile } from '@tsoa/runtime'; -import { Body, Delete, Get, Post, Request, Res, Route, Security, Tags } from 'tsoa'; +import { Controller } from '@tsoa/runtime'; +import { Body, Delete, Get, Post, Request, Route, Security, Tags } from 'tsoa'; import { StaticPosterHandler } from '../../index'; import HandlerManager from '../../../../root/handler-manager'; import { Screen } from '../../../../root/entities'; From d9efe3c8ddc131421b6f4695e61c9f685f8c33e7 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Thu, 23 Apr 2026 14:56:24 +0200 Subject: [PATCH 20/58] fix: renamed toggleposter to keep naming scheme intact --- .../handlers/screen/poster/carousel-poster-controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/handlers/screen/poster/carousel-poster-controller.ts b/src/modules/handlers/screen/poster/carousel-poster-controller.ts index 6fa9d581..5a3f66cd 100644 --- a/src/modules/handlers/screen/poster/carousel-poster-controller.ts +++ b/src/modules/handlers/screen/poster/carousel-poster-controller.ts @@ -93,7 +93,7 @@ export class CarouselPosterController extends Controller { @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) @Post('{id}/enabled') - public async togglePoster(id: number, @Body() body: EnabledParams): Promise { + public async togglePosterEnable(id: number, @Body() body: EnabledParams): Promise { const poster = await this.screenHandler.posterService.togglePosterEnable(id, body.enabled); return this.screenHandler.posterService.toResponse(poster); } From 506d6edd08de71b70a017f41583a03493d5b059f Mon Sep 17 00:00:00 2001 From: Wanderer Date: Fri, 24 Apr 2026 18:33:16 +0200 Subject: [PATCH 21/58] feat(poster): filter by enabled and due date --- .../poster/carousel-poster-controller.ts | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/modules/handlers/screen/poster/carousel-poster-controller.ts b/src/modules/handlers/screen/poster/carousel-poster-controller.ts index 5a3f66cd..60e4d888 100644 --- a/src/modules/handlers/screen/poster/carousel-poster-controller.ts +++ b/src/modules/handlers/screen/poster/carousel-poster-controller.ts @@ -48,12 +48,19 @@ export class CarouselPosterController extends Controller { @Security(SecurityNames.LOCAL, securityGroups.poster.base) @Get('') - public async getPosters(@Query() alwaysReturnBorrelPosters?: boolean): Promise { + public async getPosters(@Query() includeHidden?: boolean): Promise { const posters = await this.screenHandler.posterService.getAllLocalPosters(); - const visible = - alwaysReturnBorrelPosters || this.screenHandler.borrelMode - ? posters - : posters.filter((p) => !p.borrelMode); + + let visible: LocalPoster[]; + if (includeHidden) { + visible = posters; + } else { + const now = new Date(); + const active = posters.filter( + (p) => p.enabled && (p.expirationDate == null || p.expirationDate > now), + ); + visible = this.screenHandler.borrelMode ? active : active.filter((p) => !p.borrelMode); + } return { posters: visible.map((p) => this.screenHandler.posterService.toResponse(p)), @@ -93,7 +100,10 @@ export class CarouselPosterController extends Controller { @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) @Post('{id}/enabled') - public async togglePosterEnable(id: number, @Body() body: EnabledParams): Promise { + public async togglePosterEnable( + id: number, + @Body() body: EnabledParams, + ): Promise { const poster = await this.screenHandler.posterService.togglePosterEnable(id, body.enabled); return this.screenHandler.posterService.toResponse(poster); } From 29ca95b0c08d985cf884cb71f52c1cdb4b3dc1be Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 29 Apr 2026 14:11:27 +0200 Subject: [PATCH 22/58] feat(poster): added featureflag to postercontroller --- .../handlers/screen/poster/local/local-poster-controller.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/modules/handlers/screen/poster/local/local-poster-controller.ts b/src/modules/handlers/screen/poster/local/local-poster-controller.ts index 47139fba..da39b420 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-controller.ts +++ b/src/modules/handlers/screen/poster/local/local-poster-controller.ts @@ -11,9 +11,11 @@ import LocalPosterService, { import LocalPoster from './local-poster'; import { lookup } from 'mime-types'; import { PosterType } from '../poster'; +import { FeatureEnabled } from '../../../../server-settings'; @Route('/handler/screen/poster') @Tags('Handlers') +@FeatureEnabled('Poster') export class LocalPosterController extends Controller { private service = new LocalPosterService(); From 439aa67a60adb572ccf6d7612bc555ba0fa1b2c1 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 29 Apr 2026 15:58:13 +0200 Subject: [PATCH 23/58] feat(poster): added start date to local poster entity and implemented it --- .../screen/poster/carousel-poster-controller.ts | 5 ++++- .../screen/poster/local/local-poster-service.ts | 10 ++++++++++ .../handlers/screen/poster/local/local-poster.ts | 3 +++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/modules/handlers/screen/poster/carousel-poster-controller.ts b/src/modules/handlers/screen/poster/carousel-poster-controller.ts index 60e4d888..e3cd24a5 100644 --- a/src/modules/handlers/screen/poster/carousel-poster-controller.ts +++ b/src/modules/handlers/screen/poster/carousel-poster-controller.ts @@ -57,7 +57,10 @@ export class CarouselPosterController extends Controller { } else { const now = new Date(); const active = posters.filter( - (p) => p.enabled && (p.expirationDate == null || p.expirationDate > now), + (p) => + p.enabled && + (p.startDate == null || p.startDate <= now) && + (p.expirationDate == null || p.expirationDate > now), ); visible = this.screenHandler.borrelMode ? active : active.filter((p) => !p.borrelMode); } diff --git a/src/modules/handlers/screen/poster/local/local-poster-service.ts b/src/modules/handlers/screen/poster/local/local-poster-service.ts index 64210425..97938309 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-service.ts +++ b/src/modules/handlers/screen/poster/local/local-poster-service.ts @@ -12,6 +12,7 @@ import FileResponse from '../../../../files/entities/file-response'; interface BasePosterParams { name: string; label?: string; + startDate?: Date; expirationDate?: Date; accentColor?: string; footerSize?: FooterSize; @@ -38,6 +39,7 @@ export type CreatePosterRequest = MediaPosterRequest | ExternalPosterRequest | P export interface UpdatePosterRequest { name?: string; label?: string; + startDate?: Date; expirationDate?: Date; accentColor?: string; footerSize?: FooterSize; @@ -53,6 +55,7 @@ export interface LocalPosterResponse { enabled: boolean; createdAt: string; updatedAt: string; + startDate?: Date; expirationDate?: Date; accentColor?: string; footerSize: FooterSize; @@ -101,6 +104,7 @@ export default class LocalPosterService { enabled: poster.enabled, createdAt: poster.createdAt.toISOString(), updatedAt: poster.updatedAt.toISOString(), + startDate: poster.startDate ?? undefined, expirationDate: poster.expirationDate ?? undefined, accentColor: poster.accentColor ?? undefined, footerSize: poster.footerSize, @@ -142,6 +146,7 @@ export default class LocalPosterService { name, label, type, + startDate, expirationDate, accentColor, footerSize, @@ -152,6 +157,7 @@ export default class LocalPosterService { name, label, type, + startDate, expirationDate, accentColor, footerSize, @@ -189,6 +195,7 @@ export default class LocalPosterService { name, label, type, + startDate, expirationDate, accentColor, footerSize, @@ -200,6 +207,7 @@ export default class LocalPosterService { name, label, type, + startDate, expirationDate, accentColor, footerSize, @@ -218,6 +226,7 @@ export default class LocalPosterService { name, label, type, + startDate, expirationDate, accentColor, footerSize, @@ -229,6 +238,7 @@ export default class LocalPosterService { name, label, type, + startDate, expirationDate, accentColor, footerSize, diff --git a/src/modules/handlers/screen/poster/local/local-poster.ts b/src/modules/handlers/screen/poster/local/local-poster.ts index d6cd6469..6cc53cd9 100644 --- a/src/modules/handlers/screen/poster/local/local-poster.ts +++ b/src/modules/handlers/screen/poster/local/local-poster.ts @@ -20,6 +20,9 @@ export default class LocalPoster extends BaseEntity { @Column({ nullable: true }) label?: string; + @Column({ nullable: true }) + startDate?: Date; + @Column({ nullable: true }) expirationDate?: Date; From 32c865606bcce793684dec60c2b64fa41a159136 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Thu, 7 May 2026 16:22:13 +0200 Subject: [PATCH 24/58] feat(migration): implemented trello migration Now deletes old and fetches new trello posters every 15 minutes. TODO: add button maybe --- src/index.ts | 7 + .../poster/local/local-poster-service.ts | 15 ++ .../screen/poster/local/local-poster.ts | 3 + .../handlers/screen/poster/poster-manager.ts | 11 -- src/modules/handlers/screen/poster/poster.ts | 1 - .../poster/trello/trello-poster-manager.ts | 167 ++++++++---------- .../poster/trello/trello-poster-storage.ts | 53 ------ 7 files changed, 101 insertions(+), 156 deletions(-) delete mode 100644 src/modules/handlers/screen/poster/poster-manager.ts delete mode 100644 src/modules/handlers/screen/poster/trello/trello-poster-storage.ts diff --git a/src/index.ts b/src/index.ts index 953af145..d3e523f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ import Types from './types'; import { OrderManager } from './modules/orders'; import TimedEventsService from './modules/timed-events/timed-events-service'; import LightsSwitchManager from './modules/root/lights-switch-manager'; +import { TrelloPosterManager } from './modules/handlers/screen/poster/trello/trello-poster-manager'; async function createApp(): Promise { // Fix for production issue where a Docker volume overwrites the contents of a folder instead of merging them @@ -88,6 +89,12 @@ async function createApp(): Promise { await SpotifyTrackHandler.getInstance().init(emitterStore.musicEmitter); } + if (process.env.TRELLO_BOARD_ID && process.env.TRELLO_KEY && process.env.TRELLO_TOKEN) { + logger.info('Initialize Trello posters...'); + const trelloPosterManager = new TrelloPosterManager(); + trelloPosterManager.reloadPosters().catch((e) => logger.error(e)); + } + if (featureFlagManager.flagIsEnabled('Orders')) { OrderManager.getInstance().init(emitterStore.orderEmitter); } diff --git a/src/modules/handlers/screen/poster/local/local-poster-service.ts b/src/modules/handlers/screen/poster/local/local-poster-service.ts index 97938309..e03b3870 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-service.ts +++ b/src/modules/handlers/screen/poster/local/local-poster-service.ts @@ -18,6 +18,7 @@ interface BasePosterParams { footerSize?: FooterSize; defaultTimeout?: number; borrelMode?: boolean; + trello?: boolean; } export interface MediaPosterRequest extends BasePosterParams { @@ -152,6 +153,7 @@ export default class LocalPosterService { footerSize, defaultTimeout, borrelMode, + trello, } = params; return this.repo.save({ name, @@ -163,6 +165,7 @@ export default class LocalPosterService { footerSize, defaultTimeout, borrelMode, + trello, }); } @@ -202,6 +205,7 @@ export default class LocalPosterService { defaultTimeout, borrelMode, uri, + trello, } = params; return this.repo.save({ name, @@ -214,6 +218,7 @@ export default class LocalPosterService { defaultTimeout, borrelMode, uri, + trello, }); } @@ -233,6 +238,7 @@ export default class LocalPosterService { defaultTimeout, borrelMode, albums, + trello, } = params; return this.repo.save({ name, @@ -245,6 +251,7 @@ export default class LocalPosterService { defaultTimeout, borrelMode, albums, + trello, }); } @@ -282,4 +289,12 @@ export default class LocalPosterService { poster.enabled = enabled; return this.repo.save(poster); } + + /** + * Deletes every poster with the trello flag from the database. + */ + public async deleteTrelloPosters(): Promise { + const posters = await this.repo.find({ where: { trello: true } }); + await Promise.all(posters.map((poster) => this.deleteLocalPoster(poster.id))); + } } diff --git a/src/modules/handlers/screen/poster/local/local-poster.ts b/src/modules/handlers/screen/poster/local/local-poster.ts index 6cc53cd9..160c65b2 100644 --- a/src/modules/handlers/screen/poster/local/local-poster.ts +++ b/src/modules/handlers/screen/poster/local/local-poster.ts @@ -54,4 +54,7 @@ export default class LocalPoster extends BaseEntity { @OneToOne(() => File, { nullable: true, eager: true, onDelete: 'SET NULL' }) @JoinColumn() file?: File; + + @Column({ default: false }) + trello: boolean; } diff --git a/src/modules/handlers/screen/poster/poster-manager.ts b/src/modules/handlers/screen/poster/poster-manager.ts deleted file mode 100644 index 83a1ac74..00000000 --- a/src/modules/handlers/screen/poster/poster-manager.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Poster } from './poster'; - -export abstract class PosterManager { - protected _posters: Poster[] | undefined; - - abstract fetchPosters(): Promise; - - public get posters() { - return this._posters; - } -} diff --git a/src/modules/handlers/screen/poster/poster.ts b/src/modules/handlers/screen/poster/poster.ts index 50a146ac..34d316ef 100644 --- a/src/modules/handlers/screen/poster/poster.ts +++ b/src/modules/handlers/screen/poster/poster.ts @@ -22,7 +22,6 @@ export enum FooterSize { } export interface BasePoster { - id: string; name: string; label: string; due?: Date; diff --git a/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts b/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts index 98a829c8..77c52225 100644 --- a/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts +++ b/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts @@ -1,28 +1,18 @@ -import { randomUUID } from 'crypto'; -import { PosterManager } from '../poster-manager'; -import { - BasePoster, - ErrorPoster, - FooterSize, - LocalPoster, - LocalPosterType, - MediaPoster, - Poster, - PosterType, -} from '../poster'; +import { BasePoster, FooterSize, LocalPosterType, Poster, PosterType } from '../poster'; import { Board, Card, Checklist, TrelloClient, TrelloList } from './client'; -import { TrelloPosterStorage } from './trello-poster-storage'; +import LocalPosterService from '../local/local-poster-service'; +import axios from 'axios'; +import LocalPoster from '../local/local-poster'; const DEFAULT_POSTER_TIMEOUT = 15; const DEFAULT_POSTER_REFRESH = 1000 * 60 * 15; -export class TrelloPosterManager extends PosterManager { +export class TrelloPosterManager { private client: TrelloClient; private refreshTimeout: NodeJS.Timeout | undefined = undefined; constructor() { - super(); this.client = new TrelloClient(); } @@ -37,7 +27,7 @@ export class TrelloPosterManager extends PosterManager { list: TrelloList, board: Board, listType?: PosterType, - ): Promise { + ): Promise { const { cards: allCards, lists: allLists, checklists: allChecklists } = board; const cards = allCards?.filter((card) => card.idList === list.id) || []; @@ -79,22 +69,11 @@ export class TrelloPosterManager extends PosterManager { break; } - let type: LocalPosterType = (listType as LocalPosterType) || PosterType.UNKNOWN; - const cardType = card.desc; - if (listType === undefined && Object.values(PosterType).includes(cardType as any)) { - type = cardType as LocalPosterType; - } - - const poster: LocalPoster = { - ...this.parseBasePoster(card, checklists, borrelMode), - type, - }; - - return poster; + return undefined; }), ); - return posters.filter((p) => p !== undefined).flat() as Poster[]; + return posters.filter((p) => p !== undefined).flat() as LocalPoster[]; } /** @@ -126,7 +105,6 @@ export class TrelloPosterManager extends PosterManager { let color = labels.find((l) => l.startsWith('#')); return { - id: card.id ?? randomUUID(), name: card.name || 'Poster', timeout, // If there is a due date present, set the due date @@ -153,29 +131,37 @@ export class TrelloPosterManager extends PosterManager { checklists: Checklist[], type: PosterType.IMAGE | PosterType.VIDEO, borrelMode = false, - ): Promise { + ): Promise { const poster = this.parseBasePoster(card, checklists, borrelMode); if (!card.id) { - return { - ...poster, - type: PosterType.ERROR, - message: 'Card has no ID', - }; + return undefined; } - const attachments = await this.client.default.getCardAttachments(card.id); - const source = await Promise.all( - attachments.map(async (attachment) => { - const storage = new TrelloPosterStorage(); - return storage.storeAttachment(attachment); - }), - ); - return { - ...poster, - type, - source, + const service = new LocalPosterService(); + let localPoster = await service.createMediaPoster({ + name: poster.name, + type: type, + label: poster.label, + startDate: card.badges?.start ? new Date(card.badges.start) : undefined, + expirationDate: poster.due, + defaultTimeout: poster.timeout, + footerSize: poster.footer, + borrelMode: poster.borrelMode, + accentColor: poster.color, + trello: true, + }); + + const attachments = await this.client.default.getCardAttachments(card.id); + const headers = { + Authorization: `OAuth oauth_consumer_key="${process.env.TRELLO_KEY}", oauth_token="${process.env.TRELLO_TOKEN}"`, }; + const resp = await axios.get(attachments[0].url, { + responseType: 'arraybuffer', + headers, + }); + + return service.attachMedia(localPoster.id, attachments[0].fileName, Buffer.from(resp.data)); } /** @@ -189,26 +175,31 @@ export class TrelloPosterManager extends PosterManager { card: Card, checklists: Checklist[], borrelMode = false, - ): Promise { - // Find the checklist called "photos", that should contain the album ids + ): Promise { const index = checklists.findIndex((checklist) => checklist.name.toLowerCase() === 'photos'); // If such list cannot be found, it does not exist. Throw an error because we cannot continue if (index === undefined || index < 0) { - return { - ...this.parseBasePoster(card, checklists, borrelMode), - type: PosterType.ERROR, - message: 'Photo card has no checklist named "photos"', - }; + return undefined; } - // Get the checklist for the albums + const checkList = checklists![index]; - // @ts-ignore const albums = checkList.checkItems.map((item: any) => item.name.split(' ')[0]); - return { - ...this.parseBasePoster(card, checklists, borrelMode), + + const poster = this.parseBasePoster(card, checklists, borrelMode); + const service = new LocalPosterService(); + return service.createPhotoPoster({ + name: poster.name, type: PosterType.PHOTO, + label: poster.label, + startDate: card.badges?.start ? new Date(card.badges.start) : undefined, + expirationDate: poster.due, + defaultTimeout: poster.timeout, + footerSize: poster.footer, + borrelMode: poster.borrelMode, + accentColor: poster.color, albums, - }; + trello: true, + }); } /** @@ -223,7 +214,7 @@ export class TrelloPosterManager extends PosterManager { card: Card, checklists: Checklist[], borrelMode = false, - ): Promise { + ): Promise { const isUrl = (url: string): boolean => { try { const parsedUrl = new URL(url); @@ -239,22 +230,28 @@ export class TrelloPosterManager extends PosterManager { const url = isUrl(match) ? match : (card.desc ?? ''); if (!card.desc || !isUrl(url)) { - return { - ...this.parseBasePoster(card, checklists, borrelMode), - type: PosterType.ERROR, - message: 'Card description does not exist or is not a valid HTTP/HTTPS URL', - }; + return undefined; } - return { - ...this.parseBasePoster(card, checklists, borrelMode), + + const poster = this.parseBasePoster(card, checklists, borrelMode); + const service = new LocalPosterService(); + + return service.createExternalPoster({ + name: poster.name, type: PosterType.EXTERNAL, - source: [url || ''], - }; + label: poster.label, + startDate: card.badges?.start ? new Date(card.badges.start) : undefined, + expirationDate: poster.due, + defaultTimeout: poster.timeout, + footerSize: poster.footer, + borrelMode: poster.borrelMode, + accentColor: poster.color, + uri: url, + trello: true, + }); } - async fetchPosters(): Promise { - // const lists = await this.client.default - // .getBoardsIdLists(process.env.TRELLO_BOARD_ID || '', ViewFilter.ALL, 'all'); + async reloadPosters(): Promise { let board = await this.client.default.getBoard(process.env.TRELLO_BOARD_ID || ''); if (!Object.prototype.hasOwnProperty.call(board, 'id')) throw new Error(JSON.stringify(board)); board = board as Board; @@ -266,27 +263,15 @@ export class TrelloPosterManager extends PosterManager { const list = lists.find((l) => l.name === basePosterListName); if (!list) throw new Error(`Could not find the list called "${basePosterListName}"`); - this._posters = await this.parseLists(list, board); + const service = new LocalPosterService(); - if (this.refreshTimeout) clearTimeout(this.refreshTimeout); - this.refreshTimeout = setTimeout(this.fetchPosters.bind(this), DEFAULT_POSTER_REFRESH); + await service.deleteTrelloPosters(); - return this._posters; - } + await this.parseLists(list, board); - /** - * @inheritDoc - */ - public get posters(): Poster[] | undefined { - if (!this._posters) return undefined; - return this._posters.map((p): Poster => { - if (p.type === PosterType.IMAGE || p.type === PosterType.VIDEO) { - return { - ...p, - source: p.source.map((s) => `/static${s.replaceAll('\\', '/')}`), - }; - } - return p; - }); + if (this.refreshTimeout) clearTimeout(this.refreshTimeout); + this.refreshTimeout = setTimeout(this.reloadPosters.bind(this), DEFAULT_POSTER_REFRESH); + + return await service.getAllLocalPosters(); } } diff --git a/src/modules/handlers/screen/poster/trello/trello-poster-storage.ts b/src/modules/handlers/screen/poster/trello/trello-poster-storage.ts deleted file mode 100644 index 8cc396c4..00000000 --- a/src/modules/handlers/screen/poster/trello/trello-poster-storage.ts +++ /dev/null @@ -1,53 +0,0 @@ -import * as fs from 'node:fs'; -import path from 'node:path'; -import axios from 'axios'; -import { Attachment } from './client'; - -export class TrelloPosterStorage { - private readonly location: string; - - constructor(private urlLocation: string = '/posters') { - this.location = path.join(__dirname, '../../../../../../public', urlLocation); - if (!fs.existsSync(this.location)) { - fs.mkdirSync(this.location); - } - } - - /** - * Store the given attachment on the local disk. - * @param attachment - * @return Relative URL to get the file using HTTP(S) - */ - public async storeAttachment(attachment: Attachment): Promise { - const extension = path.extname(attachment.fileName || ''); - const fileName = `${attachment.id}${extension}`; - const fileLocationDisk = path.join(this.location, fileName); - const fileLocationWeb = path.join(this.urlLocation, fileName); - - if (fs.existsSync(fileLocationDisk)) return fileLocationWeb; - - // New trello update - const headers = { - Authorization: `OAuth oauth_consumer_key="${process.env.TRELLO_KEY}", oauth_token="${process.env.TRELLO_TOKEN}"`, - }; - - return axios.get(attachment.url, { responseType: 'stream', headers }).then( - (response) => - new Promise((resolve, reject) => { - const fileWriter = fs.createWriteStream(fileLocationDisk); - response.data.pipe(fileWriter); - let error: Error; - fileWriter.on('error', (err) => { - error = err; - fileWriter.close(); - reject(err); - }); - fileWriter.on('close', () => { - if (!error) { - resolve(fileLocationWeb); - } - }); - }), - ); - } -} From 16d70beb5e0aa16ae1c12a3f091e37730c218635 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Tue, 12 May 2026 20:15:32 +0200 Subject: [PATCH 25/58] feat(migration): added static poster migration Needs good test instance to verify --- src/index.ts | 3 + .../poster/local/local-poster-service.ts | 71 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/index.ts b/src/index.ts index d3e523f8..37ed5b63 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ import { OrderManager } from './modules/orders'; import TimedEventsService from './modules/timed-events/timed-events-service'; import LightsSwitchManager from './modules/root/lights-switch-manager'; import { TrelloPosterManager } from './modules/handlers/screen/poster/trello/trello-poster-manager'; +import LocalPosterService from './modules/handlers/screen/poster/local/local-poster-service'; async function createApp(): Promise { // Fix for production issue where a Docker volume overwrites the contents of a folder instead of merging them @@ -46,6 +47,8 @@ async function createApp(): Promise { await dataSource.initialize(); + await new LocalPosterService().migrateStaticPosters(); + await ServerSettingsStore.getInstance().initialize(); const featureFlagManager = new FeatureFlagManager(); await TimedEventsService.getInstance().registerAllDatabaseEvents(); diff --git a/src/modules/handlers/screen/poster/local/local-poster-service.ts b/src/modules/handlers/screen/poster/local/local-poster-service.ts index e03b3870..c616c4eb 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-service.ts +++ b/src/modules/handlers/screen/poster/local/local-poster-service.ts @@ -1,9 +1,12 @@ import { FileStorage } from '../../../../files/storage/file-storage'; import { Repository } from 'typeorm'; +import { lookup } from 'mime-types'; import { File } from '../../../../files/entities'; import LocalPoster from './local-poster'; +import StaticPoster from '../static/static-poster'; import { DiskStorage } from '../../../../files/storage'; import dataSource from '../../../../../database'; +import logger from '../../../../../logger'; import { HttpApiException } from '../../../../../helpers/custom-error'; import { HttpStatusCode } from 'axios'; import { FooterSize, PosterType } from '../poster'; @@ -297,4 +300,72 @@ export default class LocalPosterService { const posters = await this.repo.find({ where: { trello: true } }); await Promise.all(posters.map((poster) => this.deleteLocalPoster(poster.id))); } + + /** + * Checks whether the legacy static poster table exists, and if it does, migrates + * every static poster. + */ + public async migrateStaticPosters(): Promise { + const staticRepo = dataSource.getRepository(StaticPoster); + + const queryRunner = dataSource.createQueryRunner(); + let tableExists: boolean; + try { + tableExists = await queryRunner.hasTable(staticRepo.metadata.tableName); + } finally { + await queryRunner.release(); + } + if (!tableExists) return; + + const staticPosters = await staticRepo.find(); + if (staticPosters.length === 0) return; + + const migrated: StaticPoster[] = []; + for (const staticPoster of staticPosters) { + try { + if (staticPoster.file) { + // Read the file from wherever the static poster handler stored it... + const sourceStorage = new DiskStorage( + staticPoster.file.relativeDirectory.replace(/^public[\\/]/, ''), + ); + const data = await sourceStorage.getFile(staticPoster.file); + + // ...and re-save it through the local-poster storage to get the regular format. + const fileParams = await this.storage.saveFile(staticPoster.file.originalName, data); + const file = await this.fileRepo.save(fileParams); + + const mimeType = lookup(staticPoster.file.originalName); + await this.repo.save({ + name: staticPoster.file.originalName, + type: mimeType && mimeType.startsWith('video/') ? PosterType.VIDEO : PosterType.IMAGE, + file, + enabled: false, + }); + + await sourceStorage.deleteFile(staticPoster.file); + await this.fileRepo.delete(staticPoster.file.id); + } else if (staticPoster.uri) { + await this.repo.save({ + name: staticPoster.uri, + type: PosterType.EXTERNAL, + uri: staticPoster.uri, + enabled: false, + }); + } else { + logger.warn( + `Skipping static poster ${staticPoster.id}: it has neither a file nor a uri.`, + ); + continue; + } + migrated.push(staticPoster); + } catch (error) { + logger.error(`Failed to migrate static poster ${staticPoster.id}: ${error}`); + } + } + + if (migrated.length > 0) { + await staticRepo.remove(migrated); + } + logger.info(`Migrated ${migrated.length} static poster(s) to local posters.`); + } } From 85495a20ca817fc128ec435031282c0f7e483a85 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Tue, 12 May 2026 20:48:17 +0200 Subject: [PATCH 26/58] feat(poster): added album edit for photo posters --- .../handlers/screen/poster/local/local-poster-service.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/modules/handlers/screen/poster/local/local-poster-service.ts b/src/modules/handlers/screen/poster/local/local-poster-service.ts index c616c4eb..2d7fa6a5 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-service.ts +++ b/src/modules/handlers/screen/poster/local/local-poster-service.ts @@ -49,6 +49,7 @@ export interface UpdatePosterRequest { footerSize?: FooterSize; defaultTimeout?: number; borrelMode?: boolean; + albums?: number[]; } export interface LocalPosterResponse { @@ -324,13 +325,11 @@ export default class LocalPosterService { for (const staticPoster of staticPosters) { try { if (staticPoster.file) { - // Read the file from wherever the static poster handler stored it... const sourceStorage = new DiskStorage( staticPoster.file.relativeDirectory.replace(/^public[\\/]/, ''), ); const data = await sourceStorage.getFile(staticPoster.file); - // ...and re-save it through the local-poster storage to get the regular format. const fileParams = await this.storage.saveFile(staticPoster.file.originalName, data); const file = await this.fileRepo.save(fileParams); From 59ba423d2231f2f74bab093452832d770b90267b Mon Sep 17 00:00:00 2001 From: Wanderer Date: Tue, 12 May 2026 21:08:19 +0200 Subject: [PATCH 27/58] fix(poster): added check to prevent parsing photo poster multiple times --- .../screen/poster/trello/trello-poster-manager.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts b/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts index 77c52225..09ca3231 100644 --- a/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts +++ b/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts @@ -12,6 +12,8 @@ export class TrelloPosterManager { private refreshTimeout: NodeJS.Timeout | undefined = undefined; + private photoFlag = false; + constructor() { this.client = new TrelloClient(); } @@ -176,6 +178,13 @@ export class TrelloPosterManager { checklists: Checklist[], borrelMode = false, ): Promise { + // Only parse the list once + if (this.photoFlag) { + return; + } else { + this.photoFlag = true; + } + const index = checklists.findIndex((checklist) => checklist.name.toLowerCase() === 'photos'); // If such list cannot be found, it does not exist. Throw an error because we cannot continue if (index === undefined || index < 0) { @@ -267,6 +276,7 @@ export class TrelloPosterManager { await service.deleteTrelloPosters(); + this.photoFlag = false; await this.parseLists(list, board); if (this.refreshTimeout) clearTimeout(this.refreshTimeout); From 735e3e9eb4401cf301c202c7b749496fb0a5e959 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Tue, 12 May 2026 21:34:18 +0200 Subject: [PATCH 28/58] fix(migration): fixed posters with start date in the future missing --- .../handlers/screen/poster/trello/trello-poster-manager.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts b/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts index 09ca3231..2b96b40a 100644 --- a/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts +++ b/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts @@ -55,8 +55,6 @@ export class TrelloPosterManager { // If the card has a due date and this due date is in the past, skip this card if (card.due && new Date(card.due) < now) return undefined; - // If the card has a start date and this start date is in the future, skip this card - if (card.badges?.start != null && new Date(card.badges.start) > now) return undefined; switch (listType) { case 'img': From 36579523d7deb4f10e3e8415379c72d6979c5faa Mon Sep 17 00:00:00 2001 From: Wanderer Date: Thu, 21 May 2026 09:45:19 +0200 Subject: [PATCH 29/58] fix: added flag to prevent duplicate posters on all lists --- .../poster/trello/trello-poster-manager.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts b/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts index 2b96b40a..b8ef8d3e 100644 --- a/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts +++ b/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts @@ -11,9 +11,7 @@ export class TrelloPosterManager { private client: TrelloClient; private refreshTimeout: NodeJS.Timeout | undefined = undefined; - - private photoFlag = false; - + constructor() { this.client = new TrelloClient(); } @@ -23,13 +21,18 @@ export class TrelloPosterManager { * @param list * @param board * @param listType + * @param visitedLists * @private */ private async parseLists( list: TrelloList, board: Board, listType?: PosterType, + visitedLists: Set = new Set(), ): Promise { + if (!list.id || visitedLists.has(list.id)) return []; + visitedLists.add(list.id); + const { cards: allCards, lists: allLists, checklists: allChecklists } = board; const cards = allCards?.filter((card) => card.idList === list.id) || []; @@ -46,7 +49,7 @@ export class TrelloPosterManager { if (labels.includes('Posterlist')) { const newList = allLists?.find((l) => l.name === card.name); if (newList) { - return this.parseLists(newList, board, card.desc as PosterType); + return this.parseLists(newList, board, card.desc as PosterType, visitedLists); } throw new Error(`Unknown list: ${card.name}`); } @@ -176,13 +179,6 @@ export class TrelloPosterManager { checklists: Checklist[], borrelMode = false, ): Promise { - // Only parse the list once - if (this.photoFlag) { - return; - } else { - this.photoFlag = true; - } - const index = checklists.findIndex((checklist) => checklist.name.toLowerCase() === 'photos'); // If such list cannot be found, it does not exist. Throw an error because we cannot continue if (index === undefined || index < 0) { @@ -274,7 +270,6 @@ export class TrelloPosterManager { await service.deleteTrelloPosters(); - this.photoFlag = false; await this.parseLists(list, board); if (this.refreshTimeout) clearTimeout(this.refreshTimeout); From 31ffcc1fa919b3a06f5104f7b18647b32386c403 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Tue, 26 May 2026 13:50:07 +0200 Subject: [PATCH 30/58] feat(dev): added seeding for default GEWIS posters --- src/seed/seedGewis.ts | 83 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 14 deletions(-) diff --git a/src/seed/seedGewis.ts b/src/seed/seedGewis.ts index 6e7682c7..0155bce1 100644 --- a/src/seed/seedGewis.ts +++ b/src/seed/seedGewis.ts @@ -1,6 +1,6 @@ import RootAudioService from '../modules/root/root-audio-service'; import RootScreenService from '../modules/root/root-screen-service'; -import RootLightsService, { LightsInGroup } from '../modules/root/root-lights-service'; +import RootLightsService from '../modules/root/root-lights-service'; import dataSource from '../database'; import { LightsGroup, LightsMovingHeadWheel, LightsSwitch } from '../modules/lights/entities'; import { RgbColor, WheelColor } from '../modules/lights/color-definitions'; @@ -20,9 +20,9 @@ import { FixedPositionCreateParams } from '../modules/lights/effects/movement/fi import { ColorEffects } from '../modules/lights/effects/color/color-effects'; import { MovementEffects } from '../modules/lights/effects/movement/movement-effetcs'; import { TimedEvent } from '../modules/timed-events/entities'; -import AuthService from '../modules/auth/auth-service'; import { IntegrationUserService } from '../modules/auth/integration'; import LocalPoster from '../modules/handlers/screen/poster/local/local-poster'; +import { FooterSize, PosterType } from '../modules/handlers/screen/poster/poster'; export default async function seedDatabase() { const timedEventsRepo = dataSource.getRepository(TimedEvent); @@ -780,17 +780,72 @@ export async function seedOpeningSequence( export async function seedPosters() { const repo = dataSource.getRepository(LocalPoster); - const addPoster = async (name: string) => { - await repo.save({ - name, - type: name, + await repo.save([ + { + name: 'Logo', + type: PosterType.LOGO, + enabled: true, + label: 'GEWIS Logo', protected: true, - } as LocalPoster); - }; - - await Promise.all( - ['logo', 'borrel-logo', 'borrel-wall-of-shame', 'borrel-price-list', 'train', 'olympics'].map( - addPoster, - ), - ); + borrelMode: false, + footerSize: FooterSize.FULL, + defaultTimeout: 15, + trello: false, + }, + { + name: 'Trains', + type: PosterType.TRAINS, + enabled: true, + label: 'Trains', + protected: true, + borrelMode: false, + footerSize: FooterSize.MINIMAL, + defaultTimeout: 30, + trello: false, + }, + { + name: 'Borrel Logo', + type: PosterType.BORREL_LOGO, + enabled: true, + label: 'Borrel Logo', + protected: true, + borrelMode: true, + footerSize: FooterSize.FULL, + defaultTimeout: 15, + trello: false, + }, + { + name: 'Borrel Price List', + type: PosterType.BORREL_PRICE_LIST, + enabled: true, + label: 'Borrel Price List', + protected: true, + borrelMode: true, + footerSize: FooterSize.MINIMAL, + defaultTimeout: 15, + trello: false, + }, + { + name: 'Borrel Wall of Shame', + type: PosterType.BORREL_WALL_OF_SHAME, + enabled: true, + label: 'Borrel Wall of Shame', + protected: true, + borrelMode: true, + footerSize: FooterSize.MINIMAL, + defaultTimeout: 15, + trello: false, + }, + { + name: 'Olympics', + type: PosterType.OLYMPICS, + enabled: false, + label: 'Olympics', + protected: true, + borrelMode: false, + footerSize: FooterSize.FULL, + defaultTimeout: 15, + trello: false, + }, + ] as LocalPoster[]); } From 3315ab2ff88f043068b134f9e3540a63d3b8a2f9 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Tue, 26 May 2026 14:15:52 +0200 Subject: [PATCH 31/58] feat: added integration security for external screen/handler control --- .../handlers/screen/poster/static/static-poster-controller.ts | 1 + src/modules/root/handler-controller.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/modules/handlers/screen/poster/static/static-poster-controller.ts b/src/modules/handlers/screen/poster/static/static-poster-controller.ts index 389abdd6..d112ee5a 100644 --- a/src/modules/handlers/screen/poster/static/static-poster-controller.ts +++ b/src/modules/handlers/screen/poster/static/static-poster-controller.ts @@ -72,6 +72,7 @@ export class StaticPosterController extends Controller { * @param req */ @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) + @Security(SecurityNames.INTEGRATION, ['showStaticPoster']) @Post('items/{id}/show') public async showStaticPoster(id: number, @Request() req: ExpressRequest): Promise { const service = new LocalPosterService(); diff --git a/src/modules/root/handler-controller.ts b/src/modules/root/handler-controller.ts index b71e5f1d..b7245596 100644 --- a/src/modules/root/handler-controller.ts +++ b/src/modules/root/handler-controller.ts @@ -100,6 +100,7 @@ export class HandlerController extends Controller { } @Security(SecurityNames.LOCAL, securityGroups.handler.base) + @Security(SecurityNames.INTEGRATION, ['getScreenHandlers']) @Get('screen') public getScreenHandlers(): HandlerResponse[] { return this.handlersManager.getHandlers(Screen).map((h) => ({ @@ -110,6 +111,7 @@ export class HandlerController extends Controller { } @Security(SecurityNames.LOCAL, securityGroups.handler.privileged) + @Security(SecurityNames.INTEGRATION, ['setScreenHandler']) @Post('screen/{id}') public async setScreenHandler( @Request() req: ExpressRequest, From f28cd03eb2b8f76aa98d9fa32a752041cb64d175 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Sun, 31 May 2026 11:33:57 +0200 Subject: [PATCH 32/58] feat: added seeding command for GEWIS default posters only --- package.json | 1 + src/seed/index.ts | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 117b0a80..ec4f3a38 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "format-fix": "prettier --ignore-path .gitignore --write ./src", "seed": "ts-node src/seed/index.ts", "seed:gewis": "ts-node src/seed/index.ts --gewis", + "seed:gewis-posters": "ts-node src/seed/index.ts --gewis-posters", "seed:hubble": "ts-node src/seed/index.ts --hubble", "prepare-husky": "husky", "validate": "ts-node src/validate.ts" diff --git a/src/seed/index.ts b/src/seed/index.ts index 39004b69..e5143d04 100644 --- a/src/seed/index.ts +++ b/src/seed/index.ts @@ -14,9 +14,16 @@ const program = new Command(); program .name('aurora-seeder') .description('Functions to seed the database.') - .addOption(new Option('--gewis', 'Seed for GEWIS').conflicts(['hubble', 'disco-floor'])) - .addOption(new Option('--hubble', 'Seed for Hubble').conflicts(['gewis', 'disco-floor'])) - .addOption(new Option('--disco-floor', 'Seed the disco floor').conflicts(['gewis', 'hubble'])) + .addOption(new Option('--gewis', 'Seed for GEWIS').conflicts(['gewis-posters','hubble', 'disco-floor'])) + .addOption( + new Option('--gewis-posters', 'Seed for GEWIS (Default posters only)').conflicts([ + 'gewis', + 'hubble', + 'disco-floor', + ]), + ) + .addOption(new Option('--hubble', 'Seed for Hubble').conflicts(['gewis', 'gewis-posters', 'disco-floor'])) + .addOption(new Option('--disco-floor', 'Seed the disco floor').conflicts(['gewis', 'gewis-posters', 'hubble'])) .option('-w, --width ', 'Width of the disco floor (int)', parseInt) .option('-h, --height ', 'Height of the disco floor (int)', parseInt) .option( @@ -49,6 +56,9 @@ async function createSeeder() { await seedBorrelLights(room!, bar!, lounge!, movingHeadsGEWIS!); await seedOpeningSequence(room!, bar!, movingHeadsGEWIS!, movingHeadsRoy!); await seedPosters(); + } else if (program.opts().gewisPosters) { + console.info('Seeding default posters for GEWIS'); + await seedPosters(); } else if (program.opts().discoFloor) { console.info('Seeding database for disco floor'); const { width, height, channelOrder } = program.opts(); From c7691a02e7d95e2f2c29cd818b046d72d1b30bc9 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Sun, 31 May 2026 23:36:57 +0200 Subject: [PATCH 33/58] fix: issue where trello media got added twice --- .../screen/poster/local/local-poster-service.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/modules/handlers/screen/poster/local/local-poster-service.ts b/src/modules/handlers/screen/poster/local/local-poster-service.ts index 2d7fa6a5..efa63098 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-service.ts +++ b/src/modules/handlers/screen/poster/local/local-poster-service.ts @@ -188,9 +188,19 @@ export default class LocalPosterService { ); } + if (poster.file) { + await this.fileRepo.delete(poster.file.id); + await this.storage.deleteFile(poster.file); + } + const fileParams = await this.storage.saveFile(filename, filedata); - poster.file = await this.fileRepo.save(fileParams); - return this.repo.save(poster); + try { + poster.file = await this.fileRepo.save(fileParams); + return this.repo.save(poster); + } catch (error) { + await this.storage.deleteFile(fileParams); + throw error; + } } /** From e1a6734c5c76176608660162ae15a789127732eb Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 3 Jun 2026 18:26:29 +0200 Subject: [PATCH 34/58] refactor: renamed all instances of localposter to poster and merged enum --- src/database.ts | 4 +- src/index.ts | 4 +- .../poster/carousel-poster-controller.ts | 17 +++-- .../screen/poster/carousel-poster-handler.ts | 6 +- .../screen/poster/local/local-carousel.ts | 6 +- ...ter-controller.ts => poster-controller.ts} | 33 +++++----- ...al-poster-service.ts => poster-service.ts} | 45 +++++++------ .../local/{local-poster.ts => poster.ts} | 26 +++++++- src/modules/handlers/screen/poster/poster.ts | 65 ------------------- .../screen/poster/static-poster-handler.ts | 8 +-- .../poster/static/static-poster-controller.ts | 6 +- .../poster/trello/trello-poster-manager.ts | 31 +++++---- src/modules/timed-events/cron-manager.ts | 6 +- src/seed/index.ts | 16 ++++- src/seed/seedGewis.ts | 7 +- 15 files changed, 121 insertions(+), 159 deletions(-) rename src/modules/handlers/screen/poster/local/{local-poster-controller.ts => poster-controller.ts} (82%) rename src/modules/handlers/screen/poster/local/{local-poster-service.ts => poster-service.ts} (89%) rename src/modules/handlers/screen/poster/local/{local-poster.ts => poster.ts} (68%) delete mode 100644 src/modules/handlers/screen/poster/poster.ts diff --git a/src/database.ts b/src/database.ts index 8f585145..b9b571cf 100644 --- a/src/database.ts +++ b/src/database.ts @@ -10,7 +10,7 @@ import { Entities as SpotifyEntities } from './modules/spotify/entities'; import { Entities as LightsEntities } from './modules/lights/entities'; import { Entities as TimedEventsEntities } from './modules/timed-events/entities'; import StaticPoster from './modules/handlers/screen/poster/static/static-poster'; -import LocalPoster from './modules/handlers/screen/poster/local/local-poster'; +import Poster from './modules/handlers/screen/poster/local/poster'; import Carousel, { CarouselPoster } from './modules/handlers/screen/poster/local/local-carousel'; const dataSource = new DataSource({ @@ -45,7 +45,7 @@ const dataSource = new DataSource({ ...SpotifyEntities, ...LightsEntities, StaticPoster, - LocalPoster, + Poster, Carousel, CarouselPoster, ], diff --git a/src/index.ts b/src/index.ts index 37ed5b63..367bf0de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,7 +26,7 @@ import { OrderManager } from './modules/orders'; import TimedEventsService from './modules/timed-events/timed-events-service'; import LightsSwitchManager from './modules/root/lights-switch-manager'; import { TrelloPosterManager } from './modules/handlers/screen/poster/trello/trello-poster-manager'; -import LocalPosterService from './modules/handlers/screen/poster/local/local-poster-service'; +import PosterService from './modules/handlers/screen/poster/local/poster-service'; async function createApp(): Promise { // Fix for production issue where a Docker volume overwrites the contents of a folder instead of merging them @@ -47,7 +47,7 @@ async function createApp(): Promise { await dataSource.initialize(); - await new LocalPosterService().migrateStaticPosters(); + await new PosterService().migrateStaticPosters(); await ServerSettingsStore.getInstance().initialize(); const featureFlagManager = new FeatureFlagManager(); diff --git a/src/modules/handlers/screen/poster/carousel-poster-controller.ts b/src/modules/handlers/screen/poster/carousel-poster-controller.ts index e3cd24a5..4af46d43 100644 --- a/src/modules/handlers/screen/poster/carousel-poster-controller.ts +++ b/src/modules/handlers/screen/poster/carousel-poster-controller.ts @@ -11,10 +11,9 @@ import GEWISPosterService, { GEWISPhotoAlbumParams } from './gewis-poster-servic import OlympicsService from './olympics-service'; import { FeatureEnabled, ServerSettingsStore } from '../../../server-settings'; import { Controller } from '@tsoa/runtime'; -import { Poster } from './poster'; import { ISettings } from '../../../server-settings/server-setting'; -import { LocalPosterResponse } from './local/local-poster-service'; -import LocalPoster from './local/local-poster'; +import { PosterResponse } from './local/poster-service'; +import Poster from './local/poster'; export interface BorrelModeParams { enabled: boolean; @@ -28,8 +27,8 @@ export interface EnabledParams { enabled: boolean; } -export interface PosterResponse { - posters: LocalPosterResponse[]; +export interface CarouselResponse { + posters: PosterResponse[]; borrelMode: boolean; } @@ -48,10 +47,10 @@ export class CarouselPosterController extends Controller { @Security(SecurityNames.LOCAL, securityGroups.poster.base) @Get('') - public async getPosters(@Query() includeHidden?: boolean): Promise { - const posters = await this.screenHandler.posterService.getAllLocalPosters(); + public async getPosters(@Query() includeHidden?: boolean): Promise { + const posters = await this.screenHandler.posterService.getAllPosters(); - let visible: LocalPoster[]; + let visible: Poster[]; if (includeHidden) { visible = posters; } else { @@ -106,7 +105,7 @@ export class CarouselPosterController extends Controller { public async togglePosterEnable( id: number, @Body() body: EnabledParams, - ): Promise { + ): Promise { const poster = await this.screenHandler.posterService.togglePosterEnable(id, body.enabled); return this.screenHandler.posterService.toResponse(poster); } diff --git a/src/modules/handlers/screen/poster/carousel-poster-handler.ts b/src/modules/handlers/screen/poster/carousel-poster-handler.ts index 322c3330..214ce03a 100644 --- a/src/modules/handlers/screen/poster/carousel-poster-handler.ts +++ b/src/modules/handlers/screen/poster/carousel-poster-handler.ts @@ -3,11 +3,11 @@ import { Namespace } from 'socket.io'; import { FeatureEnabled, ServerSettingsStore } from '../../../server-settings'; import { TrackChangeEvent } from '../../../events/music-emitter-events'; import { ISettings } from '../../../server-settings/server-setting'; -import LocalPosterService from './local/local-poster-service'; +import PosterService from './local/poster-service'; @FeatureEnabled('Poster') export default class CarouselPosterHandler extends BaseScreenHandler { - public posterService: LocalPosterService; + public posterService: PosterService; private borrelModeDay: number | undefined; @@ -20,7 +20,7 @@ export default class CarouselPosterHandler extends BaseScreenHandler { // Check whether we need to enable/disable borrel mode this.borrelModeInterval = setInterval(this.checkBorrelMode.bind(this), 60 * 1000); } - this.posterService = new LocalPosterService(); + this.posterService = new PosterService(); } forceUpdate(): void { diff --git a/src/modules/handlers/screen/poster/local/local-carousel.ts b/src/modules/handlers/screen/poster/local/local-carousel.ts index b07b5454..0953d74d 100644 --- a/src/modules/handlers/screen/poster/local/local-carousel.ts +++ b/src/modules/handlers/screen/poster/local/local-carousel.ts @@ -1,6 +1,6 @@ import { Entity, Column, OneToMany, ManyToOne } from 'typeorm'; import BaseEntity from '../../../../root/entities/base-entity'; -import LocalPoster from './local-poster'; +import Poster from './poster'; @Entity() export default class Carousel extends BaseEntity { @@ -19,8 +19,8 @@ export class CarouselPoster extends BaseEntity { @ManyToOne(() => Carousel, (c) => c.posters, { onDelete: 'CASCADE' }) carousel: Carousel; - @ManyToOne(() => LocalPoster, { onDelete: 'CASCADE' }) - poster: LocalPoster; + @ManyToOne(() => Poster, { onDelete: 'CASCADE' }) + poster: Poster; @Column({ nullable: true }) customTimeout?: number; diff --git a/src/modules/handlers/screen/poster/local/local-poster-controller.ts b/src/modules/handlers/screen/poster/local/poster-controller.ts similarity index 82% rename from src/modules/handlers/screen/poster/local/local-poster-controller.ts rename to src/modules/handlers/screen/poster/local/poster-controller.ts index da39b420..b52defa3 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-controller.ts +++ b/src/modules/handlers/screen/poster/local/poster-controller.ts @@ -3,29 +3,28 @@ import { Body, Delete, Get, Post, Put, Res, Route, Security, Tags } from 'tsoa'; import { SecurityNames } from '../../../../../helpers/security'; import { securityGroups } from '../../../../../helpers/security-groups'; import { HttpStatusCode } from 'axios'; -import LocalPosterService, { +import PosterService, { CreatePosterRequest, - LocalPosterResponse, + PosterResponse, UpdatePosterRequest, -} from './local-poster-service'; -import LocalPoster from './local-poster'; +} from './poster-service'; +import Poster, { PosterType } from './poster'; import { lookup } from 'mime-types'; -import { PosterType } from '../poster'; import { FeatureEnabled } from '../../../../server-settings'; @Route('/handler/screen/poster') @Tags('Handlers') @FeatureEnabled('Poster') -export class LocalPosterController extends Controller { - private service = new LocalPosterService(); +export class PosterController extends Controller { + private service = new PosterService(); /** * Get all posters from the database. */ @Security(SecurityNames.LOCAL, securityGroups.poster.base) @Get('items') - public async getAllPosters(): Promise { - const posters = await this.service.getAllLocalPosters(); + public async getAllPosters(): Promise { + const posters = await this.service.getAllPosters(); return posters.map((poster) => this.service.toResponse(poster)); } @@ -35,8 +34,8 @@ export class LocalPosterController extends Controller { */ @Security(SecurityNames.LOCAL, securityGroups.poster.base) @Get('items/{id}') - public async getPoster(id: number): Promise { - const poster = await this.service.getSingleLocalPoster(id); + public async getPoster(id: number): Promise { + const poster = await this.service.getSinglePoster(id); return this.service.toResponse(poster); } @@ -51,8 +50,8 @@ export class LocalPosterController extends Controller { @Body() body: CreatePosterRequest, @Res() invalidPosterTypeResponse: TsoaResponse, - ): Promise { - let poster: LocalPoster; + ): Promise { + let poster: Poster; switch (body.type) { case PosterType.IMAGE: case PosterType.VIDEO: @@ -87,7 +86,7 @@ export class LocalPosterController extends Controller { HttpStatusCode.UnsupportedMediaType, 'Invalid file type, expected an image or a video.' >, - ): Promise { + ): Promise { const mimeType = lookup(file.originalname); if (!mimeType || !(mimeType.startsWith('image/') || mimeType.startsWith('video/'))) { return invalidFileTypeResponse( @@ -107,7 +106,7 @@ export class LocalPosterController extends Controller { @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) @Delete('items/{id}') public async deletePoster(id: number): Promise { - await this.service.deleteLocalPoster(id); + await this.service.deletePoster(id); } /** @@ -120,8 +119,8 @@ export class LocalPosterController extends Controller { public async updatePoster( id: number, @Body() body: UpdatePosterRequest, - ): Promise { - const poster = await this.service.updateLocalPoster(id, body); + ): Promise { + const poster = await this.service.updatePoster(id, body); return this.service.toResponse(poster); } } diff --git a/src/modules/handlers/screen/poster/local/local-poster-service.ts b/src/modules/handlers/screen/poster/local/poster-service.ts similarity index 89% rename from src/modules/handlers/screen/poster/local/local-poster-service.ts rename to src/modules/handlers/screen/poster/local/poster-service.ts index efa63098..b0126a34 100644 --- a/src/modules/handlers/screen/poster/local/local-poster-service.ts +++ b/src/modules/handlers/screen/poster/local/poster-service.ts @@ -2,14 +2,13 @@ import { FileStorage } from '../../../../files/storage/file-storage'; import { Repository } from 'typeorm'; import { lookup } from 'mime-types'; import { File } from '../../../../files/entities'; -import LocalPoster from './local-poster'; +import Poster, { FooterSize, PosterType } from './poster'; import StaticPoster from '../static/static-poster'; import { DiskStorage } from '../../../../files/storage'; import dataSource from '../../../../../database'; import logger from '../../../../../logger'; import { HttpApiException } from '../../../../../helpers/custom-error'; import { HttpStatusCode } from 'axios'; -import { FooterSize, PosterType } from '../poster'; import FileResponse from '../../../../files/entities/file-response'; interface BasePosterParams { @@ -52,7 +51,7 @@ export interface UpdatePosterRequest { albums?: number[]; } -export interface LocalPosterResponse { +export interface PosterResponse { id: number; name: string; label?: string; @@ -72,25 +71,25 @@ export interface LocalPosterResponse { file?: FileResponse; } -export default class LocalPosterService { +export default class PosterService { private storage: FileStorage; - private repo: Repository; + private repo: Repository; private fileRepo: Repository; constructor() { this.storage = new DiskStorage('posters'); - this.repo = dataSource.getRepository(LocalPoster); + this.repo = dataSource.getRepository(Poster); this.fileRepo = dataSource.getRepository(File); } /** - * Converts a poster entity to the LocalPosterResponse format. + * Converts a poster entity to the PosterResponse format. * @param poster The poster to be converted. */ - public toResponse(poster: LocalPoster): LocalPosterResponse { - let file: LocalPosterResponse['file']; + public toResponse(poster: Poster): PosterResponse { + let file: PosterResponse['file']; if (poster.file) { const location = this.storage.getPublicFileUri(poster.file); if (location) { @@ -125,7 +124,7 @@ export default class LocalPosterService { /** * Fetches all Local Posters from the database. */ - public async getAllLocalPosters(): Promise { + public async getAllPosters(): Promise { return this.repo.find(); } @@ -133,7 +132,7 @@ export default class LocalPosterService { * Gets a specific Local Poster from the database. * @param id The id of the poster to fetch. */ - public async getSingleLocalPoster(id: number): Promise { + public async getSinglePoster(id: number): Promise { const poster = await this.repo.findOne({ where: { id } }); if (poster == null) { throw new HttpApiException(HttpStatusCode.NotFound, `Poster with ID "${id}" not found.`); @@ -146,7 +145,7 @@ export default class LocalPosterService { * This does not yet contain the actual image or video of the poster. * @param params Metadata of the poster as specified in the MediaPosterParams interface. */ - public async createMediaPoster(params: MediaPosterRequest): Promise { + public async createMediaPoster(params: MediaPosterRequest): Promise { const { name, label, @@ -179,8 +178,8 @@ export default class LocalPosterService { * @param filename Original filename of the media file. * @param filedata Buffer containing the file. */ - public async attachMedia(id: number, filename: string, filedata: Buffer): Promise { - const poster = await this.getSingleLocalPoster(id); + public async attachMedia(id: number, filename: string, filedata: Buffer): Promise { + const poster = await this.getSinglePoster(id); if (poster.type != PosterType.IMAGE && poster.type != PosterType.VIDEO) { throw new HttpApiException( HttpStatusCode.BadRequest, @@ -207,7 +206,7 @@ export default class LocalPosterService { * Creates a new Local Poster of the url type. * @param params The specifics of the poster as specified in the UrlPosterParams interface. */ - public async createExternalPoster(params: ExternalPosterRequest): Promise { + public async createExternalPoster(params: ExternalPosterRequest): Promise { const { name, label, @@ -240,7 +239,7 @@ export default class LocalPosterService { * Creates a new Local Poster of the photo type. * @param params The specifics of the poster as specified in the PhotoPosterParams interface. */ - public async createPhotoPoster(params: PhotoPosterRequest): Promise { + public async createPhotoPoster(params: PhotoPosterRequest): Promise { const { name, label, @@ -273,8 +272,8 @@ export default class LocalPosterService { * Deletes the given poster from the database and storage. * @param id The id of the poster to be deleted. */ - public async deleteLocalPoster(id: number): Promise { - const poster = await this.getSingleLocalPoster(id); + public async deletePoster(id: number): Promise { + const poster = await this.getSinglePoster(id); if (poster.file) { await this.fileRepo.delete(poster.file.id); await this.storage.deleteFile(poster.file); @@ -287,8 +286,8 @@ export default class LocalPosterService { * @param id The id of the poster to be updated. * @param params The fields of the poster to be updated as specified in UpdatePosterParams. */ - public async updateLocalPoster(id: number, params: UpdatePosterRequest): Promise { - const poster = await this.getSingleLocalPoster(id); + public async updatePoster(id: number, params: UpdatePosterRequest): Promise { + const poster = await this.getSinglePoster(id); Object.assign(poster, params); return this.repo.save(poster); } @@ -298,8 +297,8 @@ export default class LocalPosterService { * @param id The id of the poster to enable/disable. * @param enabled The state to put the poster in. */ - public async togglePosterEnable(id: number, enabled: boolean): Promise { - const poster = await this.getSingleLocalPoster(id); + public async togglePosterEnable(id: number, enabled: boolean): Promise { + const poster = await this.getSinglePoster(id); poster.enabled = enabled; return this.repo.save(poster); } @@ -309,7 +308,7 @@ export default class LocalPosterService { */ public async deleteTrelloPosters(): Promise { const posters = await this.repo.find({ where: { trello: true } }); - await Promise.all(posters.map((poster) => this.deleteLocalPoster(poster.id))); + await Promise.all(posters.map((poster) => this.deletePoster(poster.id))); } /** diff --git a/src/modules/handlers/screen/poster/local/local-poster.ts b/src/modules/handlers/screen/poster/local/poster.ts similarity index 68% rename from src/modules/handlers/screen/poster/local/local-poster.ts rename to src/modules/handlers/screen/poster/local/poster.ts index 160c65b2..60f65511 100644 --- a/src/modules/handlers/screen/poster/local/local-poster.ts +++ b/src/modules/handlers/screen/poster/local/poster.ts @@ -1,10 +1,32 @@ import { Entity, Column, OneToOne, JoinColumn } from 'typeorm'; import BaseEntity from '../../../../root/entities/base-entity'; import { File } from '../../../../files/entities'; -import { FooterSize, PosterType } from '../poster'; + +export enum PosterType { + UNKNOWN = 'unknown', + ERROR = 'error', + AGENDA = 'agenda', + INFIMA = 'infima', + TRAINS = 'train', + IMAGE = 'img', + LOGO = 'logo', + EXTERNAL = 'extern', + PHOTO = 'photo', + VIDEO = 'video', + BORREL_LOGO = 'borrel-logo', + BORREL_PRICE_LIST = 'borrel-price-list', + BORREL_WALL_OF_SHAME = 'borrel-wall-of-shame', + OLYMPICS = 'olympics', +} + +export enum FooterSize { + FULL = 'full', + MINIMAL = 'minimal', + HIDDEN = 'hidden', +} @Entity() -export default class LocalPoster extends BaseEntity { +export default class Poster extends BaseEntity { @Column() name: string; diff --git a/src/modules/handlers/screen/poster/poster.ts b/src/modules/handlers/screen/poster/poster.ts deleted file mode 100644 index 34d316ef..00000000 --- a/src/modules/handlers/screen/poster/poster.ts +++ /dev/null @@ -1,65 +0,0 @@ -export enum PosterType { - UNKNOWN = 'unknown', - ERROR = 'error', - AGENDA = 'agenda', - INFIMA = 'infima', - TRAINS = 'train', - IMAGE = 'img', - LOGO = 'logo', - EXTERNAL = 'extern', - PHOTO = 'photo', - VIDEO = 'video', - BORREL_LOGO = 'borrel-logo', - BORREL_PRICE_LIST = 'borrel-price-list', - BORREL_WALL_OF_SHAME = 'borrel-wall-of-shame', - OLYMPICS = 'olympics', -} - -export enum FooterSize { - FULL = 'full', - MINIMAL = 'minimal', - HIDDEN = 'hidden', -} - -export interface BasePoster { - name: string; - label: string; - due?: Date; - timeout: number; - footer: FooterSize; - /** Whether this poster should only be shown when in BorrelMode */ - borrelMode?: boolean; - color?: string; -} - -export type LocalPosterType = - | PosterType.AGENDA - | PosterType.INFIMA - | PosterType.LOGO - | PosterType.TRAINS - | PosterType.BORREL_LOGO - | PosterType.BORREL_PRICE_LIST - | PosterType.BORREL_WALL_OF_SHAME - | PosterType.OLYMPICS - | PosterType.UNKNOWN; - -export type LocalPoster = BasePoster & { - type: LocalPosterType; -}; - -export type MediaPoster = BasePoster & { - type: PosterType.IMAGE | PosterType.VIDEO | PosterType.EXTERNAL; - source: string[]; -}; - -export type PhotoPoster = BasePoster & { - type: PosterType.PHOTO; - albums: number[]; -}; - -export type ErrorPoster = BasePoster & { - type: PosterType.ERROR; - message: string; -}; - -export type Poster = LocalPoster | MediaPoster | PhotoPoster | ErrorPoster; diff --git a/src/modules/handlers/screen/poster/static-poster-handler.ts b/src/modules/handlers/screen/poster/static-poster-handler.ts index 5226b1c5..59ae357a 100644 --- a/src/modules/handlers/screen/poster/static-poster-handler.ts +++ b/src/modules/handlers/screen/poster/static-poster-handler.ts @@ -1,19 +1,19 @@ import BaseScreenHandler from '../../base-screen-handler'; import { TrackChangeEvent } from '../../../events/music-emitter-events'; import { FeatureEnabled } from '../../../server-settings'; -import { LocalPosterResponse } from './local/local-poster-service'; +import { PosterResponse } from './local/poster-service'; const UPDATE_POSTER_EVENT_NAME = 'update_static_poster'; const DEFAULT_CLOCK_VISIBLE = true; export interface StaticPosterHandlerState { - activePoster: LocalPosterResponse | null; + activePoster: PosterResponse | null; clockVisible: boolean; } @FeatureEnabled('Poster') export default class StaticPosterHandler extends BaseScreenHandler { - private activePoster: LocalPosterResponse | null; + private activePoster: PosterResponse | null; private clockVisible = DEFAULT_CLOCK_VISIBLE; @@ -28,7 +28,7 @@ export default class StaticPosterHandler extends BaseScreenHandler { * Change the currently active poster * @param poster */ - setActivePoster(poster: LocalPosterResponse): void { + setActivePoster(poster: PosterResponse): void { this.activePoster = poster; this.sendEvent(UPDATE_POSTER_EVENT_NAME, this.getState()); } diff --git a/src/modules/handlers/screen/poster/static/static-poster-controller.ts b/src/modules/handlers/screen/poster/static/static-poster-controller.ts index d112ee5a..a843e595 100644 --- a/src/modules/handlers/screen/poster/static/static-poster-controller.ts +++ b/src/modules/handlers/screen/poster/static/static-poster-controller.ts @@ -8,7 +8,7 @@ import { securityGroups } from '../../../../../helpers/security-groups'; import { Request as ExpressRequest } from 'express'; import logger from '../../../../../logger'; import { StaticPosterHandlerState } from '../static-poster-handler'; -import LocalPosterService from '../local/local-poster-service'; +import PosterService from '../local/poster-service'; interface SetClockRequest { visible: boolean; @@ -75,8 +75,8 @@ export class StaticPosterController extends Controller { @Security(SecurityNames.INTEGRATION, ['showStaticPoster']) @Post('items/{id}/show') public async showStaticPoster(id: number, @Request() req: ExpressRequest): Promise { - const service = new LocalPosterService(); - const poster = await service.getSingleLocalPoster(id); + const service = new PosterService(); + const poster = await service.getSinglePoster(id); const posterResponse = service.toResponse(poster); logger.audit(req.user, `Show static poster (id: ${id}).`); diff --git a/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts b/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts index b8ef8d3e..8d29eae8 100644 --- a/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts +++ b/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts @@ -1,8 +1,7 @@ -import { BasePoster, FooterSize, LocalPosterType, Poster, PosterType } from '../poster'; import { Board, Card, Checklist, TrelloClient, TrelloList } from './client'; -import LocalPosterService from '../local/local-poster-service'; +import PosterService from '../local/poster-service'; import axios from 'axios'; -import LocalPoster from '../local/local-poster'; +import Poster, { FooterSize, PosterType } from '../local/poster'; const DEFAULT_POSTER_TIMEOUT = 15; const DEFAULT_POSTER_REFRESH = 1000 * 60 * 15; @@ -11,7 +10,7 @@ export class TrelloPosterManager { private client: TrelloClient; private refreshTimeout: NodeJS.Timeout | undefined = undefined; - + constructor() { this.client = new TrelloClient(); } @@ -29,7 +28,7 @@ export class TrelloPosterManager { board: Board, listType?: PosterType, visitedLists: Set = new Set(), - ): Promise { + ): Promise { if (!list.id || visitedLists.has(list.id)) return []; visitedLists.add(list.id); @@ -76,7 +75,7 @@ export class TrelloPosterManager { }), ); - return posters.filter((p) => p !== undefined).flat() as LocalPoster[]; + return posters.filter((p) => p !== undefined).flat() as Poster[]; } /** @@ -86,7 +85,7 @@ export class TrelloPosterManager { * @param borrelMode * @private */ - private parseBasePoster(card: Card, checklists: Checklist[], borrelMode: boolean): BasePoster { + private parseBasePoster(card: Card, checklists: Checklist[], borrelMode: boolean) { // Find the index of the "timeout" checklist if it exists // @ts-ignore const indexTimeout = checklists.findIndex( @@ -134,14 +133,14 @@ export class TrelloPosterManager { checklists: Checklist[], type: PosterType.IMAGE | PosterType.VIDEO, borrelMode = false, - ): Promise { + ): Promise { const poster = this.parseBasePoster(card, checklists, borrelMode); if (!card.id) { return undefined; } - const service = new LocalPosterService(); + const service = new PosterService(); let localPoster = await service.createMediaPoster({ name: poster.name, type: type, @@ -178,7 +177,7 @@ export class TrelloPosterManager { card: Card, checklists: Checklist[], borrelMode = false, - ): Promise { + ): Promise { const index = checklists.findIndex((checklist) => checklist.name.toLowerCase() === 'photos'); // If such list cannot be found, it does not exist. Throw an error because we cannot continue if (index === undefined || index < 0) { @@ -189,7 +188,7 @@ export class TrelloPosterManager { const albums = checkList.checkItems.map((item: any) => item.name.split(' ')[0]); const poster = this.parseBasePoster(card, checklists, borrelMode); - const service = new LocalPosterService(); + const service = new PosterService(); return service.createPhotoPoster({ name: poster.name, type: PosterType.PHOTO, @@ -217,7 +216,7 @@ export class TrelloPosterManager { card: Card, checklists: Checklist[], borrelMode = false, - ): Promise { + ): Promise { const isUrl = (url: string): boolean => { try { const parsedUrl = new URL(url); @@ -237,7 +236,7 @@ export class TrelloPosterManager { } const poster = this.parseBasePoster(card, checklists, borrelMode); - const service = new LocalPosterService(); + const service = new PosterService(); return service.createExternalPoster({ name: poster.name, @@ -254,7 +253,7 @@ export class TrelloPosterManager { }); } - async reloadPosters(): Promise { + async reloadPosters(): Promise { let board = await this.client.default.getBoard(process.env.TRELLO_BOARD_ID || ''); if (!Object.prototype.hasOwnProperty.call(board, 'id')) throw new Error(JSON.stringify(board)); board = board as Board; @@ -266,7 +265,7 @@ export class TrelloPosterManager { const list = lists.find((l) => l.name === basePosterListName); if (!list) throw new Error(`Could not find the list called "${basePosterListName}"`); - const service = new LocalPosterService(); + const service = new PosterService(); await service.deleteTrelloPosters(); @@ -275,6 +274,6 @@ export class TrelloPosterManager { if (this.refreshTimeout) clearTimeout(this.refreshTimeout); this.refreshTimeout = setTimeout(this.reloadPosters.bind(this), DEFAULT_POSTER_REFRESH); - return await service.getAllLocalPosters(); + return await service.getAllPosters(); } } diff --git a/src/modules/timed-events/cron-manager.ts b/src/modules/timed-events/cron-manager.ts index b35e1b28..adadbecb 100644 --- a/src/modules/timed-events/cron-manager.ts +++ b/src/modules/timed-events/cron-manager.ts @@ -9,7 +9,7 @@ import RootLightsService from '../root/root-lights-service'; import RootScreenService from '../root/root-screen-service'; import { Screen } from '../root/entities'; import { StaticPosterHandler } from '../handlers/screen'; -import LocalPosterService from '../handlers/screen/poster/local/local-poster-service'; +import PosterService from '../handlers/screen/poster/local/poster-service'; export class CronExpressionError extends Error {} @@ -157,8 +157,8 @@ export default class CronManager { return; case 'timed-event-set-static-poster': { - const service = new LocalPosterService(); - const poster = await service.getSingleLocalPoster(spec.params.posterId); + const service = new PosterService(); + const poster = await service.getSinglePoster(spec.params.posterId); const handler = HandlerManager.getInstance() .getHandlers(Screen) .filter( diff --git a/src/seed/index.ts b/src/seed/index.ts index e5143d04..f1b38671 100644 --- a/src/seed/index.ts +++ b/src/seed/index.ts @@ -14,7 +14,9 @@ const program = new Command(); program .name('aurora-seeder') .description('Functions to seed the database.') - .addOption(new Option('--gewis', 'Seed for GEWIS').conflicts(['gewis-posters','hubble', 'disco-floor'])) + .addOption( + new Option('--gewis', 'Seed for GEWIS').conflicts(['gewis-posters', 'hubble', 'disco-floor']), + ) .addOption( new Option('--gewis-posters', 'Seed for GEWIS (Default posters only)').conflicts([ 'gewis', @@ -22,8 +24,16 @@ program 'disco-floor', ]), ) - .addOption(new Option('--hubble', 'Seed for Hubble').conflicts(['gewis', 'gewis-posters', 'disco-floor'])) - .addOption(new Option('--disco-floor', 'Seed the disco floor').conflicts(['gewis', 'gewis-posters', 'hubble'])) + .addOption( + new Option('--hubble', 'Seed for Hubble').conflicts(['gewis', 'gewis-posters', 'disco-floor']), + ) + .addOption( + new Option('--disco-floor', 'Seed the disco floor').conflicts([ + 'gewis', + 'gewis-posters', + 'hubble', + ]), + ) .option('-w, --width ', 'Width of the disco floor (int)', parseInt) .option('-h, --height ', 'Height of the disco floor (int)', parseInt) .option( diff --git a/src/seed/seedGewis.ts b/src/seed/seedGewis.ts index 0155bce1..5c0732fb 100644 --- a/src/seed/seedGewis.ts +++ b/src/seed/seedGewis.ts @@ -21,8 +21,7 @@ import { ColorEffects } from '../modules/lights/effects/color/color-effects'; import { MovementEffects } from '../modules/lights/effects/movement/movement-effetcs'; import { TimedEvent } from '../modules/timed-events/entities'; import { IntegrationUserService } from '../modules/auth/integration'; -import LocalPoster from '../modules/handlers/screen/poster/local/local-poster'; -import { FooterSize, PosterType } from '../modules/handlers/screen/poster/poster'; +import Poster, { FooterSize, PosterType } from '../modules/handlers/screen/poster/local/poster'; export default async function seedDatabase() { const timedEventsRepo = dataSource.getRepository(TimedEvent); @@ -778,7 +777,7 @@ export async function seedOpeningSequence( } export async function seedPosters() { - const repo = dataSource.getRepository(LocalPoster); + const repo = dataSource.getRepository(Poster); await repo.save([ { @@ -847,5 +846,5 @@ export async function seedPosters() { defaultTimeout: 15, trello: false, }, - ] as LocalPoster[]); + ] as Poster[]); } From 3fa1f42113be3301801f7873b514fe6486a6658c Mon Sep 17 00:00:00 2001 From: Wanderer Date: Thu, 4 Jun 2026 13:42:11 +0200 Subject: [PATCH 35/58] fix: fixed issue where posters generated from email stored wrong file --- .../screen/poster/trello/trello-poster-manager.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts b/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts index 8d29eae8..c16c6334 100644 --- a/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts +++ b/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts @@ -155,15 +155,20 @@ export class TrelloPosterManager { }); const attachments = await this.client.default.getCardAttachments(card.id); + const mediaAttachment = attachments.find((a) => + a.mimeType.startsWith(type === PosterType.IMAGE ? 'image/' : 'video/'), + ); + if (!mediaAttachment) return undefined; + const headers = { Authorization: `OAuth oauth_consumer_key="${process.env.TRELLO_KEY}", oauth_token="${process.env.TRELLO_TOKEN}"`, }; - const resp = await axios.get(attachments[0].url, { + const resp = await axios.get(mediaAttachment.url, { responseType: 'arraybuffer', headers, }); - return service.attachMedia(localPoster.id, attachments[0].fileName, Buffer.from(resp.data)); + return service.attachMedia(localPoster.id, mediaAttachment.fileName, Buffer.from(resp.data)); } /** From 59660f41ee7ea9dd3ab0dde7c600894daefa5750 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Thu, 4 Jun 2026 15:47:15 +0200 Subject: [PATCH 36/58] feat: reintroduced support for multiple imgs in one poster --- .../screen/poster/local/poster-service.ts | 40 +++++++++---------- .../handlers/screen/poster/local/poster.ts | 8 ++-- .../poster/trello/trello-poster-manager.ts | 23 +++++++---- 3 files changed, 39 insertions(+), 32 deletions(-) diff --git a/src/modules/handlers/screen/poster/local/poster-service.ts b/src/modules/handlers/screen/poster/local/poster-service.ts index b0126a34..bc5a013f 100644 --- a/src/modules/handlers/screen/poster/local/poster-service.ts +++ b/src/modules/handlers/screen/poster/local/poster-service.ts @@ -68,7 +68,7 @@ export interface PosterResponse { protected: boolean; uri?: string; albums?: number[]; - file?: FileResponse; + files: FileResponse[]; } export default class PosterService { @@ -89,16 +89,13 @@ export default class PosterService { * @param poster The poster to be converted. */ public toResponse(poster: Poster): PosterResponse { - let file: PosterResponse['file']; - if (poster.file) { - const location = this.storage.getPublicFileUri(poster.file); + const files: FileResponse[] = (poster.files ?? []).reduce((acc, file) => { + const location = this.storage.getPublicFileUri(file); if (location) { - file = { - location, - name: poster.file.originalName, - }; + acc.push({ location, name: file.originalName }); } - } + return acc; + }, []); return { id: poster.id, @@ -117,7 +114,7 @@ export default class PosterService { protected: poster.protected, uri: poster.uri ?? undefined, albums: poster.albums ?? undefined, - file: file, + files, }; } @@ -187,14 +184,10 @@ export default class PosterService { ); } - if (poster.file) { - await this.fileRepo.delete(poster.file.id); - await this.storage.deleteFile(poster.file); - } - const fileParams = await this.storage.saveFile(filename, filedata); try { - poster.file = await this.fileRepo.save(fileParams); + const file = await this.fileRepo.save(fileParams); + poster.files = [...(poster.files ?? []), file]; return this.repo.save(poster); } catch (error) { await this.storage.deleteFile(fileParams); @@ -274,11 +267,16 @@ export default class PosterService { */ public async deletePoster(id: number): Promise { const poster = await this.getSinglePoster(id); - if (poster.file) { - await this.fileRepo.delete(poster.file.id); - await this.storage.deleteFile(poster.file); - } + const files = poster.files ?? []; + // Removing the poster clears the owning-side join-table rows, after which + // the now-orphaned files can be deleted from the database and disk. await this.repo.remove(poster); + await Promise.all( + files.map(async (file) => { + await this.fileRepo.delete(file.id); + await this.storage.deleteFile(file); + }), + ); } /** @@ -346,7 +344,7 @@ export default class PosterService { await this.repo.save({ name: staticPoster.file.originalName, type: mimeType && mimeType.startsWith('video/') ? PosterType.VIDEO : PosterType.IMAGE, - file, + files: [file], enabled: false, }); diff --git a/src/modules/handlers/screen/poster/local/poster.ts b/src/modules/handlers/screen/poster/local/poster.ts index 60f65511..8eba1181 100644 --- a/src/modules/handlers/screen/poster/local/poster.ts +++ b/src/modules/handlers/screen/poster/local/poster.ts @@ -1,4 +1,4 @@ -import { Entity, Column, OneToOne, JoinColumn } from 'typeorm'; +import { Entity, Column, ManyToMany, JoinTable } from 'typeorm'; import BaseEntity from '../../../../root/entities/base-entity'; import { File } from '../../../../files/entities'; @@ -73,9 +73,9 @@ export default class Poster extends BaseEntity { @Column({ type: 'simple-array', nullable: true }) albums?: number[]; - @OneToOne(() => File, { nullable: true, eager: true, onDelete: 'SET NULL' }) - @JoinColumn() - file?: File; + @ManyToMany(() => File, { eager: true }) + @JoinTable() + files: File[]; @Column({ default: false }) trello: boolean; diff --git a/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts b/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts index c16c6334..06f2a452 100644 --- a/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts +++ b/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts @@ -155,20 +155,29 @@ export class TrelloPosterManager { }); const attachments = await this.client.default.getCardAttachments(card.id); - const mediaAttachment = attachments.find((a) => + const mediaAttachments = attachments.filter((a) => a.mimeType.startsWith(type === PosterType.IMAGE ? 'image/' : 'video/'), ); - if (!mediaAttachment) return undefined; + if (mediaAttachments.length === 0) return undefined; const headers = { Authorization: `OAuth oauth_consumer_key="${process.env.TRELLO_KEY}", oauth_token="${process.env.TRELLO_TOKEN}"`, }; - const resp = await axios.get(mediaAttachment.url, { - responseType: 'arraybuffer', - headers, - }); - return service.attachMedia(localPoster.id, mediaAttachment.fileName, Buffer.from(resp.data)); + // Attach every matching attachment sequentially so each appends to the poster's files. + for (const attachment of mediaAttachments) { + const resp = await axios.get(attachment.url, { + responseType: 'arraybuffer', + headers, + }); + localPoster = await service.attachMedia( + localPoster.id, + attachment.fileName, + Buffer.from(resp.data), + ); + } + + return localPoster; } /** From 65bf9f92a71d21387c6a56edb0c54be95da73775 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Mon, 8 Jun 2026 19:40:43 +0200 Subject: [PATCH 37/58] feat: add better handling of the trello posters --- .../handlers/screen/poster/local/poster.ts | 6 + .../poster/trello/trello-poster-manager.ts | 139 ++++++++++++------ 2 files changed, 103 insertions(+), 42 deletions(-) diff --git a/src/modules/handlers/screen/poster/local/poster.ts b/src/modules/handlers/screen/poster/local/poster.ts index 8eba1181..31e101c4 100644 --- a/src/modules/handlers/screen/poster/local/poster.ts +++ b/src/modules/handlers/screen/poster/local/poster.ts @@ -79,4 +79,10 @@ export default class Poster extends BaseEntity { @Column({ default: false }) trello: boolean; + + @Column({ type: 'text', nullable: true }) + trelloCardId?: string; + + @Column({ type: 'text', nullable: true }) + trelloLastActivity?: string; } diff --git a/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts b/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts index 06f2a452..bdbdd354 100644 --- a/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts +++ b/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts @@ -2,6 +2,13 @@ import { Board, Card, Checklist, TrelloClient, TrelloList } from './client'; import PosterService from '../local/poster-service'; import axios from 'axios'; import Poster, { FooterSize, PosterType } from '../local/poster'; +import dataSource from '../../../../../database'; + +interface CardEntry { + card: Card; + checklists: Checklist[]; + type: PosterType; +} const DEFAULT_POSTER_TIMEOUT = 15; const DEFAULT_POSTER_REFRESH = 1000 * 60 * 15; @@ -16,19 +23,21 @@ export class TrelloPosterManager { } /** - * Parse a Trello list recursively to a list of posters + * Recursively resolve a Trello list to the leaf cards that should become posters. + * This performs no database writes; it only decides which cards map to which poster + * type, so the caller can reconcile them against the existing posters. * @param list * @param board * @param listType * @param visitedLists * @private */ - private async parseLists( + private collectCards( list: TrelloList, board: Board, listType?: PosterType, visitedLists: Set = new Set(), - ): Promise { + ): CardEntry[] { if (!list.id || visitedLists.has(list.id)) return []; visitedLists.add(list.id); @@ -36,46 +45,67 @@ export class TrelloPosterManager { const cards = allCards?.filter((card) => card.idList === list.id) || []; const now = new Date(); + const entries: CardEntry[] = []; + + for (const card of cards) { + const labels = card.labels?.map((l) => l.name ?? '') ?? []; + const checklists = + allChecklists?.filter((checklist) => card.idChecklists?.includes(checklist.id)) ?? []; + + // A card can be two things: a poster, or a reference to a new list of cards. + // If it has the correct label ("Posterlist"), it means the card is a reference to a list + if (labels.includes('Posterlist')) { + const newList = allLists?.find((l) => l.name === card.name); + if (!newList) throw new Error(`Unknown list: ${card.name}`); + entries.push(...this.collectCards(newList, board, card.desc as PosterType, visitedLists)); + continue; + } - const posters = await Promise.all( - cards.map(async (card) => { - const labels = card.labels?.map((l) => l.name ?? '') ?? []; - const checklists = - allChecklists?.filter((checklist) => card.idChecklists?.includes(checklist.id)) ?? []; - - // A card can be two things: a poster, or a reference to a new list of cards. - // If it has the correct label ("Posterlist"), it means the card is a reference to a list - if (labels.includes('Posterlist')) { - const newList = allLists?.find((l) => l.name === card.name); - if (newList) { - return this.parseLists(newList, board, card.desc as PosterType, visitedLists); - } - throw new Error(`Unknown list: ${card.name}`); - } - - const borrelMode = labels.includes('BorrelMode'); - - // If the card has a due date and this due date is in the past, skip this card - if (card.due && new Date(card.due) < now) return undefined; - - switch (listType) { - case 'img': - return this.parseMediaPoster(card, checklists, PosterType.IMAGE); - case 'video': - return this.parseMediaPoster(card, checklists, PosterType.VIDEO); - case 'extern': - return this.parseExternalPoster(card, checklists); - case 'photo': - return this.parsePhotoPoster(card, checklists); - default: - break; - } + // If the card has a due date and this due date is in the past, skip this card + if (card.due && new Date(card.due) < now) continue; + + let type: PosterType | undefined; + switch (listType) { + case 'img': + type = PosterType.IMAGE; + break; + case 'video': + type = PosterType.VIDEO; + break; + case 'extern': + type = PosterType.EXTERNAL; + break; + case 'photo': + type = PosterType.PHOTO; + break; + default: + break; + } - return undefined; - }), - ); + if (type) entries.push({ card, checklists, type }); + } + + return entries; + } - return posters.filter((p) => p !== undefined).flat() as Poster[]; + /** + * Create a single local poster from a resolved card entry. + * @param entry + * @private + */ + private async createPoster(entry: CardEntry): Promise { + const { card, checklists, type } = entry; + switch (type) { + case PosterType.IMAGE: + case PosterType.VIDEO: + return this.parseMediaPoster(card, checklists, type); + case PosterType.EXTERNAL: + return this.parseExternalPoster(card, checklists); + case PosterType.PHOTO: + return this.parsePhotoPoster(card, checklists); + default: + return undefined; + } } /** @@ -280,10 +310,35 @@ export class TrelloPosterManager { if (!list) throw new Error(`Could not find the list called "${basePosterListName}"`); const service = new PosterService(); + const repo = dataSource.getRepository(Poster); - await service.deleteTrelloPosters(); + const desired = this.collectCards(list, board); + const desiredById = new Map(desired.filter((d) => d.card.id).map((d) => [d.card.id!, d])); - await this.parseLists(list, board); + const existing = (await service.getAllPosters()).filter((p) => p.trello); + const existingById = new Map( + existing.filter((p) => p.trelloCardId).map((p) => [p.trelloCardId!, p]), + ); + + for (const poster of existing) { + const entry = poster.trelloCardId ? desiredById.get(poster.trelloCardId) : undefined; + if (!entry || poster.trelloLastActivity !== entry.card.dateLastActivity) { + await service.deletePoster(poster.id); + } + } + + for (const entry of desired) { + const previous = entry.card.id ? existingById.get(entry.card.id) : undefined; + if (previous && previous.trelloLastActivity === entry.card.dateLastActivity) continue; + + const created = await this.createPoster(entry); + if (created) { + await repo.update(created.id, { + trelloCardId: entry.card.id, + trelloLastActivity: entry.card.dateLastActivity, + }); + } + } if (this.refreshTimeout) clearTimeout(this.refreshTimeout); this.refreshTimeout = setTimeout(this.reloadPosters.bind(this), DEFAULT_POSTER_REFRESH); From 86faaaf1e1a90966c031a17fab643dbdb2006643 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Mon, 8 Jun 2026 20:13:24 +0200 Subject: [PATCH 38/58] feat: refactor poster-service to be cleaner and more in line with api --- .../screen/poster/local/poster-controller.ts | 21 +--- .../screen/poster/local/poster-service.ts | 116 +++--------------- .../poster/trello/trello-poster-manager.ts | 6 +- 3 files changed, 24 insertions(+), 119 deletions(-) diff --git a/src/modules/handlers/screen/poster/local/poster-controller.ts b/src/modules/handlers/screen/poster/local/poster-controller.ts index b52defa3..7e7a4e24 100644 --- a/src/modules/handlers/screen/poster/local/poster-controller.ts +++ b/src/modules/handlers/screen/poster/local/poster-controller.ts @@ -8,7 +8,7 @@ import PosterService, { PosterResponse, UpdatePosterRequest, } from './poster-service'; -import Poster, { PosterType } from './poster'; +import { PosterType } from './poster'; import { lookup } from 'mime-types'; import { FeatureEnabled } from '../../../../server-settings'; @@ -51,22 +51,11 @@ export class PosterController extends Controller { @Res() invalidPosterTypeResponse: TsoaResponse, ): Promise { - let poster: Poster; - switch (body.type) { - case PosterType.IMAGE: - case PosterType.VIDEO: - poster = await this.service.createMediaPoster(body); - break; - case PosterType.EXTERNAL: - poster = await this.service.createExternalPoster(body); - break; - case PosterType.PHOTO: - poster = await this.service.createPhotoPoster(body); - break; - default: { - return invalidPosterTypeResponse(HttpStatusCode.BadRequest, 'Unknown Poster Type'); - } + const validTypes = [PosterType.IMAGE, PosterType.VIDEO, PosterType.EXTERNAL, PosterType.PHOTO]; + if (!validTypes.includes(body.type)) { + return invalidPosterTypeResponse(HttpStatusCode.BadRequest, 'Unknown Poster Type'); } + const poster = await this.service.createPoster(body); return this.service.toResponse(poster); } diff --git a/src/modules/handlers/screen/poster/local/poster-service.ts b/src/modules/handlers/screen/poster/local/poster-service.ts index bc5a013f..71b90f8f 100644 --- a/src/modules/handlers/screen/poster/local/poster-service.ts +++ b/src/modules/handlers/screen/poster/local/poster-service.ts @@ -138,34 +138,24 @@ export default class PosterService { } /** - * Creates a new Local Poster with the media type in the database. - * This does not yet contain the actual image or video of the poster. - * @param params Metadata of the poster as specified in the MediaPosterParams interface. + * Creates a new Local Poster of the given type in the database. + * For media posters this does not yet contain the actual image or video. + * @param params Metadata of the poster as specified in CreatePosterRequest. */ - public async createMediaPoster(params: MediaPosterRequest): Promise { - const { - name, - label, - type, - startDate, - expirationDate, - accentColor, - footerSize, - defaultTimeout, - borrelMode, - trello, - } = params; + public async createPoster(params: CreatePosterRequest): Promise { return this.repo.save({ - name, - label, - type, - startDate, - expirationDate, - accentColor, - footerSize, - defaultTimeout, - borrelMode, - trello, + name: params.name, + label: params.label, + type: params.type, + startDate: params.startDate, + expirationDate: params.expirationDate, + accentColor: params.accentColor, + footerSize: params.footerSize, + defaultTimeout: params.defaultTimeout, + borrelMode: params.borrelMode, + trello: params.trello, + uri: params.type === PosterType.EXTERNAL ? params.uri : undefined, + albums: params.type === PosterType.PHOTO ? params.albums : undefined, }); } @@ -195,72 +185,6 @@ export default class PosterService { } } - /** - * Creates a new Local Poster of the url type. - * @param params The specifics of the poster as specified in the UrlPosterParams interface. - */ - public async createExternalPoster(params: ExternalPosterRequest): Promise { - const { - name, - label, - type, - startDate, - expirationDate, - accentColor, - footerSize, - defaultTimeout, - borrelMode, - uri, - trello, - } = params; - return this.repo.save({ - name, - label, - type, - startDate, - expirationDate, - accentColor, - footerSize, - defaultTimeout, - borrelMode, - uri, - trello, - }); - } - - /** - * Creates a new Local Poster of the photo type. - * @param params The specifics of the poster as specified in the PhotoPosterParams interface. - */ - public async createPhotoPoster(params: PhotoPosterRequest): Promise { - const { - name, - label, - type, - startDate, - expirationDate, - accentColor, - footerSize, - defaultTimeout, - borrelMode, - albums, - trello, - } = params; - return this.repo.save({ - name, - label, - type, - startDate, - expirationDate, - accentColor, - footerSize, - defaultTimeout, - borrelMode, - albums, - trello, - }); - } - /** * Deletes the given poster from the database and storage. * @param id The id of the poster to be deleted. @@ -301,14 +225,6 @@ export default class PosterService { return this.repo.save(poster); } - /** - * Deletes every poster with the trello flag from the database. - */ - public async deleteTrelloPosters(): Promise { - const posters = await this.repo.find({ where: { trello: true } }); - await Promise.all(posters.map((poster) => this.deletePoster(poster.id))); - } - /** * Checks whether the legacy static poster table exists, and if it does, migrates * every static poster. diff --git a/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts b/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts index bdbdd354..45563683 100644 --- a/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts +++ b/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts @@ -171,7 +171,7 @@ export class TrelloPosterManager { } const service = new PosterService(); - let localPoster = await service.createMediaPoster({ + let localPoster = await service.createPoster({ name: poster.name, type: type, label: poster.label, @@ -233,7 +233,7 @@ export class TrelloPosterManager { const poster = this.parseBasePoster(card, checklists, borrelMode); const service = new PosterService(); - return service.createPhotoPoster({ + return service.createPoster({ name: poster.name, type: PosterType.PHOTO, label: poster.label, @@ -282,7 +282,7 @@ export class TrelloPosterManager { const poster = this.parseBasePoster(card, checklists, borrelMode); const service = new PosterService(); - return service.createExternalPoster({ + return service.createPoster({ name: poster.name, type: PosterType.EXTERNAL, label: poster.label, From 42e1c30607950336117efe46f340a9b6993b0cf2 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Tue, 9 Jun 2026 15:06:36 +0200 Subject: [PATCH 39/58] feat: add reordering of carousel posters --- src/database.ts | 3 +- .../poster/carousel-poster-controller.ts | 25 ++++++++++++ .../poster/local/carousel-poster-service.ts | 40 +++++++++++++++++++ .../screen/poster/local/local-carousel.ts | 19 ++------- .../screen/poster/local/poster-service.ts | 2 + 5 files changed, 71 insertions(+), 18 deletions(-) create mode 100644 src/modules/handlers/screen/poster/local/carousel-poster-service.ts diff --git a/src/database.ts b/src/database.ts index b9b571cf..800f4143 100644 --- a/src/database.ts +++ b/src/database.ts @@ -11,7 +11,7 @@ import { Entities as LightsEntities } from './modules/lights/entities'; import { Entities as TimedEventsEntities } from './modules/timed-events/entities'; import StaticPoster from './modules/handlers/screen/poster/static/static-poster'; import Poster from './modules/handlers/screen/poster/local/poster'; -import Carousel, { CarouselPoster } from './modules/handlers/screen/poster/local/local-carousel'; +import Carousel from './modules/handlers/screen/poster/local/local-carousel'; const dataSource = new DataSource({ host: process.env.TYPEORM_HOST, @@ -47,7 +47,6 @@ const dataSource = new DataSource({ StaticPoster, Poster, Carousel, - CarouselPoster, ], }); diff --git a/src/modules/handlers/screen/poster/carousel-poster-controller.ts b/src/modules/handlers/screen/poster/carousel-poster-controller.ts index 4af46d43..76a041cf 100644 --- a/src/modules/handlers/screen/poster/carousel-poster-controller.ts +++ b/src/modules/handlers/screen/poster/carousel-poster-controller.ts @@ -14,6 +14,7 @@ import { Controller } from '@tsoa/runtime'; import { ISettings } from '../../../server-settings/server-setting'; import { PosterResponse } from './local/poster-service'; import Poster from './local/poster'; +import CarouselPosterService, { CAROUSEL_ID } from './local/carousel-poster-service'; export interface BorrelModeParams { enabled: boolean; @@ -32,6 +33,10 @@ export interface CarouselResponse { borrelMode: boolean; } +export interface CarouselOrderParams { + posterIds: number[]; +} + @Route('handler/screen/poster/carousel') @Tags('Handlers') @FeatureEnabled('Poster') @@ -64,12 +69,32 @@ export class CarouselPosterController extends Controller { visible = this.screenHandler.borrelMode ? active : active.filter((p) => !p.borrelMode); } + const order = await new CarouselPosterService().getOrder(CAROUSEL_ID); + const rank = new Map(order.map((id, i) => [id, i])); + visible.sort((a, b) => (rank.get(a.id) ?? Infinity) - (rank.get(b.id) ?? Infinity)); + return { posters: visible.map((p) => this.screenHandler.posterService.toResponse(p)), borrelMode: this.screenHandler.borrelMode, }; } + @Security(SecurityNames.LOCAL, securityGroups.poster.base) + @Get('order') + public async getCarouselOrder(): Promise { + return new CarouselPosterService().getOrder(CAROUSEL_ID); + } + + @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) + @Put('order') + public async setCarouselOrder( + @Request() req: ExpressRequest, + @Body() body: CarouselOrderParams, + ): Promise { + logger.audit(req.user, 'Reorder poster carousel.'); + await new CarouselPosterService().setOrder(CAROUSEL_ID, body.posterIds); + } + @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) @Post('force-update') public async forceUpdatePosters(@Request() req: ExpressRequest): Promise { diff --git a/src/modules/handlers/screen/poster/local/carousel-poster-service.ts b/src/modules/handlers/screen/poster/local/carousel-poster-service.ts new file mode 100644 index 00000000..06fed2cb --- /dev/null +++ b/src/modules/handlers/screen/poster/local/carousel-poster-service.ts @@ -0,0 +1,40 @@ +import { Repository } from 'typeorm'; +import Carousel from './local-carousel'; +import dataSource from '../../../../../database'; + +/** + * Id of the single carousel currently in use. Once multiple carousels are + * supported this hardcoded value can be replaced by a real selection. + */ +export const CAROUSEL_ID = 1; + +export default class CarouselPosterService { + private repo: Repository; + + constructor() { + this.repo = dataSource.getRepository(Carousel); + } + + /** + * Returns the ordered poster IDs of the given carousel. + * @param carouselId The id of the carousel to read. + */ + public async getOrder(carouselId: number): Promise { + const carousel = await this.repo.findOne({ where: { id: carouselId } }); + // TypeORM's 'simple-array' deserializes to strings, so coerce back to numbers. + return (carousel?.posterOrder ?? []).map(Number); + } + + /** + * Persists the given ordered poster IDs on the given carousel, creating it if needed. + * @param carouselId The id of the carousel to write. + * @param posterIds The poster IDs in the desired display order. + */ + public async setOrder(carouselId: number, posterIds: number[]): Promise { + const carousel = + (await this.repo.findOne({ where: { id: carouselId } })) ?? + this.repo.create({ id: carouselId, name: 'default' }); + carousel.posterOrder = posterIds; + await this.repo.save(carousel); + } +} diff --git a/src/modules/handlers/screen/poster/local/local-carousel.ts b/src/modules/handlers/screen/poster/local/local-carousel.ts index 0953d74d..eba3a4e9 100644 --- a/src/modules/handlers/screen/poster/local/local-carousel.ts +++ b/src/modules/handlers/screen/poster/local/local-carousel.ts @@ -1,6 +1,5 @@ -import { Entity, Column, OneToMany, ManyToOne } from 'typeorm'; +import { Entity, Column } from 'typeorm'; import BaseEntity from '../../../../root/entities/base-entity'; -import Poster from './poster'; @Entity() export default class Carousel extends BaseEntity { @@ -10,18 +9,6 @@ export default class Carousel extends BaseEntity { @Column({ default: true }) active: boolean; - @OneToMany(() => CarouselPoster, (cp) => cp.carousel) - posters: CarouselPoster[]; -} - -@Entity() -export class CarouselPoster extends BaseEntity { - @ManyToOne(() => Carousel, (c) => c.posters, { onDelete: 'CASCADE' }) - carousel: Carousel; - - @ManyToOne(() => Poster, { onDelete: 'CASCADE' }) - poster: Poster; - - @Column({ nullable: true }) - customTimeout?: number; + @Column({ type: 'simple-array', nullable: true }) + posterOrder?: number[]; } diff --git a/src/modules/handlers/screen/poster/local/poster-service.ts b/src/modules/handlers/screen/poster/local/poster-service.ts index 71b90f8f..a281502b 100644 --- a/src/modules/handlers/screen/poster/local/poster-service.ts +++ b/src/modules/handlers/screen/poster/local/poster-service.ts @@ -66,6 +66,7 @@ export interface PosterResponse { defaultTimeout: number; borrelMode: boolean; protected: boolean; + trello: boolean; uri?: string; albums?: number[]; files: FileResponse[]; @@ -112,6 +113,7 @@ export default class PosterService { defaultTimeout: poster.defaultTimeout, borrelMode: poster.borrelMode, protected: poster.protected, + trello: poster.trello, uri: poster.uri ?? undefined, albums: poster.albums ?? undefined, files, From 94500fa3a84670662be6401d4ee4b1e48f953151 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 10 Jun 2026 16:01:20 +0200 Subject: [PATCH 40/58] feat: add migration for poster tables and data migration for static posters --- src/database.ts | 5 +- src/index.ts | 3 - .../1781087463209-PosterMigration.ts | 131 ++++++++++++++++++ .../screen/poster/local/poster-service.ts | 69 --------- .../handlers/screen/poster/local/poster.ts | 11 +- .../screen/poster/static/static-poster.ts | 19 --- 6 files changed, 135 insertions(+), 103 deletions(-) create mode 100644 src/migrations/1781087463209-PosterMigration.ts delete mode 100644 src/modules/handlers/screen/poster/static/static-poster.ts diff --git a/src/database.ts b/src/database.ts index f3a7c1a8..4ab83e02 100644 --- a/src/database.ts +++ b/src/database.ts @@ -10,10 +10,10 @@ import { Entities as AuditEntities } from './modules/audit/entities'; import { Entities as SpotifyEntities } from './modules/spotify/entities'; import { Entities as LightsEntities } from './modules/lights/entities'; import { Entities as TimedEventsEntities } from './modules/timed-events/entities'; -import StaticPoster from './modules/handlers/screen/poster/static/static-poster'; import Poster from './modules/handlers/screen/poster/local/poster'; import Carousel from './modules/handlers/screen/poster/local/local-carousel'; import { InitialMigration1780248780327 } from './migrations/1780248780327-InitialMigration'; +import { PosterMigration1781087463209 } from './migrations/1781087463209-PosterMigration'; const dataSource = new DataSource({ host: process.env.TYPEORM_HOST, @@ -31,7 +31,7 @@ const dataSource = new DataSource({ : {}), synchronize: process.env.TYPEORM_SYNCHRONIZE === 'true', logging: process.env.TYPEORM_LOGGING === 'true', - migrations: [InitialMigration1780248780327], + migrations: [InitialMigration1780248780327, PosterMigration1781087463209], extra: { authPlugins: { mysql_clear_password: () => () => Buffer.from(`${process.env.TYPEORM_PASSWORD}\0`), @@ -47,7 +47,6 @@ const dataSource = new DataSource({ ...AuditEntities, ...SpotifyEntities, ...LightsEntities, - StaticPoster, Poster, Carousel, ], diff --git a/src/index.ts b/src/index.ts index 367bf0de..d3e523f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,7 +26,6 @@ import { OrderManager } from './modules/orders'; import TimedEventsService from './modules/timed-events/timed-events-service'; import LightsSwitchManager from './modules/root/lights-switch-manager'; import { TrelloPosterManager } from './modules/handlers/screen/poster/trello/trello-poster-manager'; -import PosterService from './modules/handlers/screen/poster/local/poster-service'; async function createApp(): Promise { // Fix for production issue where a Docker volume overwrites the contents of a folder instead of merging them @@ -47,8 +46,6 @@ async function createApp(): Promise { await dataSource.initialize(); - await new PosterService().migrateStaticPosters(); - await ServerSettingsStore.getInstance().initialize(); const featureFlagManager = new FeatureFlagManager(); await TimedEventsService.getInstance().registerAllDatabaseEvents(); diff --git a/src/migrations/1781087463209-PosterMigration.ts b/src/migrations/1781087463209-PosterMigration.ts new file mode 100644 index 00000000..36b4b227 --- /dev/null +++ b/src/migrations/1781087463209-PosterMigration.ts @@ -0,0 +1,131 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { lookup } from 'mime-types'; +import { DiskStorage } from '../modules/files/storage'; +import logger from '../logger'; + +export class PosterMigration1781087463209 implements MigrationInterface { + name = 'PosterMigration1781087463209'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE \`poster\` (\`id\` int NOT NULL AUTO_INCREMENT, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`name\` varchar(255) NOT NULL, \`type\` varchar(255) NOT NULL, \`enabled\` tinyint NOT NULL DEFAULT 1, \`label\` varchar(255) NULL, \`startDate\` datetime NULL, \`expirationDate\` datetime NULL, \`accentColor\` varchar(255) NULL, \`protected\` tinyint NOT NULL DEFAULT 0, \`borrelMode\` tinyint NOT NULL DEFAULT 0, \`footerSize\` varchar(255) NOT NULL DEFAULT 'full', \`defaultTimeout\` int NOT NULL DEFAULT '15', \`uri\` varchar(255) NULL, \`albums\` text NULL, \`trello\` tinyint NOT NULL DEFAULT 0, \`trelloCardId\` text NULL, \`trelloLastActivity\` text NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`, + ); + await queryRunner.query( + `CREATE TABLE \`carousel\` (\`id\` int NOT NULL AUTO_INCREMENT, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`name\` varchar(255) NOT NULL, \`active\` tinyint NOT NULL DEFAULT 1, \`posterOrder\` text NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`, + ); + await queryRunner.query( + `CREATE TABLE \`poster_files_file\` (\`posterId\` int NOT NULL, \`fileId\` int NOT NULL, INDEX \`IDX_b19b6773fc34c90a859006b2fa\` (\`posterId\`), INDEX \`IDX_445c020e803ef2f8d6778588ac\` (\`fileId\`), PRIMARY KEY (\`posterId\`, \`fileId\`)) ENGINE=InnoDB`, + ); + await queryRunner.query( + `ALTER TABLE \`poster_files_file\` ADD CONSTRAINT \`FK_b19b6773fc34c90a859006b2fab\` FOREIGN KEY (\`posterId\`) REFERENCES \`poster\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE \`poster_files_file\` ADD CONSTRAINT \`FK_445c020e803ef2f8d6778588ac9\` FOREIGN KEY (\`fileId\`) REFERENCES \`file\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`, + ); + + await this.migrateLocalPosters(queryRunner); + + await queryRunner.query( + `ALTER TABLE \`local_poster\` DROP FOREIGN KEY \`FK_148ccb05f5a86819c7dae1c03d5\``, + ); + await queryRunner.query(`DROP INDEX \`REL_148ccb05f5a86819c7dae1c03d\` ON \`local_poster\``); + await queryRunner.query(`DROP TABLE \`local_poster\``); + } + + /** + * Convert every local_poster row into a poster row. Media files are moved on disk + * to the posters storage directory and get a fresh file record; external posters + * keep their uri. Migrated posters start disabled. + */ + private async migrateLocalPosters(queryRunner: QueryRunner): Promise { + const rows: { + id: number; + uri: string | null; + fileId: number | null; + relativeDirectory: string | null; + name: string | null; + originalName: string | null; + }[] = await queryRunner.query( + `SELECT lp.\`id\`, lp.\`uri\`, f.\`id\` AS \`fileId\`, f.\`relativeDirectory\`, f.\`name\`, f.\`originalName\` + FROM \`local_poster\` lp LEFT JOIN \`file\` f ON f.\`id\` = lp.\`fileId\``, + ); + if (rows.length === 0) return; + + const storage = new DiskStorage('posters'); + let migrated = 0; + + for (const row of rows) { + try { + if (row.fileId != null) { + const oldFile = { + relativeDirectory: row.relativeDirectory!, + name: row.name!, + originalName: row.originalName!, + }; + const sourceStorage = new DiskStorage( + oldFile.relativeDirectory.replace(/^public[\\/]/, ''), + ); + const data = await sourceStorage.getFile(oldFile); + + const fileParams = await storage.saveFile(oldFile.originalName, data); + const fileInsert = await queryRunner.query( + `INSERT INTO \`file\` (\`relativeDirectory\`, \`name\`, \`originalName\`) VALUES (?, ?, ?)`, + [fileParams.relativeDirectory, fileParams.name, fileParams.originalName], + ); + + const mimeType = lookup(oldFile.originalName); + const posterType = mimeType && mimeType.startsWith('video/') ? 'video' : 'img'; + const posterInsert = await queryRunner.query( + `INSERT INTO \`poster\` (\`name\`, \`type\`, \`enabled\`) VALUES (?, ?, 0)`, + [oldFile.originalName, posterType], + ); + await queryRunner.query( + `INSERT INTO \`poster_files_file\` (\`posterId\`, \`fileId\`) VALUES (?, ?)`, + [posterInsert.insertId, fileInsert.insertId], + ); + + await sourceStorage.deleteFile(oldFile); + await queryRunner.query(`DELETE FROM \`file\` WHERE \`id\` = ?`, [row.fileId]); + } else if (row.uri) { + await queryRunner.query( + `INSERT INTO \`poster\` (\`name\`, \`type\`, \`enabled\`, \`uri\`) VALUES (?, 'extern', 0, ?)`, + [row.uri, row.uri], + ); + } else { + logger.warn(`Skipping local poster ${row.id}: it has neither a file nor a uri.`); + continue; + } + migrated += 1; + } catch (error) { + logger.error(`Failed to migrate local poster ${row.id}: ${error}`); + } + } + + logger.info(`Migrated ${migrated} local poster(s) to posters.`); + } + + public async down(queryRunner: QueryRunner): Promise { + // Schema-only reversal: posters migrated from local_poster are not converted back. + await queryRunner.query( + `ALTER TABLE \`poster_files_file\` DROP FOREIGN KEY \`FK_445c020e803ef2f8d6778588ac9\``, + ); + await queryRunner.query( + `ALTER TABLE \`poster_files_file\` DROP FOREIGN KEY \`FK_b19b6773fc34c90a859006b2fab\``, + ); + await queryRunner.query( + `DROP INDEX \`IDX_445c020e803ef2f8d6778588ac\` ON \`poster_files_file\``, + ); + await queryRunner.query( + `DROP INDEX \`IDX_b19b6773fc34c90a859006b2fa\` ON \`poster_files_file\``, + ); + await queryRunner.query(`DROP TABLE \`poster_files_file\``); + await queryRunner.query(`DROP TABLE \`carousel\``); + await queryRunner.query(`DROP TABLE \`poster\``); + await queryRunner.query( + `CREATE TABLE \`local_poster\` (\`id\` int NOT NULL AUTO_INCREMENT, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`uri\` varchar(255) NULL, \`fileId\` int NULL, UNIQUE INDEX \`REL_148ccb05f5a86819c7dae1c03d\` (\`fileId\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`, + ); + await queryRunner.query( + `ALTER TABLE \`local_poster\` ADD CONSTRAINT \`FK_148ccb05f5a86819c7dae1c03d5\` FOREIGN KEY (\`fileId\`) REFERENCES \`file\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + } +} diff --git a/src/modules/handlers/screen/poster/local/poster-service.ts b/src/modules/handlers/screen/poster/local/poster-service.ts index a281502b..c22061b3 100644 --- a/src/modules/handlers/screen/poster/local/poster-service.ts +++ b/src/modules/handlers/screen/poster/local/poster-service.ts @@ -1,12 +1,9 @@ import { FileStorage } from '../../../../files/storage/file-storage'; import { Repository } from 'typeorm'; -import { lookup } from 'mime-types'; import { File } from '../../../../files/entities'; import Poster, { FooterSize, PosterType } from './poster'; -import StaticPoster from '../static/static-poster'; import { DiskStorage } from '../../../../files/storage'; import dataSource from '../../../../../database'; -import logger from '../../../../../logger'; import { HttpApiException } from '../../../../../helpers/custom-error'; import { HttpStatusCode } from 'axios'; import FileResponse from '../../../../files/entities/file-response'; @@ -226,70 +223,4 @@ export default class PosterService { poster.enabled = enabled; return this.repo.save(poster); } - - /** - * Checks whether the legacy static poster table exists, and if it does, migrates - * every static poster. - */ - public async migrateStaticPosters(): Promise { - const staticRepo = dataSource.getRepository(StaticPoster); - - const queryRunner = dataSource.createQueryRunner(); - let tableExists: boolean; - try { - tableExists = await queryRunner.hasTable(staticRepo.metadata.tableName); - } finally { - await queryRunner.release(); - } - if (!tableExists) return; - - const staticPosters = await staticRepo.find(); - if (staticPosters.length === 0) return; - - const migrated: StaticPoster[] = []; - for (const staticPoster of staticPosters) { - try { - if (staticPoster.file) { - const sourceStorage = new DiskStorage( - staticPoster.file.relativeDirectory.replace(/^public[\\/]/, ''), - ); - const data = await sourceStorage.getFile(staticPoster.file); - - const fileParams = await this.storage.saveFile(staticPoster.file.originalName, data); - const file = await this.fileRepo.save(fileParams); - - const mimeType = lookup(staticPoster.file.originalName); - await this.repo.save({ - name: staticPoster.file.originalName, - type: mimeType && mimeType.startsWith('video/') ? PosterType.VIDEO : PosterType.IMAGE, - files: [file], - enabled: false, - }); - - await sourceStorage.deleteFile(staticPoster.file); - await this.fileRepo.delete(staticPoster.file.id); - } else if (staticPoster.uri) { - await this.repo.save({ - name: staticPoster.uri, - type: PosterType.EXTERNAL, - uri: staticPoster.uri, - enabled: false, - }); - } else { - logger.warn( - `Skipping static poster ${staticPoster.id}: it has neither a file nor a uri.`, - ); - continue; - } - migrated.push(staticPoster); - } catch (error) { - logger.error(`Failed to migrate static poster ${staticPoster.id}: ${error}`); - } - } - - if (migrated.length > 0) { - await staticRepo.remove(migrated); - } - logger.info(`Migrated ${migrated.length} static poster(s) to local posters.`); - } } diff --git a/src/modules/handlers/screen/poster/local/poster.ts b/src/modules/handlers/screen/poster/local/poster.ts index 31e101c4..fd1f7d51 100644 --- a/src/modules/handlers/screen/poster/local/poster.ts +++ b/src/modules/handlers/screen/poster/local/poster.ts @@ -30,10 +30,7 @@ export default class Poster extends BaseEntity { @Column() name: string; - @Column({ - type: 'text', - enum: PosterType, - }) + @Column() type: PosterType; @Column({ default: true }) @@ -57,11 +54,7 @@ export default class Poster extends BaseEntity { @Column({ default: false }) borrelMode: boolean; - @Column({ - type: 'text', - enum: FooterSize, - default: FooterSize.FULL, - }) + @Column({ default: FooterSize.FULL }) footerSize: FooterSize; @Column({ default: 15 }) diff --git a/src/modules/handlers/screen/poster/static/static-poster.ts b/src/modules/handlers/screen/poster/static/static-poster.ts deleted file mode 100644 index a7f5bfa4..00000000 --- a/src/modules/handlers/screen/poster/static/static-poster.ts +++ /dev/null @@ -1,19 +0,0 @@ -import BaseEntity from '../../../../root/entities/base-entity'; -import { File } from '../../../../files/entities'; -import { Column, Entity, JoinColumn, OneToOne } from 'typeorm'; - -@Entity() -export default class StaticPoster extends BaseEntity { - /** - * File details of the poster, if locally stored on disk - */ - @OneToOne(() => File, { nullable: true, eager: true, onDelete: 'SET NULL' }) - @JoinColumn() - file?: File; - - /** - * URI of the poster if stored somewhere else - */ - @Column({ nullable: true }) - uri?: string; -} From 251a1fc2de746c4f1c5b2b0031953ba6e5cc96ce Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 10 Jun 2026 21:09:32 +0200 Subject: [PATCH 41/58] feat: add feature flag for trello posters --- src/index.ts | 7 ++++++- .../screen/poster/poster-screen-handler-settings.ts | 6 ++++++ .../handlers/screen/poster/trello/trello-poster-manager.ts | 2 ++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index d3e523f8..462e7778 100644 --- a/src/index.ts +++ b/src/index.ts @@ -89,7 +89,12 @@ async function createApp(): Promise { await SpotifyTrackHandler.getInstance().init(emitterStore.musicEmitter); } - if (process.env.TRELLO_BOARD_ID && process.env.TRELLO_KEY && process.env.TRELLO_TOKEN) { + if ( + process.env.TRELLO_BOARD_ID && + process.env.TRELLO_KEY && + process.env.TRELLO_TOKEN && + featureFlagManager.flagIsEnabled('Poster.Trello') + ) { logger.info('Initialize Trello posters...'); const trelloPosterManager = new TrelloPosterManager(); trelloPosterManager.reloadPosters().catch((e) => logger.error(e)); diff --git a/src/modules/handlers/screen/poster/poster-screen-handler-settings.ts b/src/modules/handlers/screen/poster/poster-screen-handler-settings.ts index 7566218d..e86e6003 100644 --- a/src/modules/handlers/screen/poster/poster-screen-handler-settings.ts +++ b/src/modules/handlers/screen/poster/poster-screen-handler-settings.ts @@ -43,6 +43,11 @@ export interface PosterScreenHandlerSettings { * Whether the double-dots (:) in the middle of the clock should flicker */ 'Poster.ClockShouldTick': boolean; + + /** + * Whether the Trello poster sync is enabled. + */ + 'Poster.Trello': boolean; } export const PosterScreenHandlerSettingsDefaults: PosterScreenHandlerSettings = { @@ -54,4 +59,5 @@ export const PosterScreenHandlerSettingsDefaults: PosterScreenHandlerSettings = 'Poster.BorrelModePresent': true, 'Poster.CustomStylesheet': '', 'Poster.ClockShouldTick': true, + 'Poster.Trello': true, }; diff --git a/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts b/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts index 45563683..62df9642 100644 --- a/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts +++ b/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts @@ -3,6 +3,7 @@ import PosterService from '../local/poster-service'; import axios from 'axios'; import Poster, { FooterSize, PosterType } from '../local/poster'; import dataSource from '../../../../../database'; +import { FeatureEnabled } from '../../../../server-settings'; interface CardEntry { card: Card; @@ -13,6 +14,7 @@ interface CardEntry { const DEFAULT_POSTER_TIMEOUT = 15; const DEFAULT_POSTER_REFRESH = 1000 * 60 * 15; +@FeatureEnabled('Poster.Trello') export class TrelloPosterManager { private client: TrelloClient; From 64ae4eb007d174e27a40da21d18c0d3a18a7c964 Mon Sep 17 00:00:00 2001 From: Tycho Louws <45605257+Wand3rerz@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:46:04 +0200 Subject: [PATCH 42/58] Apply immediate suggestions from code review These are all small immediate improvements, mostly just better typescript. The larger suggestions will be handled separately. Co-authored-by: Gijs de Man <43375368+Gijsdeman@users.noreply.github.com> --- .../poster/carousel-poster-controller.ts | 15 ++++++--------- .../screen/poster/local/poster-controller.ts | 2 +- .../screen/poster/local/poster-service.ts | 19 ++++--------------- .../poster/static/static-poster-controller.ts | 11 ++--------- 4 files changed, 13 insertions(+), 34 deletions(-) diff --git a/src/modules/handlers/screen/poster/carousel-poster-controller.ts b/src/modules/handlers/screen/poster/carousel-poster-controller.ts index 76a041cf..e54276b5 100644 --- a/src/modules/handlers/screen/poster/carousel-poster-controller.ts +++ b/src/modules/handlers/screen/poster/carousel-poster-controller.ts @@ -56,18 +56,15 @@ export class CarouselPosterController extends Controller { const posters = await this.screenHandler.posterService.getAllPosters(); let visible: Poster[]; - if (includeHidden) { - visible = posters; - } else { - const now = new Date(); - const active = posters.filter( + const visible = includeHidden + ? posters + : posters.filter( (p) => p.enabled && - (p.startDate == null || p.startDate <= now) && - (p.expirationDate == null || p.expirationDate > now), + (!p.startDate || p.startDate <= now) && + (!p.expirationDate || p.expirationDate > now) && + (this.screenHandler.borrelMode || !p.borrelMode) ); - visible = this.screenHandler.borrelMode ? active : active.filter((p) => !p.borrelMode); - } const order = await new CarouselPosterService().getOrder(CAROUSEL_ID); const rank = new Map(order.map((id, i) => [id, i])); diff --git a/src/modules/handlers/screen/poster/local/poster-controller.ts b/src/modules/handlers/screen/poster/local/poster-controller.ts index 7e7a4e24..97688d27 100644 --- a/src/modules/handlers/screen/poster/local/poster-controller.ts +++ b/src/modules/handlers/screen/poster/local/poster-controller.ts @@ -73,7 +73,7 @@ export class PosterController extends Controller { @Res() invalidFileTypeResponse: TsoaResponse< HttpStatusCode.UnsupportedMediaType, - 'Invalid file type, expected an image or a video.' + string >, ): Promise { const mimeType = lookup(file.originalname); diff --git a/src/modules/handlers/screen/poster/local/poster-service.ts b/src/modules/handlers/screen/poster/local/poster-service.ts index c22061b3..daad5565 100644 --- a/src/modules/handlers/screen/poster/local/poster-service.ts +++ b/src/modules/handlers/screen/poster/local/poster-service.ts @@ -129,8 +129,8 @@ export default class PosterService { * @param id The id of the poster to fetch. */ public async getSinglePoster(id: number): Promise { - const poster = await this.repo.findOne({ where: { id } }); - if (poster == null) { + const poster = await this.repo.findOneBy({ id }); + if (poster === null) { throw new HttpApiException(HttpStatusCode.NotFound, `Poster with ID "${id}" not found.`); } return poster; @@ -143,16 +143,7 @@ export default class PosterService { */ public async createPoster(params: CreatePosterRequest): Promise { return this.repo.save({ - name: params.name, - label: params.label, - type: params.type, - startDate: params.startDate, - expirationDate: params.expirationDate, - accentColor: params.accentColor, - footerSize: params.footerSize, - defaultTimeout: params.defaultTimeout, - borrelMode: params.borrelMode, - trello: params.trello, + ...params uri: params.type === PosterType.EXTERNAL ? params.uri : undefined, albums: params.type === PosterType.PHOTO ? params.albums : undefined, }); @@ -219,8 +210,6 @@ export default class PosterService { * @param enabled The state to put the poster in. */ public async togglePosterEnable(id: number, enabled: boolean): Promise { - const poster = await this.getSinglePoster(id); - poster.enabled = enabled; - return this.repo.save(poster); + return this.repo.update({ id }, { enabled }) } } diff --git a/src/modules/handlers/screen/poster/static/static-poster-controller.ts b/src/modules/handlers/screen/poster/static/static-poster-controller.ts index a843e595..a77b2dce 100644 --- a/src/modules/handlers/screen/poster/static/static-poster-controller.ts +++ b/src/modules/handlers/screen/poster/static/static-poster-controller.ts @@ -54,17 +54,10 @@ export class StaticPosterController extends Controller { @Post('clock') public async setStaticPosterClock( @Request() req: ExpressRequest, - @Body() body: SetClockRequest, + @Body() { visible }: SetClockRequest, ): Promise { - const { visible } = body; - if (visible) { - logger.audit(req.user, 'Make clock in StaticPosterHandler visible.'); - } else { - logger.audit(req.user, 'Make clock in StaticPosterHandler invisible.'); - } - + logger.audit(req.user, `Make clock in StaticPosterHandler ${visible ? 'visible' : 'invisible'}.`); this.screenHandler.setClockVisibility(visible); - } /** * Show the given static poster on all screens using the StaticPosterHandler. From c77390c5f19bed3bd822e4c3b2c339a749def5c7 Mon Sep 17 00:00:00 2001 From: Tycho Louws <45605257+Wand3rerz@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:20:35 +0200 Subject: [PATCH 43/58] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../poster/carousel-poster-controller.ts | 20 +++++++++---------- .../screen/poster/local/poster-controller.ts | 2 +- .../screen/poster/local/poster-service.ts | 5 +++-- .../poster/static/static-poster-controller.ts | 7 +++++-- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/modules/handlers/screen/poster/carousel-poster-controller.ts b/src/modules/handlers/screen/poster/carousel-poster-controller.ts index e54276b5..e9ca070f 100644 --- a/src/modules/handlers/screen/poster/carousel-poster-controller.ts +++ b/src/modules/handlers/screen/poster/carousel-poster-controller.ts @@ -55,16 +55,16 @@ export class CarouselPosterController extends Controller { public async getPosters(@Query() includeHidden?: boolean): Promise { const posters = await this.screenHandler.posterService.getAllPosters(); - let visible: Poster[]; - const visible = includeHidden - ? posters - : posters.filter( - (p) => - p.enabled && - (!p.startDate || p.startDate <= now) && - (!p.expirationDate || p.expirationDate > now) && - (this.screenHandler.borrelMode || !p.borrelMode) - ); + const now = new Date(); + const visible = includeHidden + ? posters + : posters.filter( + (p) => + p.enabled && + (!p.startDate || p.startDate <= now) && + (!p.expirationDate || p.expirationDate > now) && + (this.screenHandler.borrelMode || !p.borrelMode), + ); const order = await new CarouselPosterService().getOrder(CAROUSEL_ID); const rank = new Map(order.map((id, i) => [id, i])); diff --git a/src/modules/handlers/screen/poster/local/poster-controller.ts b/src/modules/handlers/screen/poster/local/poster-controller.ts index 97688d27..02ff3a52 100644 --- a/src/modules/handlers/screen/poster/local/poster-controller.ts +++ b/src/modules/handlers/screen/poster/local/poster-controller.ts @@ -12,7 +12,7 @@ import { PosterType } from './poster'; import { lookup } from 'mime-types'; import { FeatureEnabled } from '../../../../server-settings'; -@Route('/handler/screen/poster') +@Route('handler/screen/poster') @Tags('Handlers') @FeatureEnabled('Poster') export class PosterController extends Controller { diff --git a/src/modules/handlers/screen/poster/local/poster-service.ts b/src/modules/handlers/screen/poster/local/poster-service.ts index daad5565..e638e39f 100644 --- a/src/modules/handlers/screen/poster/local/poster-service.ts +++ b/src/modules/handlers/screen/poster/local/poster-service.ts @@ -143,7 +143,7 @@ export default class PosterService { */ public async createPoster(params: CreatePosterRequest): Promise { return this.repo.save({ - ...params + ...params, uri: params.type === PosterType.EXTERNAL ? params.uri : undefined, albums: params.type === PosterType.PHOTO ? params.albums : undefined, }); @@ -210,6 +210,7 @@ export default class PosterService { * @param enabled The state to put the poster in. */ public async togglePosterEnable(id: number, enabled: boolean): Promise { - return this.repo.update({ id }, { enabled }) + await this.repo.update({ id }, { enabled }); + return this.getSinglePoster(id); } } diff --git a/src/modules/handlers/screen/poster/static/static-poster-controller.ts b/src/modules/handlers/screen/poster/static/static-poster-controller.ts index a77b2dce..2e363b65 100644 --- a/src/modules/handlers/screen/poster/static/static-poster-controller.ts +++ b/src/modules/handlers/screen/poster/static/static-poster-controller.ts @@ -56,9 +56,12 @@ export class StaticPosterController extends Controller { @Request() req: ExpressRequest, @Body() { visible }: SetClockRequest, ): Promise { - logger.audit(req.user, `Make clock in StaticPosterHandler ${visible ? 'visible' : 'invisible'}.`); + logger.audit( + req.user, + `Make clock in StaticPosterHandler ${visible ? 'visible' : 'invisible'}.`, + ); this.screenHandler.setClockVisibility(visible); - + } /** * Show the given static poster on all screens using the StaticPosterHandler. * @param id From 91635587dedf14113eb1b9e394b70fb52f4dbe4a Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 17 Jun 2026 14:46:11 +0200 Subject: [PATCH 44/58] fix: request and update types now defined based on poster entity --- .../screen/poster/local/poster-controller.ts | 5 +- .../screen/poster/local/poster-service.ts | 54 ++++++++++--------- .../poster/static/static-poster-controller.ts | 6 +-- 3 files changed, 32 insertions(+), 33 deletions(-) diff --git a/src/modules/handlers/screen/poster/local/poster-controller.ts b/src/modules/handlers/screen/poster/local/poster-controller.ts index 02ff3a52..8095cc3a 100644 --- a/src/modules/handlers/screen/poster/local/poster-controller.ts +++ b/src/modules/handlers/screen/poster/local/poster-controller.ts @@ -71,10 +71,7 @@ export class PosterController extends Controller { id: number, @UploadedFile() file: Express.Multer.File, @Res() - invalidFileTypeResponse: TsoaResponse< - HttpStatusCode.UnsupportedMediaType, - string - >, + invalidFileTypeResponse: TsoaResponse, ): Promise { const mimeType = lookup(file.originalname); if (!mimeType || !(mimeType.startsWith('image/') || mimeType.startsWith('video/'))) { diff --git a/src/modules/handlers/screen/poster/local/poster-service.ts b/src/modules/handlers/screen/poster/local/poster-service.ts index e638e39f..d879ec1c 100644 --- a/src/modules/handlers/screen/poster/local/poster-service.ts +++ b/src/modules/handlers/screen/poster/local/poster-service.ts @@ -8,45 +8,47 @@ import { HttpApiException } from '../../../../../helpers/custom-error'; import { HttpStatusCode } from 'axios'; import FileResponse from '../../../../files/entities/file-response'; -interface BasePosterParams { - name: string; - label?: string; - startDate?: Date; - expirationDate?: Date; - accentColor?: string; - footerSize?: FooterSize; - defaultTimeout?: number; - borrelMode?: boolean; - trello?: boolean; -} +type BasePosterFields = + | 'name' + | 'label' + | 'startDate' + | 'expirationDate' + | 'accentColor' + | 'footerSize' + | 'defaultTimeout' + | 'borrelMode' + | 'trello'; + +export interface BasePosterParams extends Pick {} export interface MediaPosterRequest extends BasePosterParams { type: PosterType.IMAGE | PosterType.VIDEO; } -export interface ExternalPosterRequest extends BasePosterParams { +export interface ExternalPosterRequest extends BasePosterParams, Required> { type: PosterType.EXTERNAL; - uri: string; } -export interface PhotoPosterRequest extends BasePosterParams { +export interface PhotoPosterRequest extends BasePosterParams, Required> { type: PosterType.PHOTO; - albums: number[]; } export type CreatePosterRequest = MediaPosterRequest | ExternalPosterRequest | PhotoPosterRequest; -export interface UpdatePosterRequest { - name?: string; - label?: string; - startDate?: Date; - expirationDate?: Date; - accentColor?: string; - footerSize?: FooterSize; - defaultTimeout?: number; - borrelMode?: boolean; - albums?: number[]; -} +export interface UpdatePosterRequest extends Partial< + Pick< + Poster, + | 'name' + | 'label' + | 'startDate' + | 'expirationDate' + | 'accentColor' + | 'footerSize' + | 'defaultTimeout' + | 'borrelMode' + | 'albums' + > +> {} export interface PosterResponse { id: number; diff --git a/src/modules/handlers/screen/poster/static/static-poster-controller.ts b/src/modules/handlers/screen/poster/static/static-poster-controller.ts index 2e363b65..d4ab586a 100644 --- a/src/modules/handlers/screen/poster/static/static-poster-controller.ts +++ b/src/modules/handlers/screen/poster/static/static-poster-controller.ts @@ -54,13 +54,13 @@ export class StaticPosterController extends Controller { @Post('clock') public async setStaticPosterClock( @Request() req: ExpressRequest, - @Body() { visible }: SetClockRequest, + @Body() body: SetClockRequest, ): Promise { logger.audit( req.user, - `Make clock in StaticPosterHandler ${visible ? 'visible' : 'invisible'}.`, + `Make clock in StaticPosterHandler ${body.visible ? 'visible' : 'invisible'}.`, ); - this.screenHandler.setClockVisibility(visible); + this.screenHandler.setClockVisibility(body.visible); } /** * Show the given static poster on all screens using the StaticPosterHandler. From 5ecafdcff96e133f25960bc94c5cd0599919040f Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 17 Jun 2026 15:01:52 +0200 Subject: [PATCH 45/58] fix: make fileRepo and normal repo operate in one transaction --- .../screen/poster/local/poster-service.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/modules/handlers/screen/poster/local/poster-service.ts b/src/modules/handlers/screen/poster/local/poster-service.ts index d879ec1c..55c768cd 100644 --- a/src/modules/handlers/screen/poster/local/poster-service.ts +++ b/src/modules/handlers/screen/poster/local/poster-service.ts @@ -16,10 +16,11 @@ type BasePosterFields = | 'accentColor' | 'footerSize' | 'defaultTimeout' - | 'borrelMode' - | 'trello'; + | 'borrelMode'; -export interface BasePosterParams extends Pick {} +export interface BasePosterParams extends Pick { + trello?: boolean; +} export interface MediaPosterRequest extends BasePosterParams { type: PosterType.IMAGE | PosterType.VIDEO; @@ -168,9 +169,11 @@ export default class PosterService { const fileParams = await this.storage.saveFile(filename, filedata); try { - const file = await this.fileRepo.save(fileParams); - poster.files = [...(poster.files ?? []), file]; - return this.repo.save(poster); + return await dataSource.transaction(async (manager) => { + const file = await manager.getRepository(File).save(fileParams); + poster.files = [...(poster.files ?? []), file]; + return manager.getRepository(Poster).save(poster); + }); } catch (error) { await this.storage.deleteFile(fileParams); throw error; From 5ea9880a08571b1141c74faae743f74cf052e4a8 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 17 Jun 2026 15:14:51 +0200 Subject: [PATCH 46/58] fix: cascading delete --- src/modules/handlers/screen/poster/local/poster.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/modules/handlers/screen/poster/local/poster.ts b/src/modules/handlers/screen/poster/local/poster.ts index fd1f7d51..af3f2d9e 100644 --- a/src/modules/handlers/screen/poster/local/poster.ts +++ b/src/modules/handlers/screen/poster/local/poster.ts @@ -66,8 +66,11 @@ export default class Poster extends BaseEntity { @Column({ type: 'simple-array', nullable: true }) albums?: number[]; - @ManyToMany(() => File, { eager: true }) - @JoinTable() + @ManyToMany(() => File, { eager: true, onDelete: 'CASCADE' }) + @JoinTable({ + joinColumn: { name: 'posterId', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'fileId', referencedColumnName: 'id' }, + }) files: File[]; @Column({ default: false }) From 7019f0ac389c0cca9b8d5f48c022cc6954606cd5 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 17 Jun 2026 15:15:47 +0200 Subject: [PATCH 47/58] fix: atomic posterUpdate --- .../screen/poster/local/poster-service.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/modules/handlers/screen/poster/local/poster-service.ts b/src/modules/handlers/screen/poster/local/poster-service.ts index 55c768cd..3c0fe527 100644 --- a/src/modules/handlers/screen/poster/local/poster-service.ts +++ b/src/modules/handlers/screen/poster/local/poster-service.ts @@ -187,12 +187,9 @@ export default class PosterService { public async deletePoster(id: number): Promise { const poster = await this.getSinglePoster(id); const files = poster.files ?? []; - // Removing the poster clears the owning-side join-table rows, after which - // the now-orphaned files can be deleted from the database and disk. await this.repo.remove(poster); await Promise.all( files.map(async (file) => { - await this.fileRepo.delete(file.id); await this.storage.deleteFile(file); }), ); @@ -204,9 +201,15 @@ export default class PosterService { * @param params The fields of the poster to be updated as specified in UpdatePosterParams. */ public async updatePoster(id: number, params: UpdatePosterRequest): Promise { - const poster = await this.getSinglePoster(id); - Object.assign(poster, params); - return this.repo.save(poster); + return dataSource.transaction(async (manager) => { + const repo = manager.getRepository(Poster); + const poster = await repo.findOneBy({ id }); + if (poster === null) { + throw new HttpApiException(HttpStatusCode.NotFound, `Poster with ID "${id}" not found.`); + } + Object.assign(poster, params); + return repo.save(poster); + }); } /** From 55f4b0f01689c62454050e34dcfc6561de62b094 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 17 Jun 2026 15:25:02 +0200 Subject: [PATCH 48/58] chore: added docs to poster entity --- .../handlers/screen/poster/local/poster.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/modules/handlers/screen/poster/local/poster.ts b/src/modules/handlers/screen/poster/local/poster.ts index af3f2d9e..2f14bf8e 100644 --- a/src/modules/handlers/screen/poster/local/poster.ts +++ b/src/modules/handlers/screen/poster/local/poster.ts @@ -2,6 +2,9 @@ import { Entity, Column, ManyToMany, JoinTable } from 'typeorm'; import BaseEntity from '../../../../root/entities/base-entity'; import { File } from '../../../../files/entities'; +/** + * List of every supported poster type. + */ export enum PosterType { UNKNOWN = 'unknown', ERROR = 'error', @@ -19,53 +22,103 @@ export enum PosterType { OLYMPICS = 'olympics', } +/** + * List of possible footersizes. + */ export enum FooterSize { FULL = 'full', MINIMAL = 'minimal', HIDDEN = 'hidden', } +/** + * Global poster entity. + */ @Entity() export default class Poster extends BaseEntity { + /** + * Internal name of the poster. Visible in backoffice and database. + */ @Column() name: string; + /** + * Type of the poster. Should be a valid PosterType. + */ @Column() type: PosterType; + /** + * Whether the poster is manually disabled through backoffice. + */ @Column({ default: true }) enabled: boolean; + /** + * The visible title of the poster on the screens. + */ @Column({ nullable: true }) label?: string; + /** + * Moment from when the poster should be in rotation. + */ @Column({ nullable: true }) startDate?: Date; + /** + * Moment from when the poster should be out of rotation. + */ @Column({ nullable: true }) expirationDate?: Date; + /** + * Color of the progressbar mostly. + */ @Column({ nullable: true }) accentColor?: string; + /** + * Whether the poster should be manually deletable. + */ @Column({ default: false }) protected: boolean; + /** + * Whether the poster should only be displayed in borrelMode. + */ @Column({ default: false }) borrelMode: boolean; + /** + * The size of the footer. Should be a valid FooterSize. + */ @Column({ default: FooterSize.FULL }) footerSize: FooterSize; + /** + * The time the poster should be on the screens for. + */ @Column({ default: 15 }) defaultTimeout: number; + /** + * If the poster is of type external this holds the link to the resource. + */ @Column({ nullable: true }) uri?: string; + /** + * If the poster is of type photo this contains the ids of the GEWISweb albums this poster should + * pick from. + */ @Column({ type: 'simple-array', nullable: true }) albums?: number[]; + /** + * If the poster is either an image or video this stores a reference to the associated files in + * the file repo. + */ @ManyToMany(() => File, { eager: true, onDelete: 'CASCADE' }) @JoinTable({ joinColumn: { name: 'posterId', referencedColumnName: 'id' }, @@ -73,12 +126,24 @@ export default class Poster extends BaseEntity { }) files: File[]; + /** + * Flag set to true when the poster is imported from trello. + * Used to manage trello specific actions and prevents the poster from being managed through + * backoffice. + */ @Column({ default: false }) trello: boolean; + /** + * Id of the trello card containing the poster. Used to determine whether the poster has changed + * since the last sync. + */ @Column({ type: 'text', nullable: true }) trelloCardId?: string; + /** + * Moment of the last change of the card on trello. Used during the sync with trello. + */ @Column({ type: 'text', nullable: true }) trelloLastActivity?: string; } From 0579b4e242f8b82ae4f6e75cc9a2331b1d4833a3 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 17 Jun 2026 15:49:46 +0200 Subject: [PATCH 49/58] feat: add file-type package and make file type checking more robust --- package.json | 1 + pnpm-lock.yaml | 89 +++++++++++++++++++ .../poster/base-poster-screen-controller.ts | 26 ++---- .../screen/poster/local/poster-controller.ts | 6 +- 4 files changed, 102 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index d01b4784..6bd49c5e 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "dotenv": "17.2.3", "express": "5.2.1", "express-session": "1.19.0", + "file-type": "16.5.4", "globals": "17.1.0", "joi": "18.0.2", "jwt-decode": "4.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0724ce88..fa868adb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: express-session: specifier: 1.19.0 version: 1.19.0 + file-type: + specifier: 16.5.4 + version: 16.5.4 globals: specifier: 17.1.0 version: 17.1.0 @@ -569,6 +572,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tootallnate/once@1.1.2': resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} engines: {node: '>= 6'} @@ -807,6 +813,10 @@ packages: abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -1449,9 +1459,17 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -1500,6 +1518,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + file-type@16.5.4: + resolution: {integrity: sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==} + engines: {node: '>=10'} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -2428,6 +2450,10 @@ packages: pause@0.0.1: resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + peek-readable@4.1.0: + resolution: {integrity: sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==} + engines: {node: '>=8'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2487,6 +2513,10 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + promise-inflight@1.0.1: resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} peerDependencies: @@ -2552,6 +2582,14 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readable-web-to-node-stream@3.0.4: + resolution: {integrity: sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==} + engines: {node: '>=8'} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -2877,6 +2915,10 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + strtok3@6.3.0: + resolution: {integrity: sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==} + engines: {node: '>=10'} + superagent@10.3.0: resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} engines: {node: '>=14.18.0'} @@ -2953,6 +2995,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + token-types@4.2.1: + resolution: {integrity: sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==} + engines: {node: '>=10'} + touch@3.1.1: resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} hasBin: true @@ -2988,6 +3034,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -3736,6 +3783,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@tokenizer/token@0.3.0': {} + '@tootallnate/once@1.1.2': optional: true @@ -4064,6 +4113,10 @@ snapshots: abbrev@1.1.1: optional: true + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -4833,8 +4886,12 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: {} + eventemitter3@5.0.4: {} + events@3.3.0: {} + expand-template@2.0.3: {} expect-type@1.3.0: {} @@ -4939,6 +4996,12 @@ snapshots: dependencies: flat-cache: 4.0.1 + file-type@16.5.4: + dependencies: + readable-web-to-node-stream: 3.0.4 + strtok3: 6.3.0 + token-types: 4.2.1 + file-uri-to-path@1.0.0: {} fill-range@7.1.1: @@ -5906,6 +5969,8 @@ snapshots: pause@0.0.1: {} + peek-readable@4.1.0: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -5986,6 +6051,8 @@ snapshots: process-warning@5.0.0: {} + process@0.11.10: {} + promise-inflight@1.0.1: optional: true @@ -6052,6 +6119,18 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readable-web-to-node-stream@3.0.4: + dependencies: + readable-stream: 4.7.0 + readdirp@3.6.0: dependencies: picomatch: 2.3.2 @@ -6489,6 +6568,11 @@ snapshots: strip-json-comments@5.0.3: {} + strtok3@6.3.0: + dependencies: + '@tokenizer/token': 0.3.0 + peek-readable: 4.1.0 + superagent@10.3.0(supports-color@5.5.0): dependencies: component-emitter: 1.3.1 @@ -6585,6 +6669,11 @@ snapshots: toidentifier@1.0.1: {} + token-types@4.2.1: + dependencies: + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + touch@3.1.1: {} tree-kill@1.2.2: {} diff --git a/src/modules/handlers/screen/poster/base-poster-screen-controller.ts b/src/modules/handlers/screen/poster/base-poster-screen-controller.ts index fcc813a2..353cc019 100644 --- a/src/modules/handlers/screen/poster/base-poster-screen-controller.ts +++ b/src/modules/handlers/screen/poster/base-poster-screen-controller.ts @@ -6,7 +6,7 @@ import { ServerSettingsStore } from '../../../server-settings'; import { ISettings } from '../../../server-settings/server-setting'; import { SecurityNames } from '../../../../helpers/security'; import { securityGroups } from '../../../../helpers/security-groups'; -import { lookup } from 'mime-types'; +import { fromBuffer } from 'file-type'; export interface PosterScreenSettingsResponse { defaultMinimal: boolean; @@ -60,17 +60,13 @@ export class BasePosterScreenController extends Controller { const res = request?.res; if (logo && res) { - const mimeType = lookup(logo.originalName); - let contentType: string; - if (!mimeType) { - contentType = 'application/octet-stream'; - } else { - contentType = mimeType; - } + const buffer = await fileStorage.getFile(logo); + const fileType = await fromBuffer(buffer); + const contentType = fileType?.mime ?? 'application/octet-stream'; res.setHeader('Content-Disposition', 'attachment; filename=' + logo.originalName); res.setHeader('Content-Type', contentType); - res.send(await fileStorage.getFile(logo)); + res.send(buffer); } } @@ -90,17 +86,13 @@ export class BasePosterScreenController extends Controller { const res = request?.res; if (stylesheet && res) { - const mimeType = lookup(stylesheet.originalName); - let contentType: string; - if (!mimeType) { - contentType = 'application/octet-stream'; - } else { - contentType = mimeType; - } + const buffer = await fileStorage.getFile(stylesheet); + const fileType = await fromBuffer(buffer); + const contentType = fileType?.mime ?? 'application/octet-stream'; res.setHeader('Content-Disposition', 'attachment; filename=' + stylesheet.originalName); res.setHeader('Content-Type', contentType); - res.send(await fileStorage.getFile(stylesheet)); + res.send(buffer); } } } diff --git a/src/modules/handlers/screen/poster/local/poster-controller.ts b/src/modules/handlers/screen/poster/local/poster-controller.ts index 8095cc3a..d1a3c154 100644 --- a/src/modules/handlers/screen/poster/local/poster-controller.ts +++ b/src/modules/handlers/screen/poster/local/poster-controller.ts @@ -9,7 +9,7 @@ import PosterService, { UpdatePosterRequest, } from './poster-service'; import { PosterType } from './poster'; -import { lookup } from 'mime-types'; +import { fromBuffer } from 'file-type'; import { FeatureEnabled } from '../../../../server-settings'; @Route('handler/screen/poster') @@ -73,8 +73,8 @@ export class PosterController extends Controller { @Res() invalidFileTypeResponse: TsoaResponse, ): Promise { - const mimeType = lookup(file.originalname); - if (!mimeType || !(mimeType.startsWith('image/') || mimeType.startsWith('video/'))) { + const fileType = await fromBuffer(file.buffer); + if (!fileType || !(fileType.mime.startsWith('image/') || fileType.mime.startsWith('video/'))) { return invalidFileTypeResponse( HttpStatusCode.UnsupportedMediaType, 'Invalid file type, expected an image or a video.', From 8a0b0aac1b685e2455c26f904c30f4cd4a330cf7 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 17 Jun 2026 16:20:35 +0200 Subject: [PATCH 50/58] feat: replace integer id based ordering with entity ordering for carousels --- src/database.ts | 2 + .../poster/local/carousel-poster-service.ts | 40 ++++++++++++------- .../poster/local/local-carousel-poster.ts | 24 +++++++++++ .../screen/poster/local/local-carousel.ts | 7 ++-- 4 files changed, 55 insertions(+), 18 deletions(-) create mode 100644 src/modules/handlers/screen/poster/local/local-carousel-poster.ts diff --git a/src/database.ts b/src/database.ts index 4ab83e02..11ade610 100644 --- a/src/database.ts +++ b/src/database.ts @@ -12,6 +12,7 @@ import { Entities as LightsEntities } from './modules/lights/entities'; import { Entities as TimedEventsEntities } from './modules/timed-events/entities'; import Poster from './modules/handlers/screen/poster/local/poster'; import Carousel from './modules/handlers/screen/poster/local/local-carousel'; +import CarouselPoster from './modules/handlers/screen/poster/local/local-carousel-poster'; import { InitialMigration1780248780327 } from './migrations/1780248780327-InitialMigration'; import { PosterMigration1781087463209 } from './migrations/1781087463209-PosterMigration'; @@ -49,6 +50,7 @@ const dataSource = new DataSource({ ...LightsEntities, Poster, Carousel, + CarouselPoster, ], }); diff --git a/src/modules/handlers/screen/poster/local/carousel-poster-service.ts b/src/modules/handlers/screen/poster/local/carousel-poster-service.ts index 06fed2cb..8129e05a 100644 --- a/src/modules/handlers/screen/poster/local/carousel-poster-service.ts +++ b/src/modules/handlers/screen/poster/local/carousel-poster-service.ts @@ -1,5 +1,5 @@ -import { Repository } from 'typeorm'; import Carousel from './local-carousel'; +import CarouselPoster from './local-carousel-poster'; import dataSource from '../../../../../database'; /** @@ -9,20 +9,17 @@ import dataSource from '../../../../../database'; export const CAROUSEL_ID = 1; export default class CarouselPosterService { - private repo: Repository; - - constructor() { - this.repo = dataSource.getRepository(Carousel); - } - /** * Returns the ordered poster IDs of the given carousel. * @param carouselId The id of the carousel to read. */ public async getOrder(carouselId: number): Promise { - const carousel = await this.repo.findOne({ where: { id: carouselId } }); - // TypeORM's 'simple-array' deserializes to strings, so coerce back to numbers. - return (carousel?.posterOrder ?? []).map(Number); + const rows = await dataSource.getRepository(CarouselPoster).find({ + where: { carousel: { id: carouselId } }, + order: { ordering: 'ASC' }, + loadRelationIds: true, + }); + return rows.map((row) => row.poster as unknown as number); } /** @@ -31,10 +28,23 @@ export default class CarouselPosterService { * @param posterIds The poster IDs in the desired display order. */ public async setOrder(carouselId: number, posterIds: number[]): Promise { - const carousel = - (await this.repo.findOne({ where: { id: carouselId } })) ?? - this.repo.create({ id: carouselId, name: 'default' }); - carousel.posterOrder = posterIds; - await this.repo.save(carousel); + await dataSource.transaction(async (manager) => { + const carouselRepo = manager.getRepository(Carousel); + const carousel = + (await carouselRepo.findOne({ where: { id: carouselId } })) ?? + (await carouselRepo.save(carouselRepo.create({ id: carouselId, name: 'default' }))); + + const repo = manager.getRepository(CarouselPoster); + await repo.delete({ carousel: { id: carousel.id } }); + if (posterIds.length > 0) { + await repo.insert( + posterIds.map((posterId, ordering) => ({ + carousel: { id: carousel.id }, + poster: { id: posterId }, + ordering, + })), + ); + } + }); } } diff --git a/src/modules/handlers/screen/poster/local/local-carousel-poster.ts b/src/modules/handlers/screen/poster/local/local-carousel-poster.ts new file mode 100644 index 00000000..83e1268f --- /dev/null +++ b/src/modules/handlers/screen/poster/local/local-carousel-poster.ts @@ -0,0 +1,24 @@ +import { Entity, Column, ManyToOne, Unique } from 'typeorm'; +import BaseEntity from '../../../../root/entities/base-entity'; +import Carousel from './local-carousel'; +import Poster from './poster'; + +/** + * Join entity describing which posters belong to a carousel and in what order. + */ +@Entity() +@Unique(['carousel', 'ordering']) +@Unique(['carousel', 'poster']) +export default class CarouselPoster extends BaseEntity { + @ManyToOne(() => Carousel, (carousel) => carousel.posters, { + nullable: false, + onDelete: 'CASCADE', + }) + carousel: Carousel; + + @ManyToOne(() => Poster, { nullable: false, onDelete: 'CASCADE' }) + poster: Poster; + + @Column() + ordering: number; +} diff --git a/src/modules/handlers/screen/poster/local/local-carousel.ts b/src/modules/handlers/screen/poster/local/local-carousel.ts index eba3a4e9..0ad073ac 100644 --- a/src/modules/handlers/screen/poster/local/local-carousel.ts +++ b/src/modules/handlers/screen/poster/local/local-carousel.ts @@ -1,5 +1,6 @@ -import { Entity, Column } from 'typeorm'; +import { Entity, Column, OneToMany } from 'typeorm'; import BaseEntity from '../../../../root/entities/base-entity'; +import CarouselPoster from './local-carousel-poster'; @Entity() export default class Carousel extends BaseEntity { @@ -9,6 +10,6 @@ export default class Carousel extends BaseEntity { @Column({ default: true }) active: boolean; - @Column({ type: 'simple-array', nullable: true }) - posterOrder?: number[]; + @OneToMany(() => CarouselPoster, (carouselPoster) => carouselPoster.carousel) + posters: CarouselPoster[]; } From 2bbcbf49f8b45cea25e082b03dcb68683d9a2afb Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 17 Jun 2026 16:32:58 +0200 Subject: [PATCH 51/58] fix: removed casting ids to number --- .../screen/poster/local/carousel-poster-service.ts | 12 ++++++------ .../screen/poster/local/local-carousel-poster.ts | 10 +++++++++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/modules/handlers/screen/poster/local/carousel-poster-service.ts b/src/modules/handlers/screen/poster/local/carousel-poster-service.ts index 8129e05a..3fb91636 100644 --- a/src/modules/handlers/screen/poster/local/carousel-poster-service.ts +++ b/src/modules/handlers/screen/poster/local/carousel-poster-service.ts @@ -15,11 +15,11 @@ export default class CarouselPosterService { */ public async getOrder(carouselId: number): Promise { const rows = await dataSource.getRepository(CarouselPoster).find({ - where: { carousel: { id: carouselId } }, + where: { carouselId }, order: { ordering: 'ASC' }, - loadRelationIds: true, + select: { posterId: true }, }); - return rows.map((row) => row.poster as unknown as number); + return rows.map((row) => row.posterId); } /** @@ -35,12 +35,12 @@ export default class CarouselPosterService { (await carouselRepo.save(carouselRepo.create({ id: carouselId, name: 'default' }))); const repo = manager.getRepository(CarouselPoster); - await repo.delete({ carousel: { id: carousel.id } }); + await repo.delete({ carouselId: carousel.id }); if (posterIds.length > 0) { await repo.insert( posterIds.map((posterId, ordering) => ({ - carousel: { id: carousel.id }, - poster: { id: posterId }, + carouselId: carousel.id, + posterId, ordering, })), ); diff --git a/src/modules/handlers/screen/poster/local/local-carousel-poster.ts b/src/modules/handlers/screen/poster/local/local-carousel-poster.ts index 83e1268f..b3ff1822 100644 --- a/src/modules/handlers/screen/poster/local/local-carousel-poster.ts +++ b/src/modules/handlers/screen/poster/local/local-carousel-poster.ts @@ -1,4 +1,4 @@ -import { Entity, Column, ManyToOne, Unique } from 'typeorm'; +import { Entity, Column, ManyToOne, JoinColumn, Unique } from 'typeorm'; import BaseEntity from '../../../../root/entities/base-entity'; import Carousel from './local-carousel'; import Poster from './poster'; @@ -10,13 +10,21 @@ import Poster from './poster'; @Unique(['carousel', 'ordering']) @Unique(['carousel', 'poster']) export default class CarouselPoster extends BaseEntity { + @Column() + carouselId: number; + @ManyToOne(() => Carousel, (carousel) => carousel.posters, { nullable: false, onDelete: 'CASCADE', }) + @JoinColumn({ name: 'carouselId' }) carousel: Carousel; + @Column() + posterId: number; + @ManyToOne(() => Poster, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'posterId' }) poster: Poster; @Column() From 1acb0f4bec2a3873aaccf7d72c6e75fc99df350b Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 17 Jun 2026 16:38:05 +0200 Subject: [PATCH 52/58] fix: made posterservice class level --- .../poster/trello/trello-poster-manager.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts b/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts index 62df9642..b674c1e8 100644 --- a/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts +++ b/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts @@ -18,6 +18,8 @@ const DEFAULT_POSTER_REFRESH = 1000 * 60 * 15; export class TrelloPosterManager { private client: TrelloClient; + private service = new PosterService(); + private refreshTimeout: NodeJS.Timeout | undefined = undefined; constructor() { @@ -172,8 +174,7 @@ export class TrelloPosterManager { return undefined; } - const service = new PosterService(); - let localPoster = await service.createPoster({ + let localPoster = await this.service.createPoster({ name: poster.name, type: type, label: poster.label, @@ -202,7 +203,7 @@ export class TrelloPosterManager { responseType: 'arraybuffer', headers, }); - localPoster = await service.attachMedia( + localPoster = await this.service.attachMedia( localPoster.id, attachment.fileName, Buffer.from(resp.data), @@ -234,8 +235,7 @@ export class TrelloPosterManager { const albums = checkList.checkItems.map((item: any) => item.name.split(' ')[0]); const poster = this.parseBasePoster(card, checklists, borrelMode); - const service = new PosterService(); - return service.createPoster({ + return this.service.createPoster({ name: poster.name, type: PosterType.PHOTO, label: poster.label, @@ -282,9 +282,8 @@ export class TrelloPosterManager { } const poster = this.parseBasePoster(card, checklists, borrelMode); - const service = new PosterService(); - return service.createPoster({ + return this.service.createPoster({ name: poster.name, type: PosterType.EXTERNAL, label: poster.label, @@ -311,13 +310,12 @@ export class TrelloPosterManager { const list = lists.find((l) => l.name === basePosterListName); if (!list) throw new Error(`Could not find the list called "${basePosterListName}"`); - const service = new PosterService(); const repo = dataSource.getRepository(Poster); const desired = this.collectCards(list, board); const desiredById = new Map(desired.filter((d) => d.card.id).map((d) => [d.card.id!, d])); - const existing = (await service.getAllPosters()).filter((p) => p.trello); + const existing = (await this.service.getAllPosters()).filter((p) => p.trello); const existingById = new Map( existing.filter((p) => p.trelloCardId).map((p) => [p.trelloCardId!, p]), ); @@ -325,7 +323,7 @@ export class TrelloPosterManager { for (const poster of existing) { const entry = poster.trelloCardId ? desiredById.get(poster.trelloCardId) : undefined; if (!entry || poster.trelloLastActivity !== entry.card.dateLastActivity) { - await service.deletePoster(poster.id); + await this.service.deletePoster(poster.id); } } @@ -345,6 +343,6 @@ export class TrelloPosterManager { if (this.refreshTimeout) clearTimeout(this.refreshTimeout); this.refreshTimeout = setTimeout(this.reloadPosters.bind(this), DEFAULT_POSTER_REFRESH); - return await service.getAllPosters(); + return await this.service.getAllPosters(); } } From f982c3f5833b4ed59a730d17c959c99eef0761c1 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 17 Jun 2026 16:41:30 +0200 Subject: [PATCH 53/58] fix: add index.ts to migrations to keep database.ts clean --- src/database.ts | 5 ++--- src/migrations/index.ts | 7 +++++++ 2 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 src/migrations/index.ts diff --git a/src/database.ts b/src/database.ts index 11ade610..8bb1d983 100644 --- a/src/database.ts +++ b/src/database.ts @@ -13,8 +13,7 @@ import { Entities as TimedEventsEntities } from './modules/timed-events/entities import Poster from './modules/handlers/screen/poster/local/poster'; import Carousel from './modules/handlers/screen/poster/local/local-carousel'; import CarouselPoster from './modules/handlers/screen/poster/local/local-carousel-poster'; -import { InitialMigration1780248780327 } from './migrations/1780248780327-InitialMigration'; -import { PosterMigration1781087463209 } from './migrations/1781087463209-PosterMigration'; +import { Migrations } from './migrations'; const dataSource = new DataSource({ host: process.env.TYPEORM_HOST, @@ -32,7 +31,7 @@ const dataSource = new DataSource({ : {}), synchronize: process.env.TYPEORM_SYNCHRONIZE === 'true', logging: process.env.TYPEORM_LOGGING === 'true', - migrations: [InitialMigration1780248780327, PosterMigration1781087463209], + migrations: Migrations, extra: { authPlugins: { mysql_clear_password: () => () => Buffer.from(`${process.env.TYPEORM_PASSWORD}\0`), diff --git a/src/migrations/index.ts b/src/migrations/index.ts new file mode 100644 index 00000000..d641aa0b --- /dev/null +++ b/src/migrations/index.ts @@ -0,0 +1,7 @@ +import { InitialMigration1780248780327 } from './1780248780327-InitialMigration'; +import { PosterMigration1781087463209 } from './1781087463209-PosterMigration'; + +export { InitialMigration1780248780327 } from './1780248780327-InitialMigration'; +export { PosterMigration1781087463209 } from './1781087463209-PosterMigration'; + +export const Migrations = [InitialMigration1780248780327, PosterMigration1781087463209]; From a14878963c9dc579b13c1a38c5bab90b8c2ae7a1 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 17 Jun 2026 16:44:48 +0200 Subject: [PATCH 54/58] fix: add poster entities.ts to keep database.ts clean --- src/database.ts | 8 ++------ src/modules/handlers/screen/poster/entities.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 src/modules/handlers/screen/poster/entities.ts diff --git a/src/database.ts b/src/database.ts index 8bb1d983..1e634d43 100644 --- a/src/database.ts +++ b/src/database.ts @@ -10,9 +10,7 @@ import { Entities as AuditEntities } from './modules/audit/entities'; import { Entities as SpotifyEntities } from './modules/spotify/entities'; import { Entities as LightsEntities } from './modules/lights/entities'; import { Entities as TimedEventsEntities } from './modules/timed-events/entities'; -import Poster from './modules/handlers/screen/poster/local/poster'; -import Carousel from './modules/handlers/screen/poster/local/local-carousel'; -import CarouselPoster from './modules/handlers/screen/poster/local/local-carousel-poster'; +import { Entities as PosterEntities } from './modules/handlers/screen/poster/entities'; import { Migrations } from './migrations'; const dataSource = new DataSource({ @@ -47,9 +45,7 @@ const dataSource = new DataSource({ ...AuditEntities, ...SpotifyEntities, ...LightsEntities, - Poster, - Carousel, - CarouselPoster, + ...PosterEntities, ], }); diff --git a/src/modules/handlers/screen/poster/entities.ts b/src/modules/handlers/screen/poster/entities.ts new file mode 100644 index 00000000..d13787b1 --- /dev/null +++ b/src/modules/handlers/screen/poster/entities.ts @@ -0,0 +1,9 @@ +import Poster from './local/poster'; +import Carousel from './local/local-carousel'; +import CarouselPoster from './local/local-carousel-poster'; + +export { default as Poster } from './local/poster'; +export { default as Carousel } from './local/local-carousel'; +export { default as CarouselPoster } from './local/local-carousel-poster'; + +export const Entities = [Poster, Carousel, CarouselPoster]; From 14cde5feee4f554b1cd8c7f2f3aab804be0391f2 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 17 Jun 2026 18:40:03 +0200 Subject: [PATCH 55/58] fix: update migration to include review changes --- .../1781087463209-PosterMigration.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/migrations/1781087463209-PosterMigration.ts b/src/migrations/1781087463209-PosterMigration.ts index 36b4b227..3abeff3f 100644 --- a/src/migrations/1781087463209-PosterMigration.ts +++ b/src/migrations/1781087463209-PosterMigration.ts @@ -11,7 +11,10 @@ export class PosterMigration1781087463209 implements MigrationInterface { `CREATE TABLE \`poster\` (\`id\` int NOT NULL AUTO_INCREMENT, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`name\` varchar(255) NOT NULL, \`type\` varchar(255) NOT NULL, \`enabled\` tinyint NOT NULL DEFAULT 1, \`label\` varchar(255) NULL, \`startDate\` datetime NULL, \`expirationDate\` datetime NULL, \`accentColor\` varchar(255) NULL, \`protected\` tinyint NOT NULL DEFAULT 0, \`borrelMode\` tinyint NOT NULL DEFAULT 0, \`footerSize\` varchar(255) NOT NULL DEFAULT 'full', \`defaultTimeout\` int NOT NULL DEFAULT '15', \`uri\` varchar(255) NULL, \`albums\` text NULL, \`trello\` tinyint NOT NULL DEFAULT 0, \`trelloCardId\` text NULL, \`trelloLastActivity\` text NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`, ); await queryRunner.query( - `CREATE TABLE \`carousel\` (\`id\` int NOT NULL AUTO_INCREMENT, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`name\` varchar(255) NOT NULL, \`active\` tinyint NOT NULL DEFAULT 1, \`posterOrder\` text NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`, + `CREATE TABLE \`carousel\` (\`id\` int NOT NULL AUTO_INCREMENT, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`name\` varchar(255) NOT NULL, \`active\` tinyint NOT NULL DEFAULT 1, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`, + ); + await queryRunner.query( + `CREATE TABLE \`carousel_poster\` (\`id\` int NOT NULL AUTO_INCREMENT, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`carouselId\` int NOT NULL, \`posterId\` int NOT NULL, \`ordering\` int NOT NULL, UNIQUE INDEX \`UQ_4239bae6dd3039260e96cbc9cc1\` (\`carouselId\`, \`ordering\`), UNIQUE INDEX \`UQ_dc2e911dd482ee95273c7bb5461\` (\`carouselId\`, \`posterId\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`, ); await queryRunner.query( `CREATE TABLE \`poster_files_file\` (\`posterId\` int NOT NULL, \`fileId\` int NOT NULL, INDEX \`IDX_b19b6773fc34c90a859006b2fa\` (\`posterId\`), INDEX \`IDX_445c020e803ef2f8d6778588ac\` (\`fileId\`), PRIMARY KEY (\`posterId\`, \`fileId\`)) ENGINE=InnoDB`, @@ -22,6 +25,12 @@ export class PosterMigration1781087463209 implements MigrationInterface { await queryRunner.query( `ALTER TABLE \`poster_files_file\` ADD CONSTRAINT \`FK_445c020e803ef2f8d6778588ac9\` FOREIGN KEY (\`fileId\`) REFERENCES \`file\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`, ); + await queryRunner.query( + `ALTER TABLE \`carousel_poster\` ADD CONSTRAINT \`FK_2c4e84aa873aa23fddc43a18b41\` FOREIGN KEY (\`carouselId\`) REFERENCES \`carousel\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE \`carousel_poster\` ADD CONSTRAINT \`FK_b26069880f6a35bc2c81d1c5481\` FOREIGN KEY (\`posterId\`) REFERENCES \`poster\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); await this.migrateLocalPosters(queryRunner); @@ -106,6 +115,15 @@ export class PosterMigration1781087463209 implements MigrationInterface { public async down(queryRunner: QueryRunner): Promise { // Schema-only reversal: posters migrated from local_poster are not converted back. + await queryRunner.query( + `ALTER TABLE \`carousel_poster\` DROP FOREIGN KEY \`FK_b26069880f6a35bc2c81d1c5481\``, + ); + await queryRunner.query( + `ALTER TABLE \`carousel_poster\` DROP FOREIGN KEY \`FK_2c4e84aa873aa23fddc43a18b41\``, + ); + await queryRunner.query(`DROP INDEX \`UQ_dc2e911dd482ee95273c7bb5461\` ON \`carousel_poster\``); + await queryRunner.query(`DROP INDEX \`UQ_4239bae6dd3039260e96cbc9cc1\` ON \`carousel_poster\``); + await queryRunner.query(`DROP TABLE \`carousel_poster\``); await queryRunner.query( `ALTER TABLE \`poster_files_file\` DROP FOREIGN KEY \`FK_445c020e803ef2f8d6778588ac9\``, ); From 1149085fa882ea816c8c2e7f98c365c7a2971439 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 17 Jun 2026 19:21:59 +0200 Subject: [PATCH 56/58] fix:re-add seed script --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 7a4a7227..c2a11ef5 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "migrate:generate": "TS_NODE_PROJECT=tsconfig.app.json typeorm-ts-node-commonjs migration:generate src/migrations/Migration -d src/database.ts", "seed": "ts-node -P tsconfig.app.json src/seed/index.ts", "seed:gewis": "ts-node -P tsconfig.app.json src/seed/index.ts --gewis", + "seed:gewis-posters": "ts-node -P tsconfig.app.json src/seed/index.ts --gewis-posters", "seed:hubble": "ts-node -P tsconfig.app.json src/seed/index.ts --hubble", "prepare-husky": "husky", "validate": "ts-node -P tsconfig.app.json src/validate.ts" From 65f0aa4ed20abcb0af56814a3a46f3cc7044ae05 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Wed, 17 Jun 2026 23:01:45 +0200 Subject: [PATCH 57/58] fix: fixed bug in migration file caused by wrongly named constraint --- src/migrations/1781087463209-PosterMigration.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/migrations/1781087463209-PosterMigration.ts b/src/migrations/1781087463209-PosterMigration.ts index 3abeff3f..898e281c 100644 --- a/src/migrations/1781087463209-PosterMigration.ts +++ b/src/migrations/1781087463209-PosterMigration.ts @@ -14,7 +14,7 @@ export class PosterMigration1781087463209 implements MigrationInterface { `CREATE TABLE \`carousel\` (\`id\` int NOT NULL AUTO_INCREMENT, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`name\` varchar(255) NOT NULL, \`active\` tinyint NOT NULL DEFAULT 1, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`, ); await queryRunner.query( - `CREATE TABLE \`carousel_poster\` (\`id\` int NOT NULL AUTO_INCREMENT, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`carouselId\` int NOT NULL, \`posterId\` int NOT NULL, \`ordering\` int NOT NULL, UNIQUE INDEX \`UQ_4239bae6dd3039260e96cbc9cc1\` (\`carouselId\`, \`ordering\`), UNIQUE INDEX \`UQ_dc2e911dd482ee95273c7bb5461\` (\`carouselId\`, \`posterId\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`, + `CREATE TABLE \`carousel_poster\` (\`id\` int NOT NULL AUTO_INCREMENT, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`carouselId\` int NOT NULL, \`posterId\` int NOT NULL, \`ordering\` int NOT NULL, UNIQUE INDEX \`IDX_4239bae6dd3039260e96cbc9cc\` (\`carouselId\`, \`ordering\`), UNIQUE INDEX \`IDX_dc2e911dd482ee95273c7bb546\` (\`carouselId\`, \`posterId\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`, ); await queryRunner.query( `CREATE TABLE \`poster_files_file\` (\`posterId\` int NOT NULL, \`fileId\` int NOT NULL, INDEX \`IDX_b19b6773fc34c90a859006b2fa\` (\`posterId\`), INDEX \`IDX_445c020e803ef2f8d6778588ac\` (\`fileId\`), PRIMARY KEY (\`posterId\`, \`fileId\`)) ENGINE=InnoDB`, @@ -121,8 +121,8 @@ export class PosterMigration1781087463209 implements MigrationInterface { await queryRunner.query( `ALTER TABLE \`carousel_poster\` DROP FOREIGN KEY \`FK_2c4e84aa873aa23fddc43a18b41\``, ); - await queryRunner.query(`DROP INDEX \`UQ_dc2e911dd482ee95273c7bb5461\` ON \`carousel_poster\``); - await queryRunner.query(`DROP INDEX \`UQ_4239bae6dd3039260e96cbc9cc1\` ON \`carousel_poster\``); + await queryRunner.query(`DROP INDEX \`IDX_dc2e911dd482ee95273c7bb546\` ON \`carousel_poster\``); + await queryRunner.query(`DROP INDEX \`IDX_4239bae6dd3039260e96cbc9cc\` ON \`carousel_poster\``); await queryRunner.query(`DROP TABLE \`carousel_poster\``); await queryRunner.query( `ALTER TABLE \`poster_files_file\` DROP FOREIGN KEY \`FK_445c020e803ef2f8d6778588ac9\``, From 673bad2b09e0999df22d40a137d57bf815a93ab0 Mon Sep 17 00:00:00 2001 From: Wanderer Date: Thu, 18 Jun 2026 11:15:36 +0200 Subject: [PATCH 58/58] feat: added tests for updated API endpoints --- .../modules/carousel-poster.spec.ts | 125 ++++++- integration-test/modules/local-poster.spec.ts | 223 ------------ integration-test/modules/poster.spec.ts | 321 ++++++++++++++++++ .../modules/static-poster.spec.ts | 79 +++++ .../handlers/screen/poster/local/poster.ts | 4 +- 5 files changed, 524 insertions(+), 228 deletions(-) delete mode 100644 integration-test/modules/local-poster.spec.ts create mode 100644 integration-test/modules/poster.spec.ts create mode 100644 integration-test/modules/static-poster.spec.ts diff --git a/integration-test/modules/carousel-poster.spec.ts b/integration-test/modules/carousel-poster.spec.ts index bb95433c..39869570 100644 --- a/integration-test/modules/carousel-poster.spec.ts +++ b/integration-test/modules/carousel-poster.spec.ts @@ -1,6 +1,6 @@ import { describe, beforeAll, it, expect } from 'vitest'; import { TestEnvironment, type TestApp } from '../shared/test-app'; -import { expectApiError } from '../shared/response-matchers'; +import { expectApiError, expectValidationError } from '../shared/response-matchers'; let testApp: TestApp; @@ -8,6 +8,21 @@ beforeAll(async () => { testApp = await TestEnvironment.getInstance().getTestApp(); }); +/** + * Creates a poster through the API and returns its id. + */ +async function createPoster(): Promise { + const res = await testApp.authorizedAgent.post('/api/handler/screen/poster/items').send({ + name: 'Carousel Test Poster', + type: 'img', + footerSize: 'full', + defaultTimeout: 15, + borrelMode: false, + }); + expect(res.status).toBe(200); + return res.body.id as number; +} + describe('GET /api/handler/screen/poster/carousel', () => { it('returns 401 without auth', async () => { // ACT @@ -27,6 +42,110 @@ describe('GET /api/handler/screen/poster/carousel', () => { }); }); +describe('GET /api/handler/screen/poster/carousel/order', () => { + it('returns 401 without auth', async () => { + // ACT + const res = await testApp.unauthorizedAgent.get('/api/handler/screen/poster/carousel/order'); + + // ASSERT + expectApiError(res, 401); + }); + + it('returns 200 with an empty array when no order is set', async () => { + // ACT + const res = await testApp.authorizedAgent.get('/api/handler/screen/poster/carousel/order'); + + // ASSERT + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); +}); + +describe('PUT /api/handler/screen/poster/carousel/order', () => { + it('returns 401 without auth', async () => { + // ACT + const res = await testApp.unauthorizedAgent + .put('/api/handler/screen/poster/carousel/order') + .send({ posterIds: [] }); + + // ASSERT + expectApiError(res, 401); + }); + + it('returns 400 when posterIds is not an array', async () => { + // ACT + const res = await testApp.authorizedAgent + .put('/api/handler/screen/poster/carousel/order') + .send({ posterIds: 'not-an-array' }); + + // ASSERT + expectValidationError(res); + }); + + it('returns 204 with an empty order', async () => { + // ACT + const res = await testApp.authorizedAgent + .put('/api/handler/screen/poster/carousel/order') + .send({ posterIds: [] }); + + // ASSERT + expect(res.status).toBe(204); + }); + + it('returns 204 and persists the given order', async () => { + // ARRANGE + const id = await createPoster(); + + // ACT + const res = await testApp.authorizedAgent + .put('/api/handler/screen/poster/carousel/order') + .send({ posterIds: [id] }); + + // ASSERT + expect(res.status).toBe(204); + + const order = await testApp.authorizedAgent.get('/api/handler/screen/poster/carousel/order'); + expect(order.body).toEqual([id]); + }); +}); + +describe('POST /api/handler/screen/poster/carousel/{id}/enabled', () => { + it('returns 401 without auth', async () => { + // ACT + const res = await testApp.unauthorizedAgent + .post('/api/handler/screen/poster/carousel/1/enabled') + .send({ enabled: true }); + + // ASSERT + expectApiError(res, 401); + }); + + it('returns 404 when the poster does not exist', async () => { + // ACT + const res = await testApp.authorizedAgent + .post('/api/handler/screen/poster/carousel/999999/enabled') + .send({ enabled: true }); + + // ASSERT + expectApiError(res, 404); + }); + + it('returns 200 and toggles the enabled state of an existing poster', async () => { + // ARRANGE + const id = await createPoster(); + + // ACT + const res = await testApp.authorizedAgent + .post(`/api/handler/screen/poster/carousel/${id}/enabled`) + .send({ enabled: false }); + + // ASSERT + expect(res.status).toBe(200); + expect(res.body.id).toBe(id); + expect(res.body.enabled).toBe(false); + }); +}); + describe('POST /api/handler/screen/poster/carousel/force-update', () => { it('returns 401 without auth', async () => { // ACT @@ -38,14 +157,14 @@ describe('POST /api/handler/screen/poster/carousel/force-update', () => { expectApiError(res, 401); }); - it('returns 500 when Trello API is not configured', async () => { + it('returns 204 when authenticated', async () => { // ACT const res = await testApp.authorizedAgent.post( '/api/handler/screen/poster/carousel/force-update', ); // ASSERT - expect(res.status).toBe(500); + expect(res.status).toBe(204); }); }); diff --git a/integration-test/modules/local-poster.spec.ts b/integration-test/modules/local-poster.spec.ts deleted file mode 100644 index 3376e2e6..00000000 --- a/integration-test/modules/local-poster.spec.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { describe, beforeAll, it, expect } from 'vitest'; -import { expectApiError } from '../shared/response-matchers'; -import { TestEnvironment, type TestApp } from '../shared/test-app'; - -let testApp: TestApp; - -beforeAll(async () => { - testApp = await TestEnvironment.getInstance().getTestApp(); -}); - -describe('GET /api/handler/screen/poster/static', () => { - it('returns 401 without auth', async () => { - // ACT - const res = await testApp.unauthorizedAgent.get('/api/handler/screen/poster/static'); - - // ASSERT - expectApiError(res, 401); - }); - - it('returns 200 with admin auth (clock visible, no active poster)', async () => { - // ACT - const res = await testApp.authorizedAgent.get('/api/handler/screen/poster/static'); - - // ASSERT - expect(res.status).toBe(200); - expect(res.body.clockVisible).toBe(true); - expect(res.body.activePoster).toBeFalsy(); - }); -}); - -describe('DELETE /api/handler/screen/poster/static', () => { - it('returns 401 without auth', async () => { - // ACT - const res = await testApp.unauthorizedAgent.delete('/api/handler/screen/poster/static'); - - // ASSERT - expectApiError(res, 401); - }); - - it('returns 204 with admin auth', async () => { - // ACT - const res = await testApp.authorizedAgent.delete('/api/handler/screen/poster/static'); - - // ASSERT - expect(res.status).toBe(204); - }); -}); - -describe('POST /api/handler/screen/poster/static/clock', () => { - it('returns 401 without auth', async () => { - // ACT - const res = await testApp.unauthorizedAgent - .post('/api/handler/screen/poster/static/clock') - .send({ visible: true }); - - // ASSERT - expectApiError(res, 401); - }); - - it('returns 204 with admin auth and valid body', async () => { - // ACT - const res = await testApp.authorizedAgent - .post('/api/handler/screen/poster/static/clock') - .send({ visible: true }); - - // ASSERT - expect(res.status).toBe(204); - }); - - it('returns 400 with empty body', async () => { - // ACT - const res = await testApp.authorizedAgent - .post('/api/handler/screen/poster/static/clock') - .send({}); - - // ASSERT - expect(res.status).toBe(400); - }); -}); - -describe('GET /api/handler/screen/poster/static/items', () => { - it('returns 401 without auth', async () => { - // ACT - const res = await testApp.unauthorizedAgent.get('/api/handler/screen/poster/static/items'); - - // ASSERT - expectApiError(res, 401); - }); - - it('returns 200 with admin auth and empty list', async () => { - // ACT - const res = await testApp.authorizedAgent.get('/api/handler/screen/poster/static/items'); - - // ASSERT - expect(res.status).toBe(200); - expect(res.body).toEqual([]); - }); -}); - -describe('POST /api/handler/screen/poster/static/items/file', () => { - it('returns 401 without auth', async () => { - // ACT - const res = await testApp.unauthorizedAgent - .post('/api/handler/screen/poster/static/items/file') - .attach('file', Buffer.from('fake-image-bytes'), { - filename: 'test.png', - contentType: 'image/png', - }); - - // ASSERT - expectApiError(res, 401); - }); - - it('returns 200 with admin auth and multipart upload', async () => { - // ACT - const res = await testApp.authorizedAgent - .post('/api/handler/screen/poster/static/items/file') - .attach('file', Buffer.from('fake-image-bytes'), { - filename: 'test.png', - contentType: 'image/png', - }); - - // ASSERT - expect(res.status).toBe(200); - expect(res.body).toHaveProperty('id'); - expect(typeof res.body.id).toBe('number'); - }); -}); - -describe('POST /api/handler/screen/poster/static/items/url', () => { - it('returns 401 without auth', async () => { - // ACT - const res = await testApp.unauthorizedAgent - .post('/api/handler/screen/poster/static/items/url') - .send({ url: 'https://example.com/poster.png' }); - - // ASSERT - expectApiError(res, 401); - }); - - it('returns 200 with admin auth and valid body', async () => { - // ACT - const res = await testApp.authorizedAgent - .post('/api/handler/screen/poster/static/items/url') - .send({ url: 'https://example.com/poster.png' }); - - // ASSERT - expect(res.status).toBe(200); - expect(res.body).toHaveProperty('id'); - expect(res.body).toHaveProperty('uri'); - expect(typeof res.body.id).toBe('number'); - }); - - it('returns 400 with empty body', async () => { - // ACT - const res = await testApp.authorizedAgent - .post('/api/handler/screen/poster/static/items/url') - .send({}); - - // ASSERT - expect(res.status).toBe(400); - }); -}); - -describe('DELETE /api/handler/screen/poster/static/items/{id} when poster does not exist', () => { - it('returns 401 without auth', async () => { - // ACT - const res = await testApp.unauthorizedAgent.delete( - '/api/handler/screen/poster/static/items/999', - ); - - // ASSERT - expectApiError(res, 401); - }); - - it('returns 404 when poster does not exist', async () => { - // ACT - const res = await testApp.authorizedAgent.delete('/api/handler/screen/poster/static/items/999'); - - // ASSERT - expect(res.status).toBe(404); - }); - - it('returns 400 with non-numeric id', async () => { - // ACT - const res = await testApp.authorizedAgent.delete('/api/handler/screen/poster/static/items/abc'); - - // ASSERT - expect(res.status).toBe(400); - }); -}); - -describe('POST /api/handler/screen/poster/static/items/{id}/show when poster does not exist', () => { - it('returns 401 without auth', async () => { - // ACT - const res = await testApp.unauthorizedAgent.post( - '/api/handler/screen/poster/static/items/999/show', - ); - - // ASSERT - expectApiError(res, 401); - }); - - it('returns 404 when poster does not exist', async () => { - // ACT - const res = await testApp.authorizedAgent.post( - '/api/handler/screen/poster/static/items/999/show', - ); - - // ASSERT - expect(res.status).toBe(404); - }); - - it('returns 400 with non-numeric id', async () => { - // ACT - const res = await testApp.authorizedAgent.post( - '/api/handler/screen/poster/static/items/abc/show', - ); - - // ASSERT - expect(res.status).toBe(400); - }); -}); diff --git a/integration-test/modules/poster.spec.ts b/integration-test/modules/poster.spec.ts new file mode 100644 index 00000000..f4b5bc43 --- /dev/null +++ b/integration-test/modules/poster.spec.ts @@ -0,0 +1,321 @@ +import { describe, beforeAll, it, expect } from 'vitest'; +import { TestEnvironment, type TestApp } from '../shared/test-app'; +import { expectApiError, expectValidationError } from '../shared/response-matchers'; + +let testApp: TestApp; + +beforeAll(async () => { + testApp = await TestEnvironment.getInstance().getTestApp(); +}); + +// Smallest valid 1x1 PNG, so file-type detection recognises it as image/png. +const PNG_BUFFER = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=', + 'base64', +); + +/** + * Minimal valid create body for a media poster. Spread and override per test. + * footerSize/defaultTimeout/borrelMode are required by the request schema. + */ +const baseMediaPoster = { + name: 'Test Poster', + type: 'img', + footerSize: 'full', + defaultTimeout: 15, + borrelMode: false, +}; + +/** + * Creates a poster through the API and returns its id. + * @param overrides Fields to merge onto the minimal media poster body. + */ +async function createPoster(overrides: Record = {}): Promise { + const res = await testApp.authorizedAgent + .post('/api/handler/screen/poster/items') + .send({ ...baseMediaPoster, ...overrides }); + expect(res.status).toBe(200); + return res.body.id as number; +} + +describe('GET /api/handler/screen/poster/items', () => { + it('returns 401 without auth', async () => { + // ACT + const res = await testApp.unauthorizedAgent.get('/api/handler/screen/poster/items'); + + // ASSERT + expectApiError(res, 401); + }); + + it('returns 200 with admin auth and an empty list initially', async () => { + // ACT + const res = await testApp.authorizedAgent.get('/api/handler/screen/poster/items'); + + // ASSERT + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); +}); + +describe('POST /api/handler/screen/poster/items', () => { + it('returns 401 without auth', async () => { + // ACT + const res = await testApp.unauthorizedAgent + .post('/api/handler/screen/poster/items') + .send(baseMediaPoster); + + // ASSERT + expectApiError(res, 401); + }); + + it('returns 400 with an unknown poster type', async () => { + // ACT + const res = await testApp.authorizedAgent + .post('/api/handler/screen/poster/items') + .send({ ...baseMediaPoster, type: 'definitely-not-a-type' }); + + // ASSERT + expectValidationError(res); + }); + + it('returns 400 when required fields are missing', async () => { + // ACT + const res = await testApp.authorizedAgent + .post('/api/handler/screen/poster/items') + .send({ type: 'img' }); + + // ASSERT + expectValidationError(res); + }); + + it('returns 200 and creates an image poster', async () => { + // ACT + const res = await testApp.authorizedAgent + .post('/api/handler/screen/poster/items') + .send({ ...baseMediaPoster, type: 'img' }); + + // ASSERT + expect(res.status).toBe(200); + expect(typeof res.body.id).toBe('number'); + expect(res.body.type).toBe('img'); + expect(res.body.enabled).toBe(true); + expect(res.body.files).toEqual([]); + }); + + it('returns 200 and creates a video poster', async () => { + // ACT + const res = await testApp.authorizedAgent + .post('/api/handler/screen/poster/items') + .send({ ...baseMediaPoster, type: 'video' }); + + // ASSERT + expect(res.status).toBe(200); + expect(res.body.type).toBe('video'); + }); + + it('returns 200 and creates an external poster with a uri', async () => { + // ACT + const res = await testApp.authorizedAgent.post('/api/handler/screen/poster/items').send({ + ...baseMediaPoster, + type: 'extern', + uri: 'https://example.com/poster.png', + }); + + // ASSERT + expect(res.status).toBe(200); + expect(res.body.type).toBe('extern'); + expect(res.body.uri).toBe('https://example.com/poster.png'); + }); + + it('returns 200 and creates a photo poster with albums', async () => { + // ACT + const res = await testApp.authorizedAgent.post('/api/handler/screen/poster/items').send({ + ...baseMediaPoster, + type: 'photo', + albums: [1, 2], + }); + + // ASSERT + expect(res.status).toBe(200); + expect(res.body.type).toBe('photo'); + expect(res.body.albums).toEqual([1, 2]); + }); +}); + +describe('GET /api/handler/screen/poster/items/{id}', () => { + it('returns 401 without auth', async () => { + // ACT + const res = await testApp.unauthorizedAgent.get('/api/handler/screen/poster/items/1'); + + // ASSERT + expectApiError(res, 401); + }); + + it('returns 400 with a non-numeric id', async () => { + // ACT + const res = await testApp.authorizedAgent.get('/api/handler/screen/poster/items/abc'); + + // ASSERT + expectValidationError(res); + }); + + it('returns 404 when the poster does not exist', async () => { + // ACT + const res = await testApp.authorizedAgent.get('/api/handler/screen/poster/items/999999'); + + // ASSERT + expectApiError(res, 404); + }); + + it('returns 200 with the poster when it exists', async () => { + // ARRANGE + const id = await createPoster(); + + // ACT + const res = await testApp.authorizedAgent.get(`/api/handler/screen/poster/items/${id}`); + + // ASSERT + expect(res.status).toBe(200); + expect(res.body.id).toBe(id); + }); +}); + +describe('PUT /api/handler/screen/poster/items/{id}/media', () => { + it('returns 401 without auth', async () => { + // ACT + const res = await testApp.unauthorizedAgent + .put('/api/handler/screen/poster/items/1/media') + .attach('file', PNG_BUFFER, { filename: 'test.png', contentType: 'image/png' }); + + // ASSERT + expectApiError(res, 401); + }); + + it('returns 415 when the uploaded file is not an image or video', async () => { + // ARRANGE + const id = await createPoster(); + + // ACT + const res = await testApp.authorizedAgent + .put(`/api/handler/screen/poster/items/${id}/media`) + .attach('file', Buffer.from('not-a-real-image'), { + filename: 'test.png', + contentType: 'image/png', + }); + + // ASSERT + expect(res.status).toBe(415); + }); + + it('returns 404 when the poster does not exist', async () => { + // ACT + const res = await testApp.authorizedAgent + .put('/api/handler/screen/poster/items/999999/media') + .attach('file', PNG_BUFFER, { filename: 'test.png', contentType: 'image/png' }); + + // ASSERT + expectApiError(res, 404); + }); + + it('returns 400 when attaching media to a non-media poster', async () => { + // ARRANGE + const id = await createPoster({ type: 'extern', uri: 'https://example.com/poster.png' }); + + // ACT + const res = await testApp.authorizedAgent + .put(`/api/handler/screen/poster/items/${id}/media`) + .attach('file', PNG_BUFFER, { filename: 'test.png', contentType: 'image/png' }); + + // ASSERT + expectApiError(res, 400); + }); + + it('returns 200 and attaches the file to a media poster', async () => { + // ARRANGE + const id = await createPoster({ type: 'img' }); + + // ACT + const res = await testApp.authorizedAgent + .put(`/api/handler/screen/poster/items/${id}/media`) + .attach('file', PNG_BUFFER, { filename: 'test.png', contentType: 'image/png' }); + + // ASSERT + expect(res.status).toBe(200); + expect(res.body.id).toBe(id); + expect(res.body.files.length).toBe(1); + }); +}); + +describe('PATCH /api/handler/screen/poster/items/{id}', () => { + it('returns 401 without auth', async () => { + // ACT + const res = await testApp.unauthorizedAgent + .patch('/api/handler/screen/poster/items/1') + .send({ label: 'Updated' }); + + // ASSERT + expectApiError(res, 401); + }); + + it('returns 404 when the poster does not exist', async () => { + // ACT + const res = await testApp.authorizedAgent + .patch('/api/handler/screen/poster/items/999999') + .send({ label: 'Updated' }); + + // ASSERT + expectApiError(res, 404); + }); + + it('returns 200 and updates the given fields', async () => { + // ARRANGE + const id = await createPoster(); + + // ACT + const res = await testApp.authorizedAgent + .patch(`/api/handler/screen/poster/items/${id}`) + .send({ label: 'Updated label' }); + + // ASSERT + expect(res.status).toBe(200); + expect(res.body.id).toBe(id); + expect(res.body.label).toBe('Updated label'); + }); +}); + +describe('DELETE /api/handler/screen/poster/items/{id}', () => { + it('returns 401 without auth', async () => { + // ACT + const res = await testApp.unauthorizedAgent.delete('/api/handler/screen/poster/items/1'); + + // ASSERT + expectApiError(res, 401); + }); + + it('returns 400 with a non-numeric id', async () => { + // ACT + const res = await testApp.authorizedAgent.delete('/api/handler/screen/poster/items/abc'); + + // ASSERT + expectValidationError(res); + }); + + it('returns 404 when the poster does not exist', async () => { + // ACT + const res = await testApp.authorizedAgent.delete('/api/handler/screen/poster/items/999999'); + + // ASSERT + expectApiError(res, 404); + }); + + it('returns 204 when the poster exists', async () => { + // ARRANGE + const id = await createPoster(); + + // ACT + const res = await testApp.authorizedAgent.delete(`/api/handler/screen/poster/items/${id}`); + + // ASSERT + expect(res.status).toBe(204); + }); +}); diff --git a/integration-test/modules/static-poster.spec.ts b/integration-test/modules/static-poster.spec.ts new file mode 100644 index 00000000..bda900bd --- /dev/null +++ b/integration-test/modules/static-poster.spec.ts @@ -0,0 +1,79 @@ +import { describe, beforeAll, it, expect } from 'vitest'; +import { expectApiError } from '../shared/response-matchers'; +import { TestEnvironment, type TestApp } from '../shared/test-app'; + +let testApp: TestApp; + +beforeAll(async () => { + testApp = await TestEnvironment.getInstance().getTestApp(); +}); + +describe('GET /api/handler/screen/poster/static', () => { + it('returns 401 without auth', async () => { + // ACT + const res = await testApp.unauthorizedAgent.get('/api/handler/screen/poster/static'); + + // ASSERT + expectApiError(res, 401); + }); + + it('returns 200 with admin auth (clock visible, no active poster)', async () => { + // ACT + const res = await testApp.authorizedAgent.get('/api/handler/screen/poster/static'); + + // ASSERT + expect(res.status).toBe(200); + expect(res.body.clockVisible).toBe(true); + expect(res.body.activePoster).toBeFalsy(); + }); +}); + +describe('DELETE /api/handler/screen/poster/static', () => { + it('returns 401 without auth', async () => { + // ACT + const res = await testApp.unauthorizedAgent.delete('/api/handler/screen/poster/static'); + + // ASSERT + expectApiError(res, 401); + }); + + it('returns 204 with admin auth', async () => { + // ACT + const res = await testApp.authorizedAgent.delete('/api/handler/screen/poster/static'); + + // ASSERT + expect(res.status).toBe(204); + }); +}); + +describe('POST /api/handler/screen/poster/static/clock', () => { + it('returns 401 without auth', async () => { + // ACT + const res = await testApp.unauthorizedAgent + .post('/api/handler/screen/poster/static/clock') + .send({ visible: true }); + + // ASSERT + expectApiError(res, 401); + }); + + it('returns 204 with admin auth and valid body', async () => { + // ACT + const res = await testApp.authorizedAgent + .post('/api/handler/screen/poster/static/clock') + .send({ visible: true }); + + // ASSERT + expect(res.status).toBe(204); + }); + + it('returns 400 with empty body', async () => { + // ACT + const res = await testApp.authorizedAgent + .post('/api/handler/screen/poster/static/clock') + .send({}); + + // ASSERT + expect(res.status).toBe(400); + }); +}); diff --git a/src/modules/handlers/screen/poster/local/poster.ts b/src/modules/handlers/screen/poster/local/poster.ts index 2f14bf8e..d307f8bc 100644 --- a/src/modules/handlers/screen/poster/local/poster.ts +++ b/src/modules/handlers/screen/poster/local/poster.ts @@ -45,7 +45,7 @@ export default class Poster extends BaseEntity { /** * Type of the poster. Should be a valid PosterType. */ - @Column() + @Column({ type: 'varchar' }) type: PosterType; /** @@ -93,7 +93,7 @@ export default class Poster extends BaseEntity { /** * The size of the footer. Should be a valid FooterSize. */ - @Column({ default: FooterSize.FULL }) + @Column({ type: 'varchar', default: FooterSize.FULL }) footerSize: FooterSize; /**