diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a5e3fc825f8..0d84f9bdc1c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -88,9 +88,9 @@ jobs: - uses: actions/setup-java@v4 with: distribution: 'temurin' - java-version: '17' + java-version: '21' - name: SonarCloud upload coverage - uses: SonarSource/sonarcloud-github-action@v4.0.0 + uses: SonarSource/sonarqube-scan-action@v4.2.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONARCLOUD_TOKEN }} diff --git a/ansible/roles/media-metadata-sync/defaults/main.yml b/ansible/roles/media-metadata-sync/defaults/main.yml new file mode 100644 index 00000000000..fd5db6d673f --- /dev/null +++ b/ansible/roles/media-metadata-sync/defaults/main.yml @@ -0,0 +1 @@ +SERVER_MEDIA_METADATA_SYNC_CRONJOB_SCHEDULE: "30 2 * * *" diff --git a/ansible/roles/media-metadata-sync/meta/main.yml b/ansible/roles/media-metadata-sync/meta/main.yml new file mode 100644 index 00000000000..4f0fdb787f4 --- /dev/null +++ b/ansible/roles/media-metadata-sync/meta/main.yml @@ -0,0 +1,9 @@ +galaxy_info: + role_name: media-metadata-sync + author: Schul-Cloud Verbund + description: media-metadata-sync role for the media metadata synchronization purposes + company: Schul-Cloud Verbund + license: license (AGPLv3) + min_ansible_version: 2.8 + galaxy_tags: [] +dependencies: [] diff --git a/ansible/roles/media-metadata-sync/tasks/main.yml b/ansible/roles/media-metadata-sync/tasks/main.yml new file mode 100644 index 00000000000..c2c9a0f1295 --- /dev/null +++ b/ansible/roles/media-metadata-sync/tasks/main.yml @@ -0,0 +1,17 @@ +- name: media metadata sync CronJob ConfigMap + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: media-metadata-sync-cronjob-configmap.yml.j2 + state: "{{ 'present' if WITH_MEDIA_METADATA_SYNC is defined and WITH_MEDIA_METADATA_SYNC|bool else 'absent'}}" + tags: + - configmap + +- name: media metadata sync sync CronJob + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: media-metadata-sync-cronjob.yml.j2 + state: "{{ 'present' if WITH_MEDIA_METADATA_SYNC is defined and WITH_MEDIA_METADATA_SYNC|bool else 'absent'}}" + tags: + - cronjob diff --git a/ansible/roles/media-metadata-sync/templates/media-metadata-sync-cronjob-configmap.yml.j2 b/ansible/roles/media-metadata-sync/templates/media-metadata-sync-cronjob-configmap.yml.j2 new file mode 100644 index 00000000000..6b0a8242ec7 --- /dev/null +++ b/ansible/roles/media-metadata-sync/templates/media-metadata-sync-cronjob-configmap.yml.j2 @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + namespace: {{ NAMESPACE }} + name: media-metadata-sync-cronjob-configmap + labels: + app: media-metadata-sync-cronjob +data: + NODE_OPTIONS: "--max-old-space-size=1536" + NEST_LOG_LEVEL: "info" + EXIT_ON_ERROR: "true" diff --git a/ansible/roles/media-metadata-sync/templates/media-metadata-sync-cronjob.yml.j2 b/ansible/roles/media-metadata-sync/templates/media-metadata-sync-cronjob.yml.j2 new file mode 100644 index 00000000000..ec55dbb3a6a --- /dev/null +++ b/ansible/roles/media-metadata-sync/templates/media-metadata-sync-cronjob.yml.j2 @@ -0,0 +1,97 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + namespace: {{ NAMESPACE }} + labels: + app: media-metadata-sync-cronjob + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: media-metadata-sync-cronjob + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} + name: media-metadata-sync-cronjob +spec: + schedule: {{ SERVER_MEDIA_METADATA_SYNC_CRONJOB_SCHEDULE|default("30 2 * * *", true) }} + concurrencyPolicy: Forbid + jobTemplate: + spec: + template: + metadata: + labels: + app: media-metadata-sync-cronjob + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: media-metadata-sync-cronjob + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} + spec: + containers: + - name: media-metadata-sync-cronjob + image: {{ SCHULCLOUD_SERVER_IMAGE }}:{{ SCHULCLOUD_SERVER_IMAGE_TAG }} + envFrom: + - configMapRef: + name: api-configmap + - secretRef: + name: api-secret + - configMapRef: + name: media-metadata-sync-cronjob-configmap + command: ['/bin/sh','-c'] + args: ['npm run nest:start:sync:media-metadata'] + resources: + limits: + cpu: {{ MEDIA_METADATA_SYNC_CPU_LIMITS|default("2000m", true) }} + memory: {{ MEDIA_METADATA_SYNC_MEMORY_LIMITS|default("2Gi", true) }} + requests: + cpu: {{ MEDIA_METADATA_SYNC_CPU_REQUESTS|default("100m", true) }} + memory: {{ MEDIA_METADATA_SYNC_MEMORY_REQUESTS|default("150Mi", true) }} + restartPolicy: OnFailure +{% if AFFINITY_ENABLE is defined and AFFINITY_ENABLE|bool %} + affinity: + podAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 20 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app.kubernetes.io/part-of + operator: In + values: + - schulcloud-verbund + topologyKey: "kubernetes.io/hostname" + namespaceSelector: {} + - weight: 10 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: git.repo + operator: In + values: + - {{ SCHULCLOUD_SERVER_REPO_NAME }} + topologyKey: "kubernetes.io/hostname" + namespaceSelector: {} + - weight: 10 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: git.branch + operator: In + values: + - {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + topologyKey: "kubernetes.io/hostname" + namespaceSelector: {} + - weight: 10 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app.kubernetes.io/version + operator: In + values: + - {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + topologyKey: "kubernetes.io/hostname" + namespaceSelector: {} +{% endif %} + diff --git a/apps/server/src/infra/sync/console/README.md b/apps/server/src/infra/sync/console/README.md index 130dc31988b..8141b6b0610 100644 --- a/apps/server/src/infra/sync/console/README.md +++ b/apps/server/src/infra/sync/console/README.md @@ -10,13 +10,14 @@ npm run nest:start:console sync run Where `` is the name of the system you want to start the synchronization for. The currently available systems are: - `tsp` - Synchronize Thüringer schulportal. - `vidis` - Synchronize Vidis Activation Data. +- `media_metadata` - Synchronize media metadata in ctl tools in SVS with data from its media source. If the target is not provided, the synchronization will not start and the available targets will be displayed in an error message. ```bash { message: 'Either synchronization is not activated or the target entered is invalid', - data: { enteredTarget: 'tsp', availableTargets: { TSP: 'tsp', VIDIS: 'vidis' }} + data: { enteredTarget: 'tsp', availableTargets: { TSP: 'tsp', VIDIS: 'vidis', MEDIA_METADTA: 'media_metadata' }} } ``` diff --git a/apps/server/src/infra/sync/index.ts b/apps/server/src/infra/sync/index.ts index 02a7612b301..8cd53e619fb 100644 --- a/apps/server/src/infra/sync/index.ts +++ b/apps/server/src/infra/sync/index.ts @@ -1,2 +1,3 @@ export * from './tsp'; export * from './media-licenses'; +export * from './media-metadata'; diff --git a/apps/server/src/infra/sync/media-metadata/index.ts b/apps/server/src/infra/sync/media-metadata/index.ts new file mode 100644 index 00000000000..9f73814a83e --- /dev/null +++ b/apps/server/src/infra/sync/media-metadata/index.ts @@ -0,0 +1 @@ +export { MediaMetadataSyncStrategy } from './strategy'; diff --git a/apps/server/src/infra/sync/media-metadata/loggable/index.ts b/apps/server/src/infra/sync/media-metadata/loggable/index.ts new file mode 100644 index 00000000000..461925fc0d9 --- /dev/null +++ b/apps/server/src/infra/sync/media-metadata/loggable/index.ts @@ -0,0 +1 @@ +export { MediaMetadataSyncReportLoggable } from './media-metadata-sync-report.loggable'; diff --git a/apps/server/src/infra/sync/media-metadata/loggable/media-metadata-sync-report.loggable.spec.ts b/apps/server/src/infra/sync/media-metadata/loggable/media-metadata-sync-report.loggable.spec.ts new file mode 100644 index 00000000000..c44cb96fd37 --- /dev/null +++ b/apps/server/src/infra/sync/media-metadata/loggable/media-metadata-sync-report.loggable.spec.ts @@ -0,0 +1,48 @@ +import { MediaSourceSyncOperationReport } from '@modules/media-source/domain'; +import { mediaSourceSyncReportFactory } from '@modules/media-source/testing'; +import { MediaMetadataSyncReportLoggable } from './media-metadata-sync-report.loggable'; + +describe(MediaMetadataSyncReportLoggable.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const report = mediaSourceSyncReportFactory.build(); + + const loggable = new MediaMetadataSyncReportLoggable(report); + + let expectedMessage = + 'Media metadata sync had finished\n' + + `Total media processed: ${report.totalCount}\n` + + `Total successful sync: ${report.successCount}\n` + + `Total failed sync: ${report.failedCount}\n` + + `Total undelivered media: ${report.undeliveredCount}\n`; + + const operationsString = report.operations + .map( + (operation: MediaSourceSyncOperationReport): string => + `${operation.operation} operation, Status: ${operation.status}, Total: ${operation.count}` + ) + .join('\n'); + + expectedMessage += operationsString; + + return { + loggable, + report, + expectedMessage, + }; + }; + + it('should return the correct log message', () => { + const { loggable, report, expectedMessage } = setup(); + + const logMessage = loggable.getLogMessage(); + + expect(logMessage).toEqual({ + message: expectedMessage, + data: { + report: JSON.stringify(report), + }, + }); + }); + }); +}); diff --git a/apps/server/src/infra/sync/media-metadata/loggable/media-metadata-sync-report.loggable.ts b/apps/server/src/infra/sync/media-metadata/loggable/media-metadata-sync-report.loggable.ts new file mode 100644 index 00000000000..53d0ff0ec84 --- /dev/null +++ b/apps/server/src/infra/sync/media-metadata/loggable/media-metadata-sync-report.loggable.ts @@ -0,0 +1,40 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@core/logger'; +import { MediaSourceSyncOperationReport, MediaSourceSyncReport } from '@modules/media-source/domain'; + +export class MediaMetadataSyncReportLoggable implements Loggable { + constructor(private readonly report: MediaSourceSyncReport) {} + + public getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + const message = `Media metadata sync had finished\n${this.formatCountOverview(this.report)}${this.formatOperations( + this.report.operations + )}`; + + return { + message, + data: { + report: JSON.stringify(this.report), + }, + }; + } + + private formatCountOverview(syncReport: MediaSourceSyncReport): string { + const formattedString = + `Total media processed: ${syncReport.totalCount}\n` + + `Total successful sync: ${syncReport.successCount}\n` + + `Total failed sync: ${syncReport.failedCount}\n` + + `Total undelivered media: ${syncReport.undeliveredCount}\n`; + + return formattedString; + } + + private formatOperations(operations: MediaSourceSyncOperationReport[]): string { + const formattedString = operations + .map( + (operation: MediaSourceSyncOperationReport): string => + `${operation.operation} operation, Status: ${operation.status}, Total: ${operation.count}` + ) + .join('\n'); + + return formattedString; + } +} diff --git a/apps/server/src/infra/sync/media-metadata/strategy/index.ts b/apps/server/src/infra/sync/media-metadata/strategy/index.ts new file mode 100644 index 00000000000..d4b744cd914 --- /dev/null +++ b/apps/server/src/infra/sync/media-metadata/strategy/index.ts @@ -0,0 +1 @@ +export { MediaMetadataSyncStrategy } from './media-metadata-sync.strategy'; diff --git a/apps/server/src/infra/sync/media-metadata/strategy/media-metadata-sync.strategy.spec.ts b/apps/server/src/infra/sync/media-metadata/strategy/media-metadata-sync.strategy.spec.ts new file mode 100644 index 00000000000..1092b5bace2 --- /dev/null +++ b/apps/server/src/infra/sync/media-metadata/strategy/media-metadata-sync.strategy.spec.ts @@ -0,0 +1,74 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Logger } from '@core/logger'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { MediaSourceSyncService } from '@modules/media-source/service'; +import { mediaSourceSyncReportFactory } from '@modules/media-source/testing'; +import { SyncStrategyTarget } from '../../sync-strategy.types'; +import { MediaMetadataSyncReportLoggable } from '../loggable'; +import { MediaMetadataSyncStrategy } from './media-metadata-sync.strategy'; + +describe(MediaMetadataSyncStrategy.name, () => { + let module: TestingModule; + let strategy: MediaMetadataSyncStrategy; + let syncService: DeepMocked; + let logger: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + MediaMetadataSyncStrategy, + { + provide: MediaSourceSyncService, + useValue: createMock(), + }, + { + provide: Logger, + useValue: createMock(), + }, + ], + }).compile(); + + strategy = module.get(MediaMetadataSyncStrategy); + syncService = module.get(MediaSourceSyncService); + logger = module.get(Logger); + }); + + describe('getType', () => { + describe('when the method is called', () => { + it('should return the correct sync target', () => { + const result = strategy.getType(); + + expect(result).toEqual(SyncStrategyTarget.MEDIA_METADATA); + }); + }); + }); + + describe('sync', () => { + describe('when the method is called', () => { + const setup = () => { + const report = mediaSourceSyncReportFactory.build(); + + syncService.syncAllMediaMetadata.mockResolvedValue(report); + + return { report }; + }; + + it('should start the sync for media metadata', async () => { + setup(); + + await strategy.sync(); + + expect(syncService.syncAllMediaMetadata).toBeCalled(); + }); + + it('should log the report after sync', async () => { + const { report } = setup(); + + await strategy.sync(); + + const loggable = new MediaMetadataSyncReportLoggable(report); + expect(logger.info).toBeCalledWith(loggable); + }); + }); + }); +}); diff --git a/apps/server/src/infra/sync/media-metadata/strategy/media-metadata-sync.strategy.ts b/apps/server/src/infra/sync/media-metadata/strategy/media-metadata-sync.strategy.ts new file mode 100644 index 00000000000..eff42fd15a5 --- /dev/null +++ b/apps/server/src/infra/sync/media-metadata/strategy/media-metadata-sync.strategy.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { Logger } from '@core/logger'; +import { MediaSourceSyncService } from '@modules/media-source/service'; +import { MediaSourceSyncReport } from '@modules/media-source/domain'; +import { SyncStrategy } from '../../strategy/sync-strategy'; +import { SyncStrategyTarget } from '../../sync-strategy.types'; +import { MediaMetadataSyncReportLoggable } from '../loggable'; + +@Injectable() +export class MediaMetadataSyncStrategy implements SyncStrategy { + constructor(private readonly mediaSourceSyncService: MediaSourceSyncService, private readonly logger: Logger) {} + + public getType(): SyncStrategyTarget { + return SyncStrategyTarget.MEDIA_METADATA; + } + + public async sync(): Promise { + const report: MediaSourceSyncReport = await this.mediaSourceSyncService.syncAllMediaMetadata(); + + this.logSyncReport(report); + } + + private logSyncReport(report: MediaSourceSyncReport): void { + const loggable = new MediaMetadataSyncReportLoggable(report); + + this.logger.info(loggable); + } +} diff --git a/apps/server/src/infra/sync/service/sync.service.ts b/apps/server/src/infra/sync/service/sync.service.ts index 44124cb37c5..dbfb34270c9 100644 --- a/apps/server/src/infra/sync/service/sync.service.ts +++ b/apps/server/src/infra/sync/service/sync.service.ts @@ -5,6 +5,7 @@ import { SyncStrategy } from '../strategy/sync-strategy'; import { SyncStrategyTarget } from '../sync-strategy.types'; import { InvalidTargetLoggable } from '../errors/invalid-target.loggable'; import { VidisSyncStrategy } from '../media-licenses/strategy'; +import { MediaMetadataSyncStrategy } from '../media-metadata/strategy'; @Injectable() export class SyncService { @@ -13,11 +14,12 @@ export class SyncService { constructor( private readonly logger: Logger, @Optional() private readonly tspSyncStrategy?: TspSyncStrategy, - @Optional() private readonly vidisSyncStrategy?: VidisSyncStrategy + @Optional() private readonly vidisSyncStrategy?: VidisSyncStrategy, + @Optional() private readonly mediaMetadataSyncStrategy?: MediaMetadataSyncStrategy ) { this.logger.setContext(SyncService.name); this.registerStrategy(tspSyncStrategy); - this.registerStrategy(vidisSyncStrategy); + this.registerStrategy(mediaMetadataSyncStrategy); } protected registerStrategy(strategy?: SyncStrategy) { diff --git a/apps/server/src/infra/sync/sync-strategy.types.ts b/apps/server/src/infra/sync/sync-strategy.types.ts index 0805f03730d..3f701750bd6 100644 --- a/apps/server/src/infra/sync/sync-strategy.types.ts +++ b/apps/server/src/infra/sync/sync-strategy.types.ts @@ -1,4 +1,5 @@ export enum SyncStrategyTarget { TSP = 'tsp', VIDIS = 'vidis', + MEDIA_METADATA = 'media-metadata', } diff --git a/apps/server/src/infra/sync/sync.module.ts b/apps/server/src/infra/sync/sync.module.ts index c65bb4201a5..20184bfe74b 100644 --- a/apps/server/src/infra/sync/sync.module.ts +++ b/apps/server/src/infra/sync/sync.module.ts @@ -16,6 +16,7 @@ import { UserModule } from '@modules/user'; import { Module } from '@nestjs/common'; import { SyncConsole } from './console/sync.console'; import { VidisFetchService, VidisSyncService, VidisSyncStrategy } from './media-licenses'; +import { MediaMetadataSyncStrategy } from './media-metadata'; import { SyncService } from './service/sync.service'; import { TspFetchService } from './tsp/tsp-fetch.service'; import { TspLegacyMigrationService } from './tsp/tsp-legacy-migration.service'; @@ -65,6 +66,7 @@ import { SyncUc } from './uc/sync.uc'; VidisSyncService, VidisSyncStrategy, VidisFetchService, + MediaMetadataSyncStrategy, ], exports: [SyncConsole], }) diff --git a/apps/server/src/modules/board/controller/api-test/board-layout-in-room.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-layout-in-room.api.spec.ts new file mode 100644 index 00000000000..8a2285beed2 --- /dev/null +++ b/apps/server/src/modules/board/controller/api-test/board-layout-in-room.api.spec.ts @@ -0,0 +1,164 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { accountFactory } from '@modules/account/testing'; +import { GroupEntityTypes } from '@modules/group/entity'; +import { roomMembershipEntityFactory } from '@modules/room-membership/testing'; +import { roomEntityFactory } from '@modules/room/testing'; +import { ServerTestModule } from '@modules/server/server.app.module'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Permission, RoleName } from '@shared/domain/interface'; +import { cleanupCollections } from '@testing/cleanup-collections'; +import { groupEntityFactory } from '@testing/factory/group-entity.factory'; +import { roleFactory } from '@testing/factory/role.factory'; +import { userFactory } from '@testing/factory/user.factory'; +import { TestApiClient } from '@testing/test-api-client'; +import { BoardExternalReferenceType, BoardLayout } from '../../domain'; +import { BoardNodeEntity } from '../../repo'; +import { columnBoardEntityFactory } from '../../testing'; + +const baseRouteName = '/boards'; + +describe(`board update layout with room relation (api)`, () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + await cleanupCollections(em); + }); + + const setup = async () => { + const userWithEditRole = userFactory.buildWithId(); + const accountWithEditRole = accountFactory.withUser(userWithEditRole).build(); + + const userWithViewRole = userFactory.buildWithId(); + const accountWithViewRole = accountFactory.withUser(userWithViewRole).build(); + + const noAccessUser = userFactory.buildWithId(); + const noAccessAccount = accountFactory.withUser(noAccessUser).build(); + + const roleRoomEdit = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleRoomView = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + + const userGroup = groupEntityFactory.buildWithId({ + type: GroupEntityTypes.ROOM, + users: [ + { user: userWithEditRole, role: roleRoomEdit }, + { user: userWithViewRole, role: roleRoomView }, + ], + }); + + const room = roomEntityFactory.buildWithId(); + + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + + await em.persistAndFlush([ + accountWithEditRole, + accountWithViewRole, + noAccessAccount, + userWithEditRole, + userWithViewRole, + noAccessUser, + roleRoomEdit, + roleRoomView, + userGroup, + room, + roomMembership, + ]); + + const columnBoardNode = columnBoardEntityFactory.build({ + layout: BoardLayout.COLUMNS, + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + + await em.persistAndFlush([columnBoardNode]); + em.clear(); + + return { accountWithEditRole, accountWithViewRole, noAccessAccount, columnBoardNode }; + }; + + describe('with user who has edit role in room', () => { + it('should return status 204', async () => { + const { accountWithEditRole, columnBoardNode } = await setup(); + const loggedInClient = await testApiClient.login(accountWithEditRole); + + const response = await loggedInClient.patch(`${columnBoardNode.id}/layout`, { layout: BoardLayout.LIST }); + + expect(response.status).toEqual(204); + }); + + it('should actually change the board layout', async () => { + const { accountWithEditRole, columnBoardNode } = await setup(); + const loggedInClient = await testApiClient.login(accountWithEditRole); + + await loggedInClient.patch(`${columnBoardNode.id}/layout`, { layout: BoardLayout.LIST }); + + const result = await em.findOneOrFail(BoardNodeEntity, columnBoardNode.id); + + expect(result.layout).toEqual(BoardLayout.LIST); + }); + }); + + describe('with user who has only view role in room', () => { + it('should return status 403', async () => { + const { accountWithViewRole, columnBoardNode } = await setup(); + const loggedInClient = await testApiClient.login(accountWithViewRole); + + const response = await loggedInClient.patch(`${columnBoardNode.id}/layout`, { layout: BoardLayout.LIST }); + + expect(response.status).toEqual(403); + }); + + it('should not change the board layout', async () => { + const { accountWithViewRole, columnBoardNode } = await setup(); + const loggedInClient = await testApiClient.login(accountWithViewRole); + + await loggedInClient.patch(`${columnBoardNode.id}/layout`, { layout: BoardLayout.LIST }); + + const result = await em.findOneOrFail(BoardNodeEntity, columnBoardNode.id); + expect(result.layout).toEqual(BoardLayout.COLUMNS); + }); + }); + + describe('with user who is not part of the room', () => { + it('should return status 403', async () => { + const { noAccessAccount, columnBoardNode } = await setup(); + const loggedInClient = await testApiClient.login(noAccessAccount); + + const response = await loggedInClient.patch(`${columnBoardNode.id}/layout`, { layout: BoardLayout.LIST }); + + expect(response.status).toEqual(403); + }); + + it('should not change the board layout', async () => { + const { noAccessAccount, columnBoardNode } = await setup(); + const loggedInClient = await testApiClient.login(noAccessAccount); + + await loggedInClient.patch(`${columnBoardNode.id}/layout`, { layout: BoardLayout.LIST }); + + const result = await em.findOneOrFail(BoardNodeEntity, columnBoardNode.id); + expect(result.layout).toEqual(BoardLayout.COLUMNS); + }); + }); +}); diff --git a/apps/server/src/modules/board/controller/board.controller.ts b/apps/server/src/modules/board/controller/board.controller.ts index 566bed25145..adaa025401e 100644 --- a/apps/server/src/modules/board/controller/board.controller.ts +++ b/apps/server/src/modules/board/controller/board.controller.ts @@ -21,6 +21,7 @@ import { ColumnResponse, CreateBoardBodyParams, CreateBoardResponse, + LayoutBodyParams, UpdateBoardTitleParams, VisibilityBodyParams, } from './dto'; @@ -157,4 +158,19 @@ export class BoardController { ) { await this.boardUc.updateVisibility(currentUser.userId, urlParams.boardId, bodyParams.isVisible); } + + @ApiOperation({ summary: 'Update the layout of a board.' }) + @ApiResponse({ status: 204 }) + @ApiResponse({ status: 400, type: ApiValidationError }) + @ApiResponse({ status: 403, type: ForbiddenException }) + @ApiResponse({ status: 404, type: NotFoundException }) + @HttpCode(204) + @Patch(':boardId/layout') + public async updateLayout( + @Param() urlParams: BoardUrlParams, + @Body() bodyParams: LayoutBodyParams, + @CurrentUser() currentUser: ICurrentUser + ): Promise { + await this.boardUc.updateLayout(currentUser.userId, urlParams.boardId, bodyParams.layout); + } } diff --git a/apps/server/src/modules/board/controller/dto/board/index.ts b/apps/server/src/modules/board/controller/dto/board/index.ts index ac8d1713624..068be43a672 100644 --- a/apps/server/src/modules/board/controller/dto/board/index.ts +++ b/apps/server/src/modules/board/controller/dto/board/index.ts @@ -11,3 +11,4 @@ export * from './move-column.body.params'; export * from './rename.body.params'; export * from './update-board-title.body.params'; export * from './visibility.body.params'; +export * from './layout.body.params'; diff --git a/apps/server/src/modules/board/controller/dto/board/layout.body.params.ts b/apps/server/src/modules/board/controller/dto/board/layout.body.params.ts new file mode 100644 index 00000000000..c2cf81b89a7 --- /dev/null +++ b/apps/server/src/modules/board/controller/dto/board/layout.body.params.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum } from 'class-validator'; +import { BoardLayout } from '../../../domain'; + +export class LayoutBodyParams { + @IsEnum(BoardLayout) + @ApiProperty({ enum: BoardLayout, enumName: 'BoardLayout' }) + public layout!: BoardLayout; +} diff --git a/apps/server/src/modules/board/controller/media-board/dto/layout.body.params.ts b/apps/server/src/modules/board/controller/media-board/dto/layout.body.params.ts index 2ead06c804d..c660f6fa4e1 100644 --- a/apps/server/src/modules/board/controller/media-board/dto/layout.body.params.ts +++ b/apps/server/src/modules/board/controller/media-board/dto/layout.body.params.ts @@ -5,6 +5,6 @@ import { BoardLayout } from '../../../domain/types'; export class LayoutBodyParams { @IsEnum(BoardLayout) @NotEquals(BoardLayout[BoardLayout.COLUMNS]) - @ApiProperty({ enum: BoardLayout, enumName: 'MediaBoardLayoutType' }) - layout!: BoardLayout; + @ApiProperty({ enum: BoardLayout, enumName: 'BoardLayout' }) + public layout!: BoardLayout; } diff --git a/apps/server/src/modules/board/controller/media-board/dto/media-board.response.ts b/apps/server/src/modules/board/controller/media-board/dto/media-board.response.ts index 4550bc45bf0..97b7a507859 100644 --- a/apps/server/src/modules/board/controller/media-board/dto/media-board.response.ts +++ b/apps/server/src/modules/board/controller/media-board/dto/media-board.response.ts @@ -18,7 +18,7 @@ export class MediaBoardResponse { @ApiProperty({ enum: BoardLayout, - enumName: 'MediaBoardLayoutType', + enumName: 'BoardLayout', description: 'Layout of media board', }) layout: BoardLayout; diff --git a/apps/server/src/modules/board/domain/colum-board.do.ts b/apps/server/src/modules/board/domain/colum-board.do.ts index 5b74b1adc61..c6aaceceb95 100644 --- a/apps/server/src/modules/board/domain/colum-board.do.ts +++ b/apps/server/src/modules/board/domain/colum-board.do.ts @@ -31,6 +31,10 @@ export class ColumnBoard extends BoardNode { return this.props.layout; } + set layout(layout: BoardLayout) { + this.props.layout = layout; + } + canHaveChild(childNode: AnyBoardNode): boolean { const allowed = childNode instanceof Column; return allowed; diff --git a/apps/server/src/modules/board/gateway/api-test/board-collaboration.gateway.spec.ts b/apps/server/src/modules/board/gateway/api-test/board-collaboration.gateway.spec.ts index 537a692dd96..9966aebac2c 100644 --- a/apps/server/src/modules/board/gateway/api-test/board-collaboration.gateway.spec.ts +++ b/apps/server/src/modules/board/gateway/api-test/board-collaboration.gateway.spec.ts @@ -1,8 +1,7 @@ +import { MongoIoAdapter } from '@infra/socketio'; import { EntityManager } from '@mikro-orm/mongodb'; import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; - -import { MongoIoAdapter } from '@infra/socketio'; import { InputFormat } from '@shared/domain/types/input-format.types'; import { cleanupCollections } from '@testing/cleanup-collections'; import { courseFactory } from '@testing/factory/course.factory'; @@ -12,7 +11,7 @@ import { UserAndAccountTestFactory } from '@testing/factory/user-and-account.tes import { getSocketApiClient, waitForEvent } from '@testing/test-socket-api-client'; import { Socket } from 'socket.io-client'; import { BoardCollaborationTestModule } from '../../board-collaboration.app.module'; -import { BoardExternalReferenceType, CardProps, ContentElementType } from '../../domain'; +import { BoardExternalReferenceType, BoardLayout, CardProps, ContentElementType } from '../../domain'; import { cardEntityFactory, columnBoardEntityFactory, @@ -360,6 +359,32 @@ describe(BoardCollaborationGateway.name, () => { }); }); + describe('update board layout', () => { + describe('when board exists', () => { + it('should answer with success', async () => { + const { columnBoardNode } = await setup(); + const boardId = columnBoardNode.id; + + ioClient.emit('update-board-layout-request', { boardId, layout: BoardLayout.LIST }); + const success = await waitForEvent(ioClient, 'update-board-layout-success'); + + expect(success).toEqual(expect.objectContaining({ boardId, layout: BoardLayout.LIST })); + }); + }); + + describe('when user is not authorized', () => { + it('should answer with failure', async () => { + const { columnBoardNode } = await setup(); + const boardId = columnBoardNode.id; + + unauthorizedIoClient.emit('update-board-layout-request', { boardId, layout: BoardLayout.LIST }); + const failure = await waitForEvent(unauthorizedIoClient, 'update-board-layout-failure'); + + expect(failure).toEqual({ boardId, layout: BoardLayout.LIST }); + }); + }); + }); + describe('delete column', () => { describe('when column exists', () => { it('should answer with success', async () => { diff --git a/apps/server/src/modules/board/gateway/board-collaboration.gateway.ts b/apps/server/src/modules/board/gateway/board-collaboration.gateway.ts index 24a6c3d9aaa..163c4ea10f0 100644 --- a/apps/server/src/modules/board/gateway/board-collaboration.gateway.ts +++ b/apps/server/src/modules/board/gateway/board-collaboration.gateway.ts @@ -17,29 +17,32 @@ import { ColumnResponseMapper, ContentElementResponseFactory, } from '../controller/mapper'; -import { AnyBoardNode } from '../domain'; +import { AnyBoardNode, ColumnBoard } from '../domain'; import { MetricsService } from '../metrics/metrics.service'; import { TrackExecutionTime } from '../metrics/track-execution-time.decorator'; import { BoardUc, CardUc, ColumnUc, ElementUc } from '../uc'; +import { + CreateCardMessageParams, + CreateColumnMessageParams, + CreateContentElementMessageParams, + DeleteBoardMessageParams, + DeleteCardMessageParams, + DeleteColumnMessageParams, + DeleteContentElementMessageParams, + FetchBoardMessageParams, + FetchCardsMessageParams, + MoveCardMessageParams, + MoveColumnMessageParams, + MoveContentElementMessageParams, + UpdateBoardLayoutMessageParams, + UpdateBoardTitleMessageParams, + UpdateBoardVisibilityMessageParams, + UpdateCardHeightMessageParams, + UpdateCardTitleMessageParams, + UpdateColumnTitleMessageParams, + UpdateContentElementMessageParams, +} from './dto'; import BoardCollaborationConfiguration from './dto/board-collaboration-config'; -import { CreateCardMessageParams } from './dto/create-card.message.param'; -import { CreateColumnMessageParams } from './dto/create-column.message.param'; -import { CreateContentElementMessageParams } from './dto/create-content-element.message.param'; -import { DeleteBoardMessageParams } from './dto/delete-board.message.param'; -import { DeleteCardMessageParams } from './dto/delete-card.message.param'; -import { DeleteColumnMessageParams } from './dto/delete-column.message.param'; -import { DeleteContentElementMessageParams } from './dto/delete-content-element.message.param'; -import { FetchBoardMessageParams } from './dto/fetch-board.message.param'; -import { FetchCardsMessageParams } from './dto/fetch-cards.message.param'; -import { MoveCardMessageParams } from './dto/move-card.message.param'; -import { MoveColumnMessageParams } from './dto/move-column.message.param'; -import { MoveContentElementMessageParams } from './dto/move-content-element.message.param'; -import { UpdateBoardTitleMessageParams } from './dto/update-board-title.message.param'; -import { UpdateBoardVisibilityMessageParams } from './dto/update-board-visibility.message.param'; -import { UpdateCardHeightMessageParams } from './dto/update-card-height.message.param'; -import { UpdateCardTitleMessageParams } from './dto/update-card-title.message.param'; -import { UpdateColumnTitleMessageParams } from './dto/update-column-title.message.param'; -import { UpdateContentElementMessageParams } from './dto/update-content-element.message.param'; @UsePipes(new WsValidationPipe()) @WebSocketGateway(BoardCollaborationConfiguration.websocket) @@ -291,6 +294,21 @@ export class BoardCollaborationGateway implements OnGatewayDisconnect { await this.updateRoomsAndUsersMetrics(socket); } + @SubscribeMessage('update-board-layout-request') + @TrackExecutionTime() + @UseRequestContext() + public async updateBoardLayout(socket: Socket, data: UpdateBoardLayoutMessageParams): Promise { + const emitter = this.buildBoardSocketEmitter({ socket, action: 'update-board-layout' }); + const { userId } = this.getCurrentUser(socket); + try { + const board: ColumnBoard = await this.boardUc.updateLayout(userId, data.boardId, data.layout); + emitter.emitToClientAndRoom(data, board); + } catch (err) { + emitter.emitFailure(data); + } + await this.updateRoomsAndUsersMetrics(socket); + } + @SubscribeMessage('delete-column-request') @TrackExecutionTime() @UseRequestContext() diff --git a/apps/server/src/modules/board/gateway/dto/index.ts b/apps/server/src/modules/board/gateway/dto/index.ts index 35d8c364514..62ac22945f1 100644 --- a/apps/server/src/modules/board/gateway/dto/index.ts +++ b/apps/server/src/modules/board/gateway/dto/index.ts @@ -1,39 +1,19 @@ -import { CreateCardMessageParams } from './create-card.message.param'; -import { CreateColumnMessageParams } from './create-column.message.param'; -import { CreateContentElementMessageParams } from './create-content-element.message.param'; -import { DeleteBoardMessageParams } from './delete-board.message.param'; -import { DeleteCardMessageParams } from './delete-card.message.param'; -import { DeleteContentElementMessageParams } from './delete-content-element.message.param'; -import { DeleteColumnMessageParams } from './delete-column.message.param'; -import { FetchBoardMessageParams } from './fetch-board.message.param'; -import { FetchCardsMessageParams } from './fetch-cards.message.param'; -import { MoveCardMessageParams } from './move-card.message.param'; -import { MoveColumnMessageParams } from './move-column.message.param'; -import { MoveContentElementMessageParams } from './move-content-element.message.param'; -import { UpdateBoardTitleMessageParams } from './update-board-title.message.param'; -import { UpdateBoardVisibilityMessageParams } from './update-board-visibility.message.param'; -import { UpdateCardHeightMessageParams } from './update-card-height.message.param'; -import { UpdateCardTitleMessageParams } from './update-card-title.message.param'; -import { UpdateColumnTitleMessageParams } from './update-column-title.message.param'; -import { UpdateContentElementMessageParams } from './update-content-element.message.param'; - -export { - CreateCardMessageParams, - CreateColumnMessageParams, - CreateContentElementMessageParams, - DeleteBoardMessageParams, - DeleteCardMessageParams, - DeleteColumnMessageParams, - DeleteContentElementMessageParams, - FetchBoardMessageParams, - FetchCardsMessageParams, - MoveCardMessageParams, - MoveColumnMessageParams, - MoveContentElementMessageParams, - UpdateBoardTitleMessageParams, - UpdateBoardVisibilityMessageParams, - UpdateCardHeightMessageParams, - UpdateCardTitleMessageParams, - UpdateColumnTitleMessageParams, - UpdateContentElementMessageParams, -}; +export { CreateCardMessageParams } from './create-card.message.param'; +export { CreateColumnMessageParams } from './create-column.message.param'; +export { CreateContentElementMessageParams } from './create-content-element.message.param'; +export { DeleteBoardMessageParams } from './delete-board.message.param'; +export { DeleteCardMessageParams } from './delete-card.message.param'; +export { DeleteColumnMessageParams } from './delete-column.message.param'; +export { DeleteContentElementMessageParams } from './delete-content-element.message.param'; +export { FetchBoardMessageParams } from './fetch-board.message.param'; +export { FetchCardsMessageParams } from './fetch-cards.message.param'; +export { MoveCardMessageParams } from './move-card.message.param'; +export { MoveColumnMessageParams } from './move-column.message.param'; +export { MoveContentElementMessageParams } from './move-content-element.message.param'; +export { UpdateBoardTitleMessageParams } from './update-board-title.message.param'; +export { UpdateBoardVisibilityMessageParams } from './update-board-visibility.message.param'; +export { UpdateCardHeightMessageParams } from './update-card-height.message.param'; +export { UpdateCardTitleMessageParams } from './update-card-title.message.param'; +export { UpdateColumnTitleMessageParams } from './update-column-title.message.param'; +export { UpdateContentElementMessageParams } from './update-content-element.message.param'; +export { UpdateBoardLayoutMessageParams } from './update-board-layout.message.param'; diff --git a/apps/server/src/modules/board/gateway/dto/update-board-layout.message.param.ts b/apps/server/src/modules/board/gateway/dto/update-board-layout.message.param.ts new file mode 100644 index 00000000000..d1283f0e687 --- /dev/null +++ b/apps/server/src/modules/board/gateway/dto/update-board-layout.message.param.ts @@ -0,0 +1,10 @@ +import { IsEnum, IsMongoId } from 'class-validator'; +import { BoardLayout } from '../../domain'; + +export class UpdateBoardLayoutMessageParams { + @IsMongoId() + public boardId!: string; + + @IsEnum(BoardLayout) + public layout!: BoardLayout; +} diff --git a/apps/server/src/modules/board/service/board-node.service.spec.ts b/apps/server/src/modules/board/service/board-node.service.spec.ts index a586446bf56..6c073c6cb44 100644 --- a/apps/server/src/modules/board/service/board-node.service.spec.ts +++ b/apps/server/src/modules/board/service/board-node.service.spec.ts @@ -3,7 +3,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { setupEntities } from '@testing/setup-entities'; -import { Card, ColumnBoard } from '../domain'; +import { BoardLayout, Card, ColumnBoard } from '../domain'; import { BoardNodeRepo } from '../repo'; import { cardFactory, @@ -284,4 +284,32 @@ describe(BoardNodeService.name, () => { }); }); }); + + describe('updateLayout', () => { + describe('when updating the layout', () => { + const setup = () => { + const node = columnBoardFactory.build({ + layout: BoardLayout.COLUMNS, + }); + + const expected = new ColumnBoard({ + ...node.getProps(), + layout: BoardLayout.LIST, + }); + + return { + node, + expected, + }; + }; + + it('should save the board with the updated layout', async () => { + const { node, expected } = setup(); + + await service.updateLayout(node, BoardLayout.LIST); + + expect(boardNodeRepo.save).toHaveBeenCalledWith(expected); + }); + }); + }); }); diff --git a/apps/server/src/modules/board/service/board-node.service.ts b/apps/server/src/modules/board/service/board-node.service.ts index d8ae4dccee1..652f0a1163a 100644 --- a/apps/server/src/modules/board/service/board-node.service.ts +++ b/apps/server/src/modules/board/service/board-node.service.ts @@ -16,6 +16,7 @@ import { ContentElementUpdateService } from './internal/content-element-update.s type WithTitle = Extract; type WithVisibility = Extract; +type WithLayout = Extract; type WithHeight = Extract; type WithCompleted = Extract; @@ -46,6 +47,11 @@ export class BoardNodeService { await this.boardNodeRepo.save(node); } + public async updateLayout>(node: T, layout: T['layout']): Promise { + node.layout = layout; + await this.boardNodeRepo.save(node); + } + async updateHeight>(node: T, height: T['height']) { node.height = height; await this.boardNodeRepo.save(node); diff --git a/apps/server/src/modules/board/uc/board.uc.spec.ts b/apps/server/src/modules/board/uc/board.uc.spec.ts index 275981917d0..3b5d9e1fefd 100644 --- a/apps/server/src/modules/board/uc/board.uc.spec.ts +++ b/apps/server/src/modules/board/uc/board.uc.spec.ts @@ -560,4 +560,40 @@ describe(BoardUc.name, () => { expect(boardNodeService.updateVisibility).toHaveBeenCalledWith(board.id, true); }); }); + + describe('updateLayout', () => { + describe('when updating the layout', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const board = columnBoardFactory.build(); + + return { user, board }; + }; + + it('should call the service to find the board', async () => { + const { user, board } = setup(); + + await uc.updateLayout(user.id, board.id, BoardLayout.LIST); + + expect(boardNodeService.findByClassAndId).toHaveBeenCalledWith(ColumnBoard, board.id); + }); + + it('should call the service to check the permissions', async () => { + const { user, board } = setup(); + boardNodeService.findByClassAndId.mockResolvedValueOnce(board); + + await uc.updateLayout(user.id, board.id, BoardLayout.LIST); + + expect(boardPermissionService.checkPermission).toHaveBeenCalledWith(user.id, board, Action.write); + }); + + it('should call the service to update the board layout', async () => { + const { user, board } = setup(); + + await uc.updateLayout(user.id, board.id, BoardLayout.LIST); + + expect(boardNodeService.updateLayout).toHaveBeenCalledWith(board.id, BoardLayout.LIST); + }); + }); + }); }); diff --git a/apps/server/src/modules/board/uc/board.uc.ts b/apps/server/src/modules/board/uc/board.uc.ts index a0b8d0744fb..e02c3f85407 100644 --- a/apps/server/src/modules/board/uc/board.uc.ts +++ b/apps/server/src/modules/board/uc/board.uc.ts @@ -14,6 +14,7 @@ import { BoardExternalReference, BoardExternalReferenceType, BoardFeature, + BoardLayout, BoardNodeFactory, Column, ColumnBoard, @@ -159,6 +160,14 @@ export class BoardUc { return board; } + public async updateLayout(userId: EntityId, boardId: EntityId, layout: BoardLayout): Promise { + const board: ColumnBoard = await this.boardNodeService.findByClassAndId(ColumnBoard, boardId); + await this.boardPermissionService.checkPermission(userId, board, Action.write); + + await this.boardNodeService.updateLayout(board, layout); + return board; + } + // ---- Move to shared service? (see apps/server/src/modules/sharing/uc/share-token.uc.ts) private async checkReferenceWritePermission(userId: EntityId, context: BoardExternalReference): Promise { diff --git a/apps/server/src/modules/media-source/domain/do/index.ts b/apps/server/src/modules/media-source/domain/do/index.ts new file mode 100644 index 00000000000..d007004c6d7 --- /dev/null +++ b/apps/server/src/modules/media-source/domain/do/index.ts @@ -0,0 +1,3 @@ +export { MediaSource, MediaSourceProps } from './media-source'; +export { MediaSourceBasicAuthConfig } from './media-source-basic-auth-config'; +export { MediaSourceOauthConfig } from './media-source-oauth-config'; diff --git a/apps/server/src/modules/media-source/domain/media-source-basic-auth-config.ts b/apps/server/src/modules/media-source/domain/do/media-source-basic-auth-config.ts similarity index 100% rename from apps/server/src/modules/media-source/domain/media-source-basic-auth-config.ts rename to apps/server/src/modules/media-source/domain/do/media-source-basic-auth-config.ts diff --git a/apps/server/src/modules/media-source/domain/media-source-oauth-config.ts b/apps/server/src/modules/media-source/domain/do/media-source-oauth-config.ts similarity index 87% rename from apps/server/src/modules/media-source/domain/media-source-oauth-config.ts rename to apps/server/src/modules/media-source/domain/do/media-source-oauth-config.ts index 38fccb84b31..f9668783f91 100644 --- a/apps/server/src/modules/media-source/domain/media-source-oauth-config.ts +++ b/apps/server/src/modules/media-source/domain/do/media-source-oauth-config.ts @@ -1,4 +1,4 @@ -import { MediaSourceAuthMethod } from '../enum'; +import { MediaSourceAuthMethod } from '../../enum'; export class MediaSourceOauthConfig { public clientId: string; diff --git a/apps/server/src/modules/media-source/domain/media-source.ts b/apps/server/src/modules/media-source/domain/do/media-source.ts similarity index 94% rename from apps/server/src/modules/media-source/domain/media-source.ts rename to apps/server/src/modules/media-source/domain/do/media-source.ts index 6624b4c56ba..e793a2018ca 100644 --- a/apps/server/src/modules/media-source/domain/media-source.ts +++ b/apps/server/src/modules/media-source/domain/do/media-source.ts @@ -1,7 +1,7 @@ import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; import { MediaSourceBasicAuthConfig } from './media-source-basic-auth-config'; import { MediaSourceOauthConfig } from './media-source-oauth-config'; -import { MediaSourceDataFormat } from '../enum'; +import { MediaSourceDataFormat } from '../../enum'; export interface MediaSourceProps extends AuthorizableObject { id: string; diff --git a/apps/server/src/modules/media-source/domain/index.ts b/apps/server/src/modules/media-source/domain/index.ts index d007004c6d7..1b57e07b2ca 100644 --- a/apps/server/src/modules/media-source/domain/index.ts +++ b/apps/server/src/modules/media-source/domain/index.ts @@ -1,3 +1,2 @@ -export { MediaSource, MediaSourceProps } from './media-source'; -export { MediaSourceBasicAuthConfig } from './media-source-basic-auth-config'; -export { MediaSourceOauthConfig } from './media-source-oauth-config'; +export * from './do'; +export * from './interface'; diff --git a/apps/server/src/modules/media-source/domain/interface/index.ts b/apps/server/src/modules/media-source/domain/interface/index.ts new file mode 100644 index 00000000000..019d4edf2ac --- /dev/null +++ b/apps/server/src/modules/media-source/domain/interface/index.ts @@ -0,0 +1,2 @@ +export { MediaSourceSyncReport } from './media-source-sync-report'; +export { MediaSourceSyncOperationReport } from './media-source-sync-operation-report'; diff --git a/apps/server/src/modules/media-source/domain/interface/media-source-sync-operation-report.ts b/apps/server/src/modules/media-source/domain/interface/media-source-sync-operation-report.ts new file mode 100644 index 00000000000..90f32bfc163 --- /dev/null +++ b/apps/server/src/modules/media-source/domain/interface/media-source-sync-operation-report.ts @@ -0,0 +1,7 @@ +import { MediaSourceSyncOperation, MediaSourceSyncStatus } from '../../enum'; + +export interface MediaSourceSyncOperationReport { + operation: MediaSourceSyncOperation; + status: MediaSourceSyncStatus; + count: number; +} diff --git a/apps/server/src/modules/media-source/domain/interface/media-source-sync-report.ts b/apps/server/src/modules/media-source/domain/interface/media-source-sync-report.ts new file mode 100644 index 00000000000..252a6f2d542 --- /dev/null +++ b/apps/server/src/modules/media-source/domain/interface/media-source-sync-report.ts @@ -0,0 +1,9 @@ +import { MediaSourceSyncOperationReport } from './media-source-sync-operation-report'; + +export interface MediaSourceSyncReport { + totalCount: number; + successCount: number; + failedCount: number; + undeliveredCount: number; + operations: MediaSourceSyncOperationReport[]; +} diff --git a/apps/server/src/modules/media-source/enum/index.ts b/apps/server/src/modules/media-source/enum/index.ts index 7543244471a..7081bdfa253 100644 --- a/apps/server/src/modules/media-source/enum/index.ts +++ b/apps/server/src/modules/media-source/enum/index.ts @@ -1,2 +1,4 @@ export { MediaSourceDataFormat } from './media-source-data-format.enum'; export { MediaSourceAuthMethod } from './media-source-auth-method.enum'; +export { MediaSourceSyncOperation } from './media-source-sync-operation.enum'; +export { MediaSourceSyncStatus } from './media-source-sync-status.enum'; diff --git a/apps/server/src/modules/media-source/enum/media-source-sync-operation.enum.ts b/apps/server/src/modules/media-source/enum/media-source-sync-operation.enum.ts new file mode 100644 index 00000000000..d302ca147f4 --- /dev/null +++ b/apps/server/src/modules/media-source/enum/media-source-sync-operation.enum.ts @@ -0,0 +1,5 @@ +export enum MediaSourceSyncOperation { + ANY = 'any', + UPDATE = 'update', + CREATE = 'create', +} diff --git a/apps/server/src/modules/media-source/enum/media-source-sync-status.enum.ts b/apps/server/src/modules/media-source/enum/media-source-sync-status.enum.ts new file mode 100644 index 00000000000..6ff1dc27b70 --- /dev/null +++ b/apps/server/src/modules/media-source/enum/media-source-sync-status.enum.ts @@ -0,0 +1,5 @@ +export enum MediaSourceSyncStatus { + SUCCESS = 'success', + FAILED = 'failed', + UNDELIVERED = 'undelivered', +} diff --git a/apps/server/src/modules/media-source/media-source.module.ts b/apps/server/src/modules/media-source/media-source.module.ts index 2899e3cb458..c530aa69acb 100644 --- a/apps/server/src/modules/media-source/media-source.module.ts +++ b/apps/server/src/modules/media-source/media-source.module.ts @@ -1,9 +1,9 @@ import { Module } from '@nestjs/common'; import { MediaSourceRepo } from './repo'; -import { MediaSourceService } from './service'; +import { MediaSourceService, MediaSourceSyncService } from './service'; @Module({ - providers: [MediaSourceService, MediaSourceRepo], - exports: [MediaSourceService, MediaSourceRepo], + providers: [MediaSourceService, MediaSourceSyncService, MediaSourceRepo], + exports: [MediaSourceService, MediaSourceSyncService, MediaSourceRepo], }) export class MediaSourceModule {} diff --git a/apps/server/src/modules/media-source/service/index.ts b/apps/server/src/modules/media-source/service/index.ts index 95cc754abea..84e254d2035 100644 --- a/apps/server/src/modules/media-source/service/index.ts +++ b/apps/server/src/modules/media-source/service/index.ts @@ -1 +1,2 @@ export { MediaSourceService } from './media-source.service'; +export { MediaSourceSyncService } from './media-source-sync.service'; diff --git a/apps/server/src/modules/media-source/service/media-source-sync.service.spec.ts b/apps/server/src/modules/media-source/service/media-source-sync.service.spec.ts new file mode 100644 index 00000000000..4ee0af5ddec --- /dev/null +++ b/apps/server/src/modules/media-source/service/media-source-sync.service.spec.ts @@ -0,0 +1,28 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { MediaSourceSyncService } from './media-source-sync.service'; + +describe(MediaSourceSyncService.name, () => { + let service: MediaSourceSyncService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [MediaSourceSyncService], + }).compile(); + + service = module.get(MediaSourceSyncService); + }); + + describe('syncAllMediaMetadata', () => { + it('should return a sync report', async () => { + const result = await service.syncAllMediaMetadata(); + + expect(result).toMatchObject({ + totalCount: 0, + successCount: 0, + failedCount: 0, + undeliveredCount: 0, + operations: [], + }); + }); + }); +}); diff --git a/apps/server/src/modules/media-source/service/media-source-sync.service.ts b/apps/server/src/modules/media-source/service/media-source-sync.service.ts new file mode 100644 index 00000000000..7d326690f64 --- /dev/null +++ b/apps/server/src/modules/media-source/service/media-source-sync.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { MediaSourceSyncReport } from '../domain'; + +@Injectable() +export class MediaSourceSyncService { + public async syncAllMediaMetadata(): Promise { + const dummyReport: MediaSourceSyncReport = { + totalCount: 0, + successCount: 0, + failedCount: 0, + undeliveredCount: 0, + operations: [], + }; + + await Promise.resolve(); + + return dummyReport; + } +} diff --git a/apps/server/src/modules/media-source/testing/index.ts b/apps/server/src/modules/media-source/testing/index.ts index 9690d284c7d..399cd71c801 100644 --- a/apps/server/src/modules/media-source/testing/index.ts +++ b/apps/server/src/modules/media-source/testing/index.ts @@ -4,3 +4,5 @@ export { mediaSourceBasicAuthConfigFactory } from './media-source-basic-auth-con export { mediaSourceOauthConfigFactory } from './media-source-oauth-config.factory'; export { mediaSourceBasicConfigEmbeddableFactory } from './media-source-basic-auth-config.embeddable.factory'; export { mediaSourceOAuthConfigEmbeddableFactory } from './media-source-oauth-config.embeddable.factory'; +export { mediaSourceSyncReportFactory } from './media-source-sync-report.factory'; +export { mediaSourceSyncOperationReportFactory } from './media-source-sync-operation-report.factory'; diff --git a/apps/server/src/modules/media-source/testing/media-source-sync-operation-report.factory.ts b/apps/server/src/modules/media-source/testing/media-source-sync-operation-report.factory.ts new file mode 100644 index 00000000000..6240b895660 --- /dev/null +++ b/apps/server/src/modules/media-source/testing/media-source-sync-operation-report.factory.ts @@ -0,0 +1,13 @@ +import { Factory } from 'fishery'; +import { MediaSourceSyncOperationReport } from '../domain'; +import { MediaSourceSyncOperation, MediaSourceSyncStatus } from '../enum'; + +export const mediaSourceSyncOperationReportFactory = Factory.define(() => { + const syncOperationReportProps: MediaSourceSyncOperationReport = { + status: MediaSourceSyncStatus.SUCCESS, + operation: MediaSourceSyncOperation.CREATE, + count: 10, + }; + + return syncOperationReportProps; +}); diff --git a/apps/server/src/modules/media-source/testing/media-source-sync-report.factory.ts b/apps/server/src/modules/media-source/testing/media-source-sync-report.factory.ts new file mode 100644 index 00000000000..6f1ce187147 --- /dev/null +++ b/apps/server/src/modules/media-source/testing/media-source-sync-report.factory.ts @@ -0,0 +1,15 @@ +import { Factory } from 'fishery'; +import { MediaSourceSyncReport } from '../domain'; +import { mediaSourceSyncOperationReportFactory } from './media-source-sync-operation-report.factory'; + +export const mediaSourceSyncReportFactory = Factory.define(() => { + const syncReportProps: MediaSourceSyncReport = { + totalCount: 10, + successCount: 10, + failedCount: 0, + undeliveredCount: 0, + operations: mediaSourceSyncOperationReportFactory.buildList(1), + }; + + return syncReportProps; +}); diff --git a/package-lock.json b/package-lock.json index b1566114bcd..f8e1b054cdb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23818,9 +23818,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.0.tgz", - "integrity": "sha512-BUgJXc752Kou3oOIuU1i+yZZypyZRqNPW0vqoMPl8VaoalSfeR0D8/t4iAS3yirs79SSMTxTag+ZC86uswv+Cw==", + "version": "6.21.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz", + "integrity": "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==", "license": "MIT", "engines": { "node": ">=18.17" diff --git a/package.json b/package.json index e1811b316e7..6fb5084354a 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,9 @@ "nest:start:sync:vidis": "npm run nest:start:console -- sync run vidis", "nest:start:sync:vidis:dev": "npm run nest:start:console --watch -- sync run vidis", "nest:start:sync:vidis:debug": "npm run nest:start:console --watch --debug -- sync run vidis", + "nest:start:sync:media-metadata": "npm run nest:start:console -- sync run media-metadata", + "nest:start:sync:media-metadata:dev": "npm run nest:start:console --watch -- sync run media-metadata", + "nest:start:sync:media-metadata:debug": "npm run nest:start:console --watch --debug -- sync run media-metadata", "nest:test": "npm run nest:test:cov && npm run nest:lint", "nest:test:all": "jest \"^((?!(\\.load)\\.spec\\.ts).)*\"", "nest:test:unit": "jest \"^((?!(\\.api|\\.load)\\.spec\\.ts).)*\\.spec\\.ts$\"",