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/package.json b/package.json index 44516223..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" @@ -42,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/database.ts b/src/database.ts index aeeb2e6b..1e634d43 100644 --- a/src/database.ts +++ b/src/database.ts @@ -10,8 +10,8 @@ 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 { InitialMigration1780248780327 } from './migrations/1780248780327-InitialMigration'; +import { Entities as PosterEntities } from './modules/handlers/screen/poster/entities'; +import { Migrations } from './migrations'; const dataSource = new DataSource({ host: process.env.TYPEORM_HOST, @@ -29,7 +29,7 @@ const dataSource = new DataSource({ : {}), synchronize: process.env.TYPEORM_SYNCHRONIZE === 'true', logging: process.env.TYPEORM_LOGGING === 'true', - migrations: [InitialMigration1780248780327], + migrations: Migrations, extra: { authPlugins: { mysql_clear_password: () => () => Buffer.from(`${process.env.TYPEORM_PASSWORD}\0`), @@ -45,7 +45,7 @@ const dataSource = new DataSource({ ...AuditEntities, ...SpotifyEntities, ...LightsEntities, - LocalPoster, + ...PosterEntities, ], }); diff --git a/src/index.ts b/src/index.ts index 953af145..462e7778 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,17 @@ async function createApp(): Promise { await SpotifyTrackHandler.getInstance().init(emitterStore.musicEmitter); } + 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)); + } + if (featureFlagManager.flagIsEnabled('Orders')) { OrderManager.getInstance().init(emitterStore.orderEmitter); } diff --git a/src/migrations/1781087463209-PosterMigration.ts b/src/migrations/1781087463209-PosterMigration.ts new file mode 100644 index 00000000..898e281c --- /dev/null +++ b/src/migrations/1781087463209-PosterMigration.ts @@ -0,0 +1,149 @@ +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, 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 \`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`, + ); + 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 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); + + 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 \`carousel_poster\` DROP FOREIGN KEY \`FK_b26069880f6a35bc2c81d1c5481\``, + ); + await queryRunner.query( + `ALTER TABLE \`carousel_poster\` DROP FOREIGN KEY \`FK_2c4e84aa873aa23fddc43a18b41\``, + ); + 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\``, + ); + 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/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]; 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/carousel-poster-controller.ts b/src/modules/handlers/screen/poster/carousel-poster-controller.ts index b3c034ac..e9ca070f 100644 --- a/src/modules/handlers/screen/poster/carousel-poster-controller.ts +++ b/src/modules/handlers/screen/poster/carousel-poster-controller.ts @@ -11,8 +11,10 @@ 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 { PosterResponse } from './local/poster-service'; +import Poster from './local/poster'; +import CarouselPosterService, { CAROUSEL_ID } from './local/carousel-poster-service'; export interface BorrelModeParams { enabled: boolean; @@ -22,11 +24,19 @@ export interface BorrelModeResponse extends BorrelModeParams { present: boolean; } -export interface PosterResponse { - posters: Poster[]; +export interface EnabledParams { + enabled: boolean; +} + +export interface CarouselResponse { + posters: PosterResponse[]; borrelMode: boolean; } +export interface CarouselOrderParams { + posterIds: number[]; +} + @Route('handler/screen/poster/carousel') @Tags('Handlers') @FeatureEnabled('Poster') @@ -42,34 +52,50 @@ 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, - }; - } + public async getPosters(@Query() includeHidden?: boolean): Promise { + const posters = await this.screenHandler.posterService.getAllPosters(); + + 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])); + visible.sort((a, b) => (rank.get(a.id) ?? Infinity) - (rank.get(b.id) ?? Infinity)); 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.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 { - 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(); } @@ -96,6 +122,16 @@ export class CarouselPosterController extends Controller { this.screenHandler.setBorrelModeEnabled(body.enabled); } + @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) + @Post('{id}/enabled') + 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); + } + @Security(SecurityNames.LOCAL, securityGroups.poster.base) @Get('train-departures') public async getTrains(): Promise { diff --git a/src/modules/handlers/screen/poster/carousel-poster-handler.ts b/src/modules/handlers/screen/poster/carousel-poster-handler.ts index 5f990579..214ce03a 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 PosterService from './local/poster-service'; @FeatureEnabled('Poster') export default class CarouselPosterHandler extends BaseScreenHandler { - public posterManager: PosterManager; + public posterService: PosterService; 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 PosterService(); } forceUpdate(): void { 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]; 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..3fb91636 --- /dev/null +++ b/src/modules/handlers/screen/poster/local/carousel-poster-service.ts @@ -0,0 +1,50 @@ +import Carousel from './local-carousel'; +import CarouselPoster from './local-carousel-poster'; +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 { + /** + * 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 rows = await dataSource.getRepository(CarouselPoster).find({ + where: { carouselId }, + order: { ordering: 'ASC' }, + select: { posterId: true }, + }); + return rows.map((row) => row.posterId); + } + + /** + * 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 { + 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({ carouselId: carousel.id }); + if (posterIds.length > 0) { + await repo.insert( + posterIds.map((posterId, ordering) => ({ + 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 new file mode 100644 index 00000000..b3ff1822 --- /dev/null +++ b/src/modules/handlers/screen/poster/local/local-carousel-poster.ts @@ -0,0 +1,32 @@ +import { Entity, Column, ManyToOne, JoinColumn, 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 { + @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() + ordering: number; +} 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..0ad073ac --- /dev/null +++ b/src/modules/handlers/screen/poster/local/local-carousel.ts @@ -0,0 +1,15 @@ +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 { + @Column() + name: string; + + @Column({ default: true }) + active: boolean; + + @OneToMany(() => CarouselPoster, (carouselPoster) => carouselPoster.carousel) + posters: CarouselPoster[]; +} diff --git a/src/modules/handlers/screen/poster/local/local-poster-controller.ts b/src/modules/handlers/screen/poster/local/local-poster-controller.ts deleted file mode 100644 index f16304a4..00000000 --- a/src/modules/handlers/screen/poster/local/local-poster-controller.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { Controller, TsoaResponse, UploadedFile } from '@tsoa/runtime'; -import { Body, Delete, Get, Post, Request, Res, Route, Security, Tags } from 'tsoa'; -import { StaticPosterHandler } from '../../index'; -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 { Request as ExpressRequest } from 'express'; -import logger from '../../../../../logger'; -import { HttpStatusCode } from 'axios'; -import { StaticPosterHandlerState } from '../static-poster-handler'; -import { lookup } from 'mime-types'; - -interface SetClockRequest { - visible: boolean; -} - -@Route('handler/screen/poster/static') -@Tags('Handlers') -export class LocalPosterController extends Controller { - private screenHandler: StaticPosterHandler; - - constructor() { - super(); - this.screenHandler = HandlerManager.getInstance() - .getHandlers(Screen) - .filter((h) => h.constructor.name === StaticPosterHandler.name)[0] as StaticPosterHandler; - } - - /** - * Return the current state (or settings) of the static poster handler - */ - @Security(SecurityNames.LOCAL, securityGroups.poster.base) - @Get('') - public async getStaticPosterHandlerState(): Promise { - return this.screenHandler.getState(); - } - - /** - * Hide the static poster currently shown on screens. The subscribers should - * revert to their default view. - * @param req - */ - @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) - @Delete('') - public async hideStaticPoster(@Request() req: ExpressRequest): Promise { - logger.audit(req.user, `Hide static poster.`); - this.screenHandler.removeActivePoster(); - } - - /** - * Chang the visibility of the clock on-screen - */ - @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) - @Post('clock') - public async setStaticPosterClock( - @Request() req: ExpressRequest, - @Body() body: 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.'); - } - - 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 LocalPosterService(); - const posters = await service.getAllLocalPosters(); - 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 LocalPosterService(); - const poster = await service.createLocalPoster({ - 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 LocalPosterService(); - const poster = await service.createLocalPoster({ - 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 LocalPosterService(); - await service.deleteLocalPoster(id); - } - - /** - * Show the given static poster on all screens using the StaticPosterHandler. - * @param id - * @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(); - const poster = await service.getSingleLocalPoster(id); - const posterResponse = service.toResponse(poster); - - logger.audit(req.user, `Show static poster (id: ${id}).`); - this.screenHandler.setActivePoster(posterResponse); - } -} diff --git a/src/modules/handlers/screen/poster/local/local-poster-service.ts b/src/modules/handlers/screen/poster/local/local-poster-service.ts deleted file mode 100644 index 1789ed58..00000000 --- a/src/modules/handlers/screen/poster/local/local-poster-service.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { FileStorage } from '../../../../files/storage/file-storage'; -import { DiskStorage } from '../../../../files/storage'; -import LocalPoster from './local-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 LocalPosterResponse { - id: number; - createdAt: string; - updatedAt: string; - file?: FileResponse; - uri?: string; -} - -export interface LocalPosterRequest { - file?: { - name: string; - data: Buffer; - }; - uri?: string; -} - -export default class LocalPosterService { - private storage: FileStorage; - - private repo: Repository; - - private fileRepo: Repository; - - constructor() { - this.storage = new DiskStorage('local-posters'); - this.repo = dataSource.getRepository(LocalPoster); - this.fileRepo = dataSource.getRepository(File); - } - - public toResponse(poster: LocalPoster): 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, - createdAt: poster.createdAt.toISOString(), - updatedAt: poster.updatedAt.toISOString(), - file, - uri: poster.uri ?? undefined, - }; - } - - public async getAllLocalPosters(): Promise { - return this.repo.find(); - } - - public async getSingleLocalPoster(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 createLocalPoster(poster: LocalPosterRequest): 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 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); - } -} diff --git a/src/modules/handlers/screen/poster/local/local-poster.ts b/src/modules/handlers/screen/poster/local/local-poster.ts deleted file mode 100644 index 649edd95..00000000 --- a/src/modules/handlers/screen/poster/local/local-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 LocalPoster 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; -} diff --git a/src/modules/handlers/screen/poster/local/poster-controller.ts b/src/modules/handlers/screen/poster/local/poster-controller.ts new file mode 100644 index 00000000..d1a3c154 --- /dev/null +++ b/src/modules/handlers/screen/poster/local/poster-controller.ts @@ -0,0 +1,112 @@ +import { Controller, Patch, TsoaResponse, UploadedFile } from '@tsoa/runtime'; +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 PosterService, { + CreatePosterRequest, + PosterResponse, + UpdatePosterRequest, +} from './poster-service'; +import { PosterType } from './poster'; +import { fromBuffer } from 'file-type'; +import { FeatureEnabled } from '../../../../server-settings'; + +@Route('handler/screen/poster') +@Tags('Handlers') +@FeatureEnabled('Poster') +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.getAllPosters(); + return posters.map((poster) => this.service.toResponse(poster)); + } + + /** + * 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 { + const poster = await this.service.getSinglePoster(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: CreatePosterRequest, + @Res() + invalidPosterTypeResponse: TsoaResponse, + ): Promise { + 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); + } + + /** + * 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 + */ + @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) + @Put('items/{id}/media') + public async attachMedia( + id: number, + @UploadedFile() file: Express.Multer.File, + @Res() + invalidFileTypeResponse: TsoaResponse, + ): Promise { + 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.', + ); + } + + const poster = await this.service.attachMedia(id, file.originalname, file.buffer); + return this.service.toResponse(poster); + } + + /** + * 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 { + await this.service.deletePoster(id); + } + + /** + * 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: UpdatePosterRequest, + ): Promise { + const poster = await this.service.updatePoster(id, 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 new file mode 100644 index 00000000..3c0fe527 --- /dev/null +++ b/src/modules/handlers/screen/poster/local/poster-service.ts @@ -0,0 +1,224 @@ +import { FileStorage } from '../../../../files/storage/file-storage'; +import { Repository } from 'typeorm'; +import { File } from '../../../../files/entities'; +import Poster, { FooterSize, PosterType } from './poster'; +import { DiskStorage } from '../../../../files/storage'; +import dataSource from '../../../../../database'; +import { HttpApiException } from '../../../../../helpers/custom-error'; +import { HttpStatusCode } from 'axios'; +import FileResponse from '../../../../files/entities/file-response'; + +type BasePosterFields = + | 'name' + | 'label' + | 'startDate' + | 'expirationDate' + | 'accentColor' + | 'footerSize' + | 'defaultTimeout' + | 'borrelMode'; + +export interface BasePosterParams extends Pick { + trello?: boolean; +} + +export interface MediaPosterRequest extends BasePosterParams { + type: PosterType.IMAGE | PosterType.VIDEO; +} + +export interface ExternalPosterRequest extends BasePosterParams, Required> { + type: PosterType.EXTERNAL; +} + +export interface PhotoPosterRequest extends BasePosterParams, Required> { + type: PosterType.PHOTO; +} + +export type CreatePosterRequest = MediaPosterRequest | ExternalPosterRequest | PhotoPosterRequest; + +export interface UpdatePosterRequest extends Partial< + Pick< + Poster, + | 'name' + | 'label' + | 'startDate' + | 'expirationDate' + | 'accentColor' + | 'footerSize' + | 'defaultTimeout' + | 'borrelMode' + | 'albums' + > +> {} + +export interface PosterResponse { + id: number; + name: string; + label?: string; + type: PosterType; + enabled: boolean; + createdAt: string; + updatedAt: string; + startDate?: Date; + expirationDate?: Date; + accentColor?: string; + footerSize: FooterSize; + defaultTimeout: number; + borrelMode: boolean; + protected: boolean; + trello: boolean; + uri?: string; + albums?: number[]; + files: FileResponse[]; +} + +export default class PosterService { + private storage: FileStorage; + + private repo: Repository; + + private fileRepo: Repository; + + constructor() { + this.storage = new DiskStorage('posters'); + this.repo = dataSource.getRepository(Poster); + this.fileRepo = dataSource.getRepository(File); + } + + /** + * Converts a poster entity to the PosterResponse format. + * @param poster The poster to be converted. + */ + public toResponse(poster: Poster): PosterResponse { + const files: FileResponse[] = (poster.files ?? []).reduce((acc, file) => { + const location = this.storage.getPublicFileUri(file); + if (location) { + acc.push({ location, name: file.originalName }); + } + return acc; + }, []); + + return { + id: poster.id, + name: poster.name, + label: poster.label ?? undefined, + type: poster.type, + 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, + defaultTimeout: poster.defaultTimeout, + borrelMode: poster.borrelMode, + protected: poster.protected, + trello: poster.trello, + uri: poster.uri ?? undefined, + albums: poster.albums ?? undefined, + files, + }; + } + + /** + * Fetches all Local Posters from the database. + */ + public async getAllPosters(): Promise { + return this.repo.find(); + } + + /** + * Gets a specific Local Poster from the database. + * @param id The id of the poster to fetch. + */ + public async getSinglePoster(id: number): Promise { + const poster = await this.repo.findOneBy({ id }); + if (poster === null) { + throw new HttpApiException(HttpStatusCode.NotFound, `Poster with ID "${id}" not found.`); + } + return poster; + } + + /** + * 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 createPoster(params: CreatePosterRequest): Promise { + return this.repo.save({ + ...params, + uri: params.type === PosterType.EXTERNAL ? params.uri : undefined, + albums: params.type === PosterType.PHOTO ? params.albums : undefined, + }); + } + + /** + * 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 { + const poster = await this.getSinglePoster(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); + try { + 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; + } + } + + /** + * Deletes the given poster from the database and storage. + * @param id The id of the poster to be deleted. + */ + public async deletePoster(id: number): Promise { + const poster = await this.getSinglePoster(id); + const files = poster.files ?? []; + await this.repo.remove(poster); + await Promise.all( + files.map(async (file) => { + await this.storage.deleteFile(file); + }), + ); + } + + /** + * 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 updatePoster(id: number, params: UpdatePosterRequest): Promise { + 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); + }); + } + + /** + * 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 { + await this.repo.update({ id }, { enabled }); + return this.getSinglePoster(id); + } +} diff --git a/src/modules/handlers/screen/poster/local/poster.ts b/src/modules/handlers/screen/poster/local/poster.ts new file mode 100644 index 00000000..d307f8bc --- /dev/null +++ b/src/modules/handlers/screen/poster/local/poster.ts @@ -0,0 +1,149 @@ +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', + 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', +} + +/** + * 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: 'varchar' }) + 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({ type: 'varchar', 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' }, + inverseJoinColumn: { name: 'fileId', referencedColumnName: 'id' }, + }) + 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; +} 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-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/poster.ts b/src/modules/handlers/screen/poster/poster.ts deleted file mode 100644 index 50a146ac..00000000 --- a/src/modules/handlers/screen/poster/poster.ts +++ /dev/null @@ -1,66 +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 { - id: string; - 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 b966b8d7..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 { LocalPosterResponse } from './local/local-poster-service'; import { FeatureEnabled } from '../../../server-settings'; +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 new file mode 100644 index 00000000..d4ab586a --- /dev/null +++ b/src/modules/handlers/screen/poster/static/static-poster-controller.ts @@ -0,0 +1,81 @@ +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'; +import { SecurityNames } from '../../../../../helpers/security'; +import { securityGroups } from '../../../../../helpers/security-groups'; +import { Request as ExpressRequest } from 'express'; +import logger from '../../../../../logger'; +import { StaticPosterHandlerState } from '../static-poster-handler'; +import PosterService from '../local/poster-service'; + +interface SetClockRequest { + visible: boolean; +} + +@Route('handler/screen/poster/static') +@Tags('Handlers') +export class StaticPosterController extends Controller { + private screenHandler: StaticPosterHandler; + + constructor() { + super(); + this.screenHandler = HandlerManager.getInstance() + .getHandlers(Screen) + .filter((h) => h.constructor.name === StaticPosterHandler.name)[0] as StaticPosterHandler; + } + + /** + * Return the current state (or settings) of the static poster handler + */ + @Security(SecurityNames.LOCAL, securityGroups.poster.base) + @Get('') + public async getStaticPosterHandlerState(): Promise { + return this.screenHandler.getState(); + } + + /** + * Hide the static poster currently shown on screens. The subscribers should + * revert to their default view. + * @param req + */ + @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) + @Delete('') + public async hideStaticPoster(@Request() req: ExpressRequest): Promise { + logger.audit(req.user, `Hide static poster.`); + this.screenHandler.removeActivePoster(); + } + + /** + * Change the visibility of the clock on-screen + */ + @Security(SecurityNames.LOCAL, securityGroups.poster.privileged) + @Post('clock') + public async setStaticPosterClock( + @Request() req: ExpressRequest, + @Body() body: SetClockRequest, + ): Promise { + logger.audit( + req.user, + `Make clock in StaticPosterHandler ${body.visible ? 'visible' : 'invisible'}.`, + ); + this.screenHandler.setClockVisibility(body.visible); + } + /** + * Show the given static poster on all screens using the StaticPosterHandler. + * @param id + * @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 PosterService(); + const poster = await service.getSinglePoster(id); + const posterResponse = service.toResponse(poster); + + logger.audit(req.user, `Show static poster (id: ${id}).`); + this.screenHandler.setActivePoster(posterResponse); + } +} 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..b674c1e8 100644 --- a/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts +++ b/src/modules/handlers/screen/poster/trello/trello-poster-manager.ts @@ -1,100 +1,115 @@ -import { randomUUID } from 'crypto'; -import { PosterManager } from '../poster-manager'; -import { - BasePoster, - ErrorPoster, - FooterSize, - LocalPoster, - LocalPosterType, - MediaPoster, - Poster, - PosterType, -} from '../poster'; import { Board, Card, Checklist, TrelloClient, TrelloList } from './client'; -import { TrelloPosterStorage } from './trello-poster-storage'; +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; + checklists: Checklist[]; + type: PosterType; +} const DEFAULT_POSTER_TIMEOUT = 15; const DEFAULT_POSTER_REFRESH = 1000 * 60 * 15; -export class TrelloPosterManager extends PosterManager { +@FeatureEnabled('Poster.Trello') +export class TrelloPosterManager { private client: TrelloClient; + private service = new PosterService(); + private refreshTimeout: NodeJS.Timeout | undefined = undefined; constructor() { - super(); this.client = new TrelloClient(); } /** - * 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, - ): Promise { + visitedLists: Set = new Set(), + ): CardEntry[] { + 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) || []; 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); - } - 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; - // 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': - 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; - } - - 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; - }), - ); + // 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 posters.filter((p) => p !== undefined).flat() as Poster[]; + if (type) entries.push({ card, checklists, type }); + } + + return entries; + } + + /** + * 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; + } } /** @@ -104,7 +119,7 @@ export class TrelloPosterManager extends PosterManager { * @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( @@ -126,7 +141,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 +167,50 @@ 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; } + + let localPoster = await this.service.createPoster({ + 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 source = await Promise.all( - attachments.map(async (attachment) => { - const storage = new TrelloPosterStorage(); - return storage.storeAttachment(attachment); - }), + const mediaAttachments = attachments.filter((a) => + a.mimeType.startsWith(type === PosterType.IMAGE ? 'image/' : 'video/'), ); + if (mediaAttachments.length === 0) return undefined; - return { - ...poster, - type, - source, + const headers = { + Authorization: `OAuth oauth_consumer_key="${process.env.TRELLO_KEY}", oauth_token="${process.env.TRELLO_TOKEN}"`, }; + + // 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 this.service.attachMedia( + localPoster.id, + attachment.fileName, + Buffer.from(resp.data), + ); + } + + return localPoster; } /** @@ -189,26 +224,30 @@ 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); + return this.service.createPoster({ + 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 +262,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 +278,27 @@ 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); + + return this.service.createPoster({ + 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 +310,39 @@ 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 repo = dataSource.getRepository(Poster); - if (this.refreshTimeout) clearTimeout(this.refreshTimeout); - this.refreshTimeout = setTimeout(this.fetchPosters.bind(this), DEFAULT_POSTER_REFRESH); + const desired = this.collectCards(list, board); + const desiredById = new Map(desired.filter((d) => d.card.id).map((d) => [d.card.id!, d])); - return this._posters; - } + const existing = (await this.service.getAllPosters()).filter((p) => p.trello); + const existingById = new Map( + existing.filter((p) => p.trelloCardId).map((p) => [p.trelloCardId!, p]), + ); - /** - * @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('\\', '/')}`), - }; + for (const poster of existing) { + const entry = poster.trelloCardId ? desiredById.get(poster.trelloCardId) : undefined; + if (!entry || poster.trelloLastActivity !== entry.card.dateLastActivity) { + await this.service.deletePoster(poster.id); } - return p; - }); + } + + 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); + + return await this.service.getAllPosters(); } } 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); - } - }); - }), - ); - } -} diff --git a/src/modules/timed-events/cron-manager.ts b/src/modules/timed-events/cron-manager.ts index c4af39e7..adadbecb 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 LocalPosterService from '../handlers/screen/poster/local/local-poster-service'; import { Screen } from '../root/entities'; import { StaticPosterHandler } from '../handlers/screen'; +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 d1e129fa..f1b38671 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'; @@ -14,9 +14,26 @@ 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( @@ -48,6 +65,10 @@ 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().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(); diff --git a/src/seed/seedGewis.ts b/src/seed/seedGewis.ts index 0fe11978..5c0732fb 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,8 +20,8 @@ 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 Poster, { FooterSize, PosterType } from '../modules/handlers/screen/poster/local/poster'; export default async function seedDatabase() { const timedEventsRepo = dataSource.getRepository(TimedEvent); @@ -775,3 +775,76 @@ export async function seedOpeningSequence( await addStep(borrelRuimte, 285000, 15000, [room]); await addStep(borrelBar, 285000, 15000, [bar]); } + +export async function seedPosters() { + const repo = dataSource.getRepository(Poster); + + await repo.save([ + { + name: 'Logo', + type: PosterType.LOGO, + enabled: true, + label: 'GEWIS Logo', + protected: true, + 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 Poster[]); +}