diff --git a/ansible/roles/common-cartridge/tasks/main.yml b/ansible/roles/common-cartridge/tasks/main.yml index a4d6e99575e..6771d9f73f8 100644 --- a/ansible/roles/common-cartridge/tasks/main.yml +++ b/ansible/roles/common-cartridge/tasks/main.yml @@ -52,11 +52,11 @@ - service # This is a testing route and will not be deployed -# - name: Ingress -# kubernetes.core.k8s: -# kubeconfig: ~/.kube/config -# namespace: "{{ NAMESPACE }}" -# template: ingress.yml.j2 -# when: WITH_COMMON_CARTRIDGE is defined and WITH_COMMON_CARTRIDGE|bool -# tags: -# - ingress +- name: Ingress + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: ingress.yml.j2 + when: WITH_COMMON_CARTRIDGE is defined and WITH_COMMON_CARTRIDGE|bool + tags: + - ingress diff --git a/ansible/roles/schulcloud-server-core/tasks/main.yml b/ansible/roles/schulcloud-server-core/tasks/main.yml index 5f8a82c76af..d6d723cafa3 100644 --- a/ansible/roles/schulcloud-server-core/tasks/main.yml +++ b/ansible/roles/schulcloud-server-core/tasks/main.yml @@ -222,15 +222,6 @@ tags: - cronjob - - name: Delete Tldraw Files CronJob - kubernetes.core.k8s: - kubeconfig: ~/.kube/config - namespace: "{{ NAMESPACE }}" - template: tldraw-delete-files-cronjob.yml.j2 - state: "{{ 'present' if WITH_TLDRAW else 'absent'}}" - tags: - - cronjob - - name: Data deletion trigger CronJob kubernetes.core.k8s: kubeconfig: ~/.kube/config @@ -349,66 +340,6 @@ tags: - prometheus - - name: External Secret for TlDraw Server - kubernetes.core.k8s: - kubeconfig: ~/.kube/config - namespace: "{{ NAMESPACE }}" - template: tldraw-server-external-secret.yml.j2 - state: "{{ 'present' if WITH_BRANCH_MONGO_DB_MANAGEMENT is defined and WITH_BRANCH_MONGO_DB_MANAGEMENT|bool else 'absent'}}" - when: - - EXTERNAL_SECRETS_OPERATOR is defined and EXTERNAL_SECRETS_OPERATOR|bool - - WITH_TLDRAW is defined and WITH_TLDRAW|bool - tags: - - 1password - - - name: TlDraw server Secret (from 1Password) - kubernetes.core.k8s: - kubeconfig: ~/.kube/config - namespace: "{{ NAMESPACE }}" - template: tldraw-server-onepassword.yml.j2 - when: - - ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool - - WITH_TLDRAW is defined and WITH_TLDRAW|bool - tags: - - 1password - - - name: TlDraw server deployment - kubernetes.core.k8s: - kubeconfig: ~/.kube/config - namespace: "{{ NAMESPACE }}" - template: tldraw-deployment.yml.j2 - state: "{{ 'present' if WITH_TLDRAW else 'absent'}}" - tags: - - deployment - - - name: TlDraw server service - kubernetes.core.k8s: - kubeconfig: ~/.kube/config - namespace: "{{ NAMESPACE }}" - template: tldraw-server-svc.yml.j2 - when: WITH_TLDRAW is defined and WITH_TLDRAW|bool - tags: - - service - - - name: Tldraw ingress - kubernetes.core.k8s: - kubeconfig: ~/.kube/config - namespace: "{{ NAMESPACE }}" - template: tldraw-ingress.yml.j2 - apply: yes - when: WITH_TLDRAW is defined and WITH_TLDRAW|bool - tags: - - ingress - - - name: TldrawServiceMonitor - kubernetes.core.k8s: - kubeconfig: ~/.kube/config - namespace: "{{ NAMESPACE }}" - template: tldraw-svc-monitor.yml.j2 - state: "{{ 'present' if WITH_TLDRAW else 'absent'}}" - tags: - - prometheus - - name: BoardCollaboration configmap kubernetes.core.k8s: kubeconfig: ~/.kube/config diff --git a/ansible/roles/schulcloud-server-core/templates/external-secret.yml.j2 b/ansible/roles/schulcloud-server-core/templates/external-secret.yml.j2 index adcbfefdc2b..2d58c7b62b2 100644 --- a/ansible/roles/schulcloud-server-core/templates/external-secret.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/external-secret.yml.j2 @@ -18,7 +18,6 @@ spec: data: DB_URL: "{{ '{{ .MONGO_MANAGEMENT_TEMPLATE_URL }}/' ~ MONGO_MANAGEMENT_PREFIX ~ 'scapp' ~ MONGO_MANAGEMENT_POSTFIX }}" DATABASE__URL: "{{ '{{ .MONGO_MANAGEMENT_TEMPLATE_URL }}/' ~ MONGO_MANAGEMENT_PREFIX ~ 'scapp' ~ MONGO_MANAGEMENT_POSTFIX }}" - TLDRAW_DB_URL: "{{ '{{ .MONGO_MANAGEMENT_TEMPLATE_URL }}/' ~ MONGO_MANAGEMENT_PREFIX ~ 'tldraw' ~ MONGO_MANAGEMENT_POSTFIX }}" dataFrom: - extract: key: api-secret{{ EXTERNAL_SECRETS_POSTFIX }} diff --git a/ansible/roles/schulcloud-server-core/templates/tldraw-delete-files-cronjob.yml.j2 b/ansible/roles/schulcloud-server-core/templates/tldraw-delete-files-cronjob.yml.j2 deleted file mode 100644 index c64ba6b6d58..00000000000 --- a/ansible/roles/schulcloud-server-core/templates/tldraw-delete-files-cronjob.yml.j2 +++ /dev/null @@ -1,103 +0,0 @@ -apiVersion: batch/v1 -kind: CronJob -metadata: - namespace: {{ NAMESPACE }} - labels: - app: tldraw-delete-files-cronjob - cronjob: tldraw-delete-files - app.kubernetes.io/part-of: schulcloud-verbund - app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} - app.kubernetes.io/name: tldraw-delete-files - app.kubernetes.io/component: tldraw - app.kubernetes.io/managed-by: ansible - git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} - git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} - name: tldraw-delete-files-cronjob -spec: - concurrencyPolicy: Forbid - schedule: "{{ TLDRAW_FILE_DELETION_CRONJOB_SCHEDULE|default("@midnight", true) }}" - jobTemplate: - spec: - template: - spec: - securityContext: - runAsUser: 1000 - runAsGroup: 1000 - fsGroup: 1000 - runAsNonRoot: true - containers: - - name: tldraw-delete-files-cronjob - image: {{ SCHULCLOUD_SERVER_IMAGE }}:{{ SCHULCLOUD_SERVER_IMAGE_TAG }} - envFrom: - - configMapRef: - name: api-configmap - - secretRef: - name: api-secret - - secretRef: - name: api-files-secret - command: ['/bin/sh', '-c'] - args: ['npm run nest:start:tldraw-console -- files deletion-job 24'] - resources: - limits: - cpu: {{ API_CPU_LIMITS|default("2000m", true) }} - memory: {{ API_MEMORY_LIMITS|default("2Gi", true) }} - requests: - cpu: {{ API_CPU_REQUESTS|default("100m", true) }} - memory: {{ API_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 %} - metadata: - labels: - app: tldraw-delete-files-cronjob - cronjob: tldraw-delete-files - app.kubernetes.io/part-of: schulcloud-verbund - app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} - app.kubernetes.io/name: tldraw-delete-files - app.kubernetes.io/component: tldraw - app.kubernetes.io/managed-by: ansible - git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} - git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} diff --git a/ansible/roles/schulcloud-server-core/templates/tldraw-deployment.yml.j2 b/ansible/roles/schulcloud-server-core/templates/tldraw-deployment.yml.j2 deleted file mode 100644 index ed052650dad..00000000000 --- a/ansible/roles/schulcloud-server-core/templates/tldraw-deployment.yml.j2 +++ /dev/null @@ -1,120 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: tldraw-deployment - namespace: {{ NAMESPACE }} -{% if ANNOTATIONS is defined and ANNOTATIONS|bool %} - annotations: -{% if RELOADER is defined and RELOADER|bool %} - reloader.stakater.com/auto: "true" -{% endif %} -{% endif %} - labels: - app: tldraw-server - app.kubernetes.io/part-of: schulcloud-verbund - app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} - app.kubernetes.io/name: tldraw-server - app.kubernetes.io/component: tldraw - app.kubernetes.io/managed-by: ansible - git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} - git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} -spec: - replicas: {{ TLDRAW_SERVER_REPLICAS|default("1", true) }} - strategy: - type: RollingUpdate - rollingUpdate: - maxSurge: 1 - #maxUnavailable: 1 - revisionHistoryLimit: 4 - paused: false - selector: - matchLabels: - app: tldraw-server - template: - metadata: - labels: - app: tldraw-server - app.kubernetes.io/part-of: schulcloud-verbund - app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} - app.kubernetes.io/name: tldraw-server - app.kubernetes.io/component: tldraw - app.kubernetes.io/managed-by: ansible - git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} - git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} - spec: - securityContext: - runAsUser: 1000 - runAsGroup: 1000 - fsGroup: 1000 - runAsNonRoot: true - containers: - - name: tldraw - image: {{ SCHULCLOUD_SERVER_IMAGE }}:{{ SCHULCLOUD_SERVER_IMAGE_TAG }} - imagePullPolicy: IfNotPresent - ports: - - containerPort: 3345 - name: tldraw-ws - protocol: TCP - - containerPort: 3349 - name: tldraw-http - protocol: TCP - - containerPort: 9090 - name: api-metrics - protocol: TCP - envFrom: - - configMapRef: - name: api-configmap - - secretRef: - name: api-secret - - secretRef: - name: tldraw-server-secret - - secretRef: - name: api-files-secret - env: - - name: NODE_OPTIONS - value: "--max-old-space-size=4096" - command: ['npm', 'run', 'nest:start:tldraw:prod'] - resources: - limits: - cpu: {{ TLDRAW_EDITOR_CPU_LIMITS|default("2000m", true) }} - memory: {{ TLDRAW_EDITOR_MEMORY_LIMITS|default("4Gi", true) }} - requests: - cpu: {{ TLDRAW_EDITOR_CPU_REQUESTS|default("100m", true) }} - memory: {{ TLDRAW_EDITOR_MEMORY_REQUESTS|default("150Mi", true) }} -{% if AFFINITY_ENABLE is defined and AFFINITY_ENABLE|bool %} - affinity: - podAffinity: - preferredDuringSchedulingIgnoredDuringExecution: - - weight: 9 - podAffinityTerm: - labelSelector: - matchExpressions: - - key: app.kubernetes.io/part-of - operator: In - values: - - schulcloud-verbund - topologyKey: "kubernetes.io/hostname" - namespaceSelector: {} - podAntiAffinity: - preferredDuringSchedulingIgnoredDuringExecution: -{% if ANIT_AFFINITY_NODEPOOL_ENABLE is defined and ANIT_AFFINITY_NODEPOOL_ENABLE|bool %} - - weight: 10 - podAffinityTerm: - labelSelector: - matchExpressions: - - key: app.kubernetes.io/name - operator: In - values: - - tldraw-server - topologyKey: {{ ANIT_AFFINITY_NODEPOOL_TOPOLOGY_KEY }} -{% endif %} - - weight: 20 - podAffinityTerm: - labelSelector: - matchExpressions: - - key: app.kubernetes.io/name - operator: In - values: - - tldraw-server - topologyKey: "topology.kubernetes.io/zone" -{% endif %} diff --git a/ansible/roles/schulcloud-server-core/templates/tldraw-ingress.yml.j2 b/ansible/roles/schulcloud-server-core/templates/tldraw-ingress.yml.j2 deleted file mode 100644 index aa765778276..00000000000 --- a/ansible/roles/schulcloud-server-core/templates/tldraw-ingress.yml.j2 +++ /dev/null @@ -1,43 +0,0 @@ -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: {{ NAMESPACE }}-tldraw-ingress - namespace: {{ NAMESPACE }} - annotations: - nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" - nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" - nginx.ingress.kubernetes.io/proxy-body-size: "{{ INGRESS_MAX_BODY_SIZE|default("2560") }}m" - nginx.org/client-max-body-size: "{{ INGRESS_MAX_BODY_SIZE|default("2560") }}m" - # The following properties added with BC-3606. - # The header size of the request is too big. For e.g. state and the permanent growing jwt. - # Nginx throws away the Location header, resulting in the 502 Bad Gateway. - nginx.ingress.kubernetes.io/client-header-buffer-size: 100k - nginx.ingress.kubernetes.io/http2-max-header-size: 96k - nginx.ingress.kubernetes.io/large-client-header-buffers: 4 100k - nginx.ingress.kubernetes.io/proxy-buffer-size: 96k - nginx.org/websocket-services: "tldraw-server-svc" -{% if CLUSTER_ISSUER is defined %} - cert-manager.io/cluster-issuer: {{ CLUSTER_ISSUER }} -{% endif %} - -spec: - ingressClassName: {{ INGRESS_CLASS }} -{% if CLUSTER_ISSUER is defined or (TLS_ENABLED is defined and TLS_ENABLED|bool) %} - tls: - - hosts: - - {{ DOMAIN }} -{% if CLUSTER_ISSUER is defined %} - secretName: {{ DOMAIN }}-tls -{% endif %} -{% endif %} - rules: - - host: {{ DOMAIN }} - http: - paths: - - path: /tldraw-server - backend: - service: - name: tldraw-server-svc - port: - number: 3345 - pathType: Prefix diff --git a/ansible/roles/schulcloud-server-core/templates/tldraw-server-external-secret.yml.j2 b/ansible/roles/schulcloud-server-core/templates/tldraw-server-external-secret.yml.j2 deleted file mode 100644 index 2cde63bf1fb..00000000000 --- a/ansible/roles/schulcloud-server-core/templates/tldraw-server-external-secret.yml.j2 +++ /dev/null @@ -1,27 +0,0 @@ -apiVersion: external-secrets.io/v1beta1 -kind: ExternalSecret -metadata: - name: tldraw-server-secret - namespace: {{ NAMESPACE }} - labels: - app: tldraw-server -spec: - refreshInterval: {{ EXTERNAL_SECRETS_REFRESH_INTERVAL }} - secretStoreRef: - kind: SecretStore - name: {{ EXTERNAL_SECRETS_K8S_STORE }} - target: - name: tldraw-server-secret - template: - engineVersion: v2 - mergePolicy: Merge - data: - TLDRAW_DB_URL: "{{ '{{ .MONGO_MANAGEMENT_TEMPLATE_URL }}/' ~ MONGO_MANAGEMENT_PREFIX ~ 'tldraw' ~ MONGO_MANAGEMENT_POSTFIX }}" - dataFrom: - - extract: - key: tldraw-server-secret{{ EXTERNAL_SECRETS_POSTFIX }} - data: - - secretKey: MONGO_MANAGEMENT_TEMPLATE_URL - remoteRef: - key: mongo-cluster-readwrite-secret - property: credentials-url diff --git a/ansible/roles/schulcloud-server-core/templates/tldraw-server-onepassword.yml.j2 b/ansible/roles/schulcloud-server-core/templates/tldraw-server-onepassword.yml.j2 deleted file mode 100644 index 9257e4db97b..00000000000 --- a/ansible/roles/schulcloud-server-core/templates/tldraw-server-onepassword.yml.j2 +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: onepassword.com/v1 -kind: OnePasswordItem -metadata: - name: tldraw-server-secret{{ EXTERNAL_SECRETS_POSTFIX }} - namespace: {{ NAMESPACE }} - labels: - app: tldraw-server -spec: - itemPath: "vaults/{{ ONEPASSWORD_OPERATOR_VAULT }}/items/tldraw-server" diff --git a/ansible/roles/schulcloud-server-core/templates/tldraw-server-svc.yml.j2 b/ansible/roles/schulcloud-server-core/templates/tldraw-server-svc.yml.j2 deleted file mode 100644 index 310b197921e..00000000000 --- a/ansible/roles/schulcloud-server-core/templates/tldraw-server-svc.yml.j2 +++ /dev/null @@ -1,27 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: tldraw-server-svc - namespace: {{ NAMESPACE }} - labels: - app: tldraw-server - app.kubernetes.io/name: tldraw-server-svc -spec: - type: ClusterIP - ports: - # port for WebSocket connection - - port: 3345 - targetPort: 3345 - protocol: TCP - name: tldraw-ws - # port for http managing drawing data - - port: 3349 - targetPort: 3349 - protocol: TCP - name: tldraw-http - - port: {{ PORT_METRICS_SERVER }} - targetPort: 9090 - protocol: TCP - name: api-metrics - selector: - app: tldraw-server diff --git a/ansible/roles/schulcloud-server-core/templates/tldraw-svc-monitor.yml.j2 b/ansible/roles/schulcloud-server-core/templates/tldraw-svc-monitor.yml.j2 deleted file mode 100644 index 0c3a08d804b..00000000000 --- a/ansible/roles/schulcloud-server-core/templates/tldraw-svc-monitor.yml.j2 +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: monitoring.coreos.com/v1 -kind: ServiceMonitor -metadata: - name: tldraw-svc-monitor - namespace: {{ NAMESPACE }} - labels: - app: tldraw-server -spec: - selector: - matchLabels: - app.kubernetes.io/name: tldraw-server-svc - endpoints: - - path: /metrics - port: api-metrics diff --git a/apps/server/src/apps/tldraw-console.app.ts b/apps/server/src/apps/tldraw-console.app.ts deleted file mode 100644 index 30ca108f016..00000000000 --- a/apps/server/src/apps/tldraw-console.app.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* istanbul ignore file */ -import { BootstrapConsole } from 'nestjs-console'; -import { TldrawConsoleModule } from '@modules/tldraw/tldraw-console.module'; - -async function run() { - const bootstrap = new BootstrapConsole({ - module: TldrawConsoleModule, - useDecorators: true, - }); - - const app = await bootstrap.init(); - - try { - await app.init(); - - // Execute console application with provided arguments. - await bootstrap.boot(); - } catch (err) { - // eslint-disable-next-line no-console, @typescript-eslint/no-unsafe-call - console.error(err); - - // Set the exit code to 1 to indicate a console app failure. - process.exit(1); - } - - // Always close the app, even if some exception - // has been thrown from the console app. - await app.close(); -} - -void run(); diff --git a/apps/server/src/apps/tldraw.app.ts b/apps/server/src/apps/tldraw.app.ts deleted file mode 100644 index cab6508bbca..00000000000 --- a/apps/server/src/apps/tldraw.app.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* istanbul ignore file */ -/* eslint-disable no-console */ -import { NestFactory } from '@nestjs/core'; -import { install as sourceMapInstall } from 'source-map-support'; -import { TldrawApiModule } from '@modules/tldraw/tldraw-api.module'; -import { TldrawWsModule } from '@modules/tldraw/tldraw-ws.module'; -import { LegacyLogger, Logger } from '@src/core/logger'; -import * as WebSocket from 'ws'; -import { WsAdapter } from '@nestjs/platform-ws'; -import { ExpressAdapter } from '@nestjs/platform-express'; -import express from 'express'; -import { - AppStartLoggable, - enableOpenApiDocs, - addPrometheusMetricsMiddlewaresIfEnabled, - createAndStartPrometheusMetricsAppIfEnabled, -} from './helpers'; - -async function bootstrap() { - sourceMapInstall(); - - const nestExpress = express(); - const nestExpressAdapter = new ExpressAdapter(nestExpress); - const nestApp = await NestFactory.create(TldrawApiModule, nestExpressAdapter); - nestApp.useLogger(await nestApp.resolve(LegacyLogger)); - nestApp.enableCors(); - - const nestAppWS = await NestFactory.create(TldrawWsModule); - const wss = new WebSocket.Server({ noServer: true }); - nestAppWS.useWebSocketAdapter(new WsAdapter(wss)); - nestAppWS.enableCors(); - enableOpenApiDocs(nestAppWS, 'docs'); - const logger = await nestAppWS.resolve(Logger); - - await nestAppWS.init(); - await nestApp.init(); - - // mount instances - const rootExpress = express(); - - addPrometheusMetricsMiddlewaresIfEnabled(logger, rootExpress); - const port = 3349; - const basePath = '/api/v3'; - - // exposed alias mounts - rootExpress.use(basePath, nestExpress); - - rootExpress.listen(port, () => { - logger.info( - new AppStartLoggable({ - appName: 'Tldraw server app', - port, - }) - ); - - createAndStartPrometheusMetricsAppIfEnabled(logger); - }); -} - -void bootstrap(); diff --git a/apps/server/src/core/error/domain/domain-error-handler.spec.ts b/apps/server/src/core/error/domain/domain-error-handler.spec.ts index 4edf8b5d167..f67ee755384 100644 --- a/apps/server/src/core/error/domain/domain-error-handler.spec.ts +++ b/apps/server/src/core/error/domain/domain-error-handler.spec.ts @@ -4,8 +4,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BusinessError } from '@shared/common'; import { ErrorLogger, ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; import util from 'util'; +import { AxiosError } from 'axios'; import { ErrorLoggable } from '../loggable/error.loggable'; import { ErrorUtils } from '../utils'; +import { AxiosErrorLoggable } from '../loggable'; import { DomainErrorHandler } from './domain-error-handler'; class SampleLoggableException extends BadRequestException implements Loggable { @@ -201,5 +203,22 @@ describe('GlobalErrorFilter', () => { expect(logger.error).toBeCalledWith(loggable); }); }); + + describe('when error is a axios error', () => { + const setup = () => { + const error = new AxiosError('test'); + const axiosLoggable = new AxiosErrorLoggable(error, 'AXIOS_REQUEST_ERROR'); + + return { axiosLoggable }; + }; + + it('should call logger with axios error', () => { + const { axiosLoggable } = setup(); + + domainErrorHandler.exec(axiosLoggable); + + expect(logger.error).toBeCalledWith(axiosLoggable); + }); + }); }); }); diff --git a/apps/server/src/core/error/filter/global-error.filter.spec.ts b/apps/server/src/core/error/filter/global-error.filter.spec.ts index 31ce2b9f04f..4e99f0ca396 100644 --- a/apps/server/src/core/error/filter/global-error.filter.spec.ts +++ b/apps/server/src/core/error/filter/global-error.filter.spec.ts @@ -7,6 +7,7 @@ import { WsException } from '@nestjs/websockets'; import { BusinessError } from '@shared/common'; import { ErrorLogMessage, Loggable } from '@src/core/logger'; import { Response } from 'express'; +import { AxiosError } from 'axios'; import { DomainErrorHandler } from '../domain'; import { ErrorResponse } from '../dto'; import { ErrorUtils } from '../utils'; @@ -102,6 +103,26 @@ describe('GlobalErrorFilter', () => { }); }); + describe('given context is axios', () => { + const setup = () => { + const argumentsHost = createMock(); + argumentsHost.getType.mockReturnValueOnce(UseableContextType.http); + + const error = new AxiosError('test'); + + return { error, argumentsHost }; + }; + + it('should call exec on domain error handler', () => { + const { error, argumentsHost } = setup(); + + service.catch(error, argumentsHost); + + expect(domainErrorHandler.execHttpContext).toBeCalledWith(error, {}); + expect(domainErrorHandler.execHttpContext).toBeCalledTimes(1); + }); + }); + describe('given context is http', () => { const mockHttpArgumentsHost = () => { const argumentsHost = createMock(); diff --git a/apps/server/src/infra/etherpad-client/etherpad-client.adapter.spec.ts b/apps/server/src/infra/etherpad-client/etherpad-client.adapter.spec.ts index a85501f9357..e8708d5ca88 100644 --- a/apps/server/src/infra/etherpad-client/etherpad-client.adapter.spec.ts +++ b/apps/server/src/infra/etherpad-client/etherpad-client.adapter.spec.ts @@ -995,7 +995,12 @@ describe(EtherpadClientAdapter.name, () => { it('should throw EtherpadErrorLoggableException', async () => { const groupId = setup(); - const exception = new EtherpadErrorLoggableException(EtherpadErrorType.INTERNAL_ERROR, { padId: groupId }, {}); + const exception = new EtherpadErrorLoggableException( + EtherpadErrorType.INTERNAL_ERROR, + { padId: groupId }, + undefined, + {} + ); await expect(service.deleteGroup(groupId)).rejects.toThrowError(exception); }); }); @@ -1084,7 +1089,12 @@ describe(EtherpadClientAdapter.name, () => { it('should throw EtherpadErrorLoggableException', async () => { const sessionId = setup(); - const exception = new EtherpadErrorLoggableException(EtherpadErrorType.BAD_REQUEST, { sessionId }, {}); + const exception = new EtherpadErrorLoggableException( + EtherpadErrorType.BAD_REQUEST, + { sessionId }, + undefined, + {} + ); await expect(service.deleteSession(sessionId)).rejects.toThrowError(exception); }); }); @@ -1150,7 +1160,7 @@ describe(EtherpadClientAdapter.name, () => { it('should throw EtherpadErrorLoggableException', async () => { const padId = setup(); - const exception = new EtherpadErrorLoggableException(EtherpadErrorType.BAD_REQUEST, { padId }, {}); + const exception = new EtherpadErrorLoggableException(EtherpadErrorType.BAD_REQUEST, { padId }, undefined, {}); await expect(service.deletePad(padId)).rejects.toThrowError(exception); }); }); diff --git a/apps/server/src/infra/etherpad-client/loggable/etherpad-error-loggable-exception.ts b/apps/server/src/infra/etherpad-client/loggable/etherpad-error-loggable-exception.ts index 93fa4140076..4ce8c6ba1da 100644 --- a/apps/server/src/infra/etherpad-client/loggable/etherpad-error-loggable-exception.ts +++ b/apps/server/src/infra/etherpad-client/loggable/etherpad-error-loggable-exception.ts @@ -6,6 +6,7 @@ export class EtherpadErrorLoggableException extends InternalServerErrorException constructor( private readonly type: EtherpadErrorType, private readonly payload: EtherpadParams, + private readonly originalMessage: string | undefined, private readonly exceptionOptions: HttpExceptionOptions ) { super(type, exceptionOptions); @@ -20,6 +21,7 @@ export class EtherpadErrorLoggableException extends InternalServerErrorException data: { userId, parentId, + originalMessage: this.originalMessage, }, }; diff --git a/apps/server/src/infra/etherpad-client/loggable/etherpad-server-error-exception.spec.ts b/apps/server/src/infra/etherpad-client/loggable/etherpad-server-error-exception.spec.ts index 63cbfadfabb..b81c0398ed5 100644 --- a/apps/server/src/infra/etherpad-client/loggable/etherpad-server-error-exception.spec.ts +++ b/apps/server/src/infra/etherpad-client/loggable/etherpad-server-error-exception.spec.ts @@ -13,7 +13,7 @@ describe('EtherpadErrorLoggableException', () => { const error = new Error('error'); const httpExceptionOptions = ErrorUtils.createHttpExceptionOptions(error); - const exception = new EtherpadErrorLoggableException(type, payload, httpExceptionOptions); + const exception = new EtherpadErrorLoggableException(type, payload, 'hugo ist nudeln', httpExceptionOptions); const result = exception.getLogMessage(); expect(result).toStrictEqual({ @@ -22,6 +22,7 @@ describe('EtherpadErrorLoggableException', () => { data: { userId: 'userId', parentId: 'parentId', + originalMessage: 'hugo ist nudeln', }, }); }); diff --git a/apps/server/src/infra/etherpad-client/mappers/etherpad-response.mapper.ts b/apps/server/src/infra/etherpad-client/mappers/etherpad-response.mapper.ts index cadab81fc7f..0857b3c502b 100644 --- a/apps/server/src/infra/etherpad-client/mappers/etherpad-response.mapper.ts +++ b/apps/server/src/infra/etherpad-client/mappers/etherpad-response.mapper.ts @@ -62,7 +62,12 @@ export class EtherpadResponseMapper { payload: EtherpadParams, response: T | Error ): EtherpadErrorLoggableException { - return new EtherpadErrorLoggableException(type, payload, ErrorUtils.createHttpExceptionOptions(response.message)); + return new EtherpadErrorLoggableException( + type, + payload, + response.message, + ErrorUtils.createHttpExceptionOptions(response.message) + ); } static mapEtherpadSessionsToSessions(etherpadSessions: unknown): Session[] { diff --git a/apps/server/src/infra/schulconnex-client/response/schulconnex-gruppe-response.ts b/apps/server/src/infra/schulconnex-client/response/schulconnex-gruppe-response.ts index 2533d306743..82eb6abc73a 100644 --- a/apps/server/src/infra/schulconnex-client/response/schulconnex-gruppe-response.ts +++ b/apps/server/src/infra/schulconnex-client/response/schulconnex-gruppe-response.ts @@ -1,6 +1,5 @@ import { Type } from 'class-transformer'; -import { IsEnum, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; -import { SchulconnexGroupType } from './schulconnex-group-type'; +import { IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; import { SchulconnexLaufzeitResponse } from './schulconnex-laufzeit-response'; export class SchulconnexGruppeResponse { @@ -10,8 +9,8 @@ export class SchulconnexGruppeResponse { @IsString() bezeichnung!: string; - @IsEnum(SchulconnexGroupType) - typ!: SchulconnexGroupType; + @IsString() + typ!: string; @IsOptional() @IsObject() diff --git a/apps/server/src/infra/schulconnex-client/response/schulconnex-personenkontext-response.ts b/apps/server/src/infra/schulconnex-client/response/schulconnex-personenkontext-response.ts index f127688af59..0e6c4474c39 100644 --- a/apps/server/src/infra/schulconnex-client/response/schulconnex-personenkontext-response.ts +++ b/apps/server/src/infra/schulconnex-client/response/schulconnex-personenkontext-response.ts @@ -1,17 +1,16 @@ import { Type } from 'class-transformer'; -import { IsArray, IsEnum, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { IsArray, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; import { SchulconnexErreichbarkeitenResponse } from './schulconnex-erreichbarkeiten-response'; import { SchulconnexGruppenResponse } from './schulconnex-gruppen-response'; import { SchulconnexOrganisationResponse } from './schulconnex-organisation-response'; import { SchulconnexResponseValidationGroups } from './schulconnex-response-validation-groups'; -import { SchulconnexRole } from './schulconnex-role'; export class SchulconnexPersonenkontextResponse { @IsString({ groups: [SchulconnexResponseValidationGroups.USER, SchulconnexResponseValidationGroups.GROUPS] }) id!: string; - @IsEnum(SchulconnexRole, { groups: [SchulconnexResponseValidationGroups.USER] }) - rolle!: SchulconnexRole; + @IsString({ groups: [SchulconnexResponseValidationGroups.USER] }) + rolle!: string; @IsObject({ groups: [SchulconnexResponseValidationGroups.SCHOOL] }) @ValidateNested({ groups: [SchulconnexResponseValidationGroups.SCHOOL] }) diff --git a/apps/server/src/infra/schulconnex-client/schulconnex-client-config.ts b/apps/server/src/infra/schulconnex-client/schulconnex-client-config.ts index e7d5e6b23b6..709f4a1ea71 100644 --- a/apps/server/src/infra/schulconnex-client/schulconnex-client-config.ts +++ b/apps/server/src/infra/schulconnex-client/schulconnex-client-config.ts @@ -1,4 +1,5 @@ export interface SchulconnexClientConfig { + SCHULCONNEX_CLIENT__PERSON_INFO_TIMEOUT_IN_MS: number; SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS: number; SCHULCONNEX_CLIENT__POLICIES_INFO_TIMEOUT_IN_MS: number; SCHULCONNEX_CLIENT__API_URL?: string; diff --git a/apps/server/src/infra/schulconnex-client/schulconnex-client.module.ts b/apps/server/src/infra/schulconnex-client/schulconnex-client.module.ts index b16a7f55458..bff42d9bbdb 100644 --- a/apps/server/src/infra/schulconnex-client/schulconnex-client.module.ts +++ b/apps/server/src/infra/schulconnex-client/schulconnex-client.module.ts @@ -8,7 +8,7 @@ import { SchulconnexRestClientOptions } from './schulconnex-rest-client-options' @Module({}) export class SchulconnexClientModule { - static registerAsync(): DynamicModule { + public static registerAsync(): DynamicModule { return { imports: [HttpModule, LoggerModule], module: SchulconnexClientModule, @@ -27,6 +27,7 @@ export class SchulconnexClientModule { tokenEndpoint: configService.get('SCHULCONNEX_CLIENT__TOKEN_ENDPOINT'), clientId: configService.get('SCHULCONNEX_CLIENT__CLIENT_ID'), clientSecret: configService.get('SCHULCONNEX_CLIENT__CLIENT_SECRET'), + personInfoTimeoutInMs: configService.get('SCHULCONNEX_CLIENT__PERSON_INFO_TIMEOUT_IN_MS'), personenInfoTimeoutInMs: configService.get('SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS'), policiesInfoTimeoutInMs: configService.get('SCHULCONNEX_CLIENT__POLICIES_INFO_TIMEOUT_IN_MS'), }; diff --git a/apps/server/src/infra/schulconnex-client/schulconnex-rest-client-options.ts b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client-options.ts index 01391ec207e..5316df7e74a 100644 --- a/apps/server/src/infra/schulconnex-client/schulconnex-rest-client-options.ts +++ b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client-options.ts @@ -7,6 +7,8 @@ export interface SchulconnexRestClientOptions { clientSecret?: string; + personInfoTimeoutInMs?: number; + personenInfoTimeoutInMs?: number; policiesInfoTimeoutInMs?: number; diff --git a/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.spec.ts b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.spec.ts index 5af753d8554..49ad5e2fa29 100644 --- a/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.spec.ts +++ b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.spec.ts @@ -25,8 +25,9 @@ describe(SchulconnexRestClient.name, () => { clientId: 'clientId', clientSecret: 'clientSecret', tokenEndpoint: 'https://schulconnex.url/token', - personenInfoTimeoutInMs: 30000, - policiesInfoTimeoutInMs: 30000, + personInfoTimeoutInMs: 30001, + personenInfoTimeoutInMs: 30002, + policiesInfoTimeoutInMs: 30003, }; beforeAll(() => { @@ -100,6 +101,7 @@ describe(SchulconnexRestClient.name, () => { Authorization: `Bearer ${accessToken}`, 'Accept-Encoding': 'gzip', }, + timeout: options.personInfoTimeoutInMs, }); }); diff --git a/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.ts b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.ts index 820668c16ce..d9a3b829cd3 100644 --- a/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.ts +++ b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.ts @@ -30,10 +30,14 @@ export class SchulconnexRestClient implements SchulconnexApiInterface { this.SCHULCONNEX_API_BASE_URL = options.apiUrl || ''; } - public async getPersonInfo(accessToken: string, options?: { overrideUrl: string }): Promise { + public getPersonInfo(accessToken: string, options?: { overrideUrl: string }): Promise { const url: URL = new URL(options?.overrideUrl ?? `${this.SCHULCONNEX_API_BASE_URL}/person-info`); - const response: Promise = this.getRequest(url, accessToken); + const response: Promise = this.getRequest( + url, + accessToken, + this.options.personInfoTimeoutInMs + ); return response; } diff --git a/apps/server/src/infra/sync/tsp/tsp-oauth-data.mapper.ts b/apps/server/src/infra/sync/tsp/tsp-oauth-data.mapper.ts index 31904871123..dcadb75014f 100644 --- a/apps/server/src/infra/sync/tsp/tsp-oauth-data.mapper.ts +++ b/apps/server/src/infra/sync/tsp/tsp-oauth-data.mapper.ts @@ -34,7 +34,7 @@ export class TspOauthDataMapper { }); const externalSchools = new Map(); - const externalClasses = new Map(); + const externalClasses = new Map(); const teacherForClasses = new Map>(); const oauthDataDtos: OauthDataDto[] = []; @@ -85,9 +85,9 @@ export class TspOauthDataMapper { }); const classIds = teacherForClasses.get(tspTeacher.lehrerUid) ?? []; - const classes = classIds + const classes: ExternalClassDto[] = classIds .map((classId) => externalClasses.get(classId)) - .filter((externalClass) => !!externalClass); + .filter((externalClass: ExternalClassDto | undefined): externalClass is ExternalClassDto => !!externalClass); const externalSchool = tspTeacher.schuleNummer == null ? undefined : externalSchools.get(tspTeacher.schuleNummer); diff --git a/apps/server/src/infra/sync/tsp/tsp-sync.strategy.spec.ts b/apps/server/src/infra/sync/tsp/tsp-sync.strategy.spec.ts index b4d8a3b8a52..b4303779eef 100644 --- a/apps/server/src/infra/sync/tsp/tsp-sync.strategy.spec.ts +++ b/apps/server/src/infra/sync/tsp/tsp-sync.strategy.spec.ts @@ -22,8 +22,8 @@ import { schoolFactory } from '@src/modules/school/testing'; import { System } from '@src/modules/system'; import { systemFactory } from '@src/modules/system/testing'; import { SyncStrategyTarget } from '../sync-strategy.types'; -import { TspLegacyMigrationService } from './tsp-legacy-migration.service'; import { TspFetchService } from './tsp-fetch.service'; +import { TspLegacyMigrationService } from './tsp-legacy-migration.service'; import { TspOauthDataMapper } from './tsp-oauth-data.mapper'; import { TspSyncConfig } from './tsp-sync.config'; import { TspSyncService } from './tsp-sync.service'; @@ -171,6 +171,7 @@ describe(TspSyncStrategy.name, () => { }), externalUser: new ExternalUserDto({ externalId: faker.string.alpha(), + roles: [], }), }); const tspTeacher: RobjExportLehrerMigration = { diff --git a/apps/server/src/infra/tldraw-client/generated/.gitignore b/apps/server/src/infra/tldraw-client/generated/.gitignore new file mode 100644 index 00000000000..149b5765472 --- /dev/null +++ b/apps/server/src/infra/tldraw-client/generated/.gitignore @@ -0,0 +1,4 @@ +wwwroot/*.js +node_modules +typings +dist diff --git a/apps/server/src/infra/tldraw-client/generated/.npmignore b/apps/server/src/infra/tldraw-client/generated/.npmignore new file mode 100644 index 00000000000..999d88df693 --- /dev/null +++ b/apps/server/src/infra/tldraw-client/generated/.npmignore @@ -0,0 +1 @@ +# empty npmignore to ensure all required files (e.g., in the dist folder) are published by npm \ No newline at end of file diff --git a/apps/server/src/infra/tldraw-client/generated/.openapi-generator-ignore b/apps/server/src/infra/tldraw-client/generated/.openapi-generator-ignore new file mode 100644 index 00000000000..7484ee590a3 --- /dev/null +++ b/apps/server/src/infra/tldraw-client/generated/.openapi-generator-ignore @@ -0,0 +1,23 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md diff --git a/apps/server/src/infra/tldraw-client/generated/.openapi-generator/FILES b/apps/server/src/infra/tldraw-client/generated/.openapi-generator/FILES new file mode 100644 index 00000000000..e657390503b --- /dev/null +++ b/apps/server/src/infra/tldraw-client/generated/.openapi-generator/FILES @@ -0,0 +1,13 @@ +.gitignore +.npmignore +.openapi-generator-ignore +api.ts +api/tldraw-config-api.ts +api/tldraw-document-api.ts +base.ts +common.ts +configuration.ts +git_push.sh +index.ts +models/index.ts +models/tldraw-public-config-response.ts diff --git a/apps/server/src/infra/tldraw-client/generated/.openapi-generator/VERSION b/apps/server/src/infra/tldraw-client/generated/.openapi-generator/VERSION new file mode 100644 index 00000000000..93c8ddab9fe --- /dev/null +++ b/apps/server/src/infra/tldraw-client/generated/.openapi-generator/VERSION @@ -0,0 +1 @@ +7.6.0 diff --git a/apps/server/src/infra/tldraw-client/generated/api.ts b/apps/server/src/infra/tldraw-client/generated/api.ts new file mode 100644 index 00000000000..28b204591ca --- /dev/null +++ b/apps/server/src/infra/tldraw-client/generated/api.ts @@ -0,0 +1,19 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Tldraw API + * The Tldraw API to persist and share drawings + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export * from './api/tldraw-config-api'; +export * from './api/tldraw-document-api'; + diff --git a/apps/server/src/infra/tldraw-client/generated/api/tldraw-config-api.ts b/apps/server/src/infra/tldraw-client/generated/api/tldraw-config-api.ts new file mode 100644 index 00000000000..921b007a504 --- /dev/null +++ b/apps/server/src/infra/tldraw-client/generated/api/tldraw-config-api.ts @@ -0,0 +1,141 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Tldraw API + * The Tldraw API to persist and share drawings + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import type { Configuration } from '../configuration'; +import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; +import globalAxios from 'axios'; +// Some imports not used depending on template conditions +// @ts-ignore +import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction } from '../common'; +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; +// @ts-ignore +import type { TldrawPublicConfigResponse } from '../models'; +/** + * TldrawConfigApi - axios parameter creator + * @export + */ +export const TldrawConfigApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @summary Useable configuration for clients + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + publicConfig: async (options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/api/tldraw/config/public`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * TldrawConfigApi - functional programming interface + * @export + */ +export const TldrawConfigApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = TldrawConfigApiAxiosParamCreator(configuration) + return { + /** + * + * @summary Useable configuration for clients + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async publicConfig(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.publicConfig(options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['TldrawConfigApi.publicConfig']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + } +}; + +/** + * TldrawConfigApi - factory interface + * @export + */ +export const TldrawConfigApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = TldrawConfigApiFp(configuration) + return { + /** + * + * @summary Useable configuration for clients + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + publicConfig(options?: any): AxiosPromise { + return localVarFp.publicConfig(options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * TldrawConfigApi - interface + * @export + * @interface TldrawConfigApi + */ +export interface TldrawConfigApiInterface { + /** + * + * @summary Useable configuration for clients + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof TldrawConfigApiInterface + */ + publicConfig(options?: RawAxiosRequestConfig): AxiosPromise; + +} + +/** + * TldrawConfigApi - object-oriented interface + * @export + * @class TldrawConfigApi + * @extends {BaseAPI} + */ +export class TldrawConfigApi extends BaseAPI implements TldrawConfigApiInterface { + /** + * + * @summary Useable configuration for clients + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof TldrawConfigApi + */ + public publicConfig(options?: RawAxiosRequestConfig) { + return TldrawConfigApiFp(this.configuration).publicConfig(options).then((request) => request(this.axios, this.basePath)); + } +} + diff --git a/apps/server/src/infra/tldraw-client/generated/api/tldraw-document-api.ts b/apps/server/src/infra/tldraw-client/generated/api/tldraw-document-api.ts new file mode 100644 index 00000000000..f2ab5e07d42 --- /dev/null +++ b/apps/server/src/infra/tldraw-client/generated/api/tldraw-document-api.ts @@ -0,0 +1,142 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Tldraw API + * The Tldraw API to persist and share drawings + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import type { Configuration } from '../configuration'; +import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; +import globalAxios from 'axios'; +// Some imports not used depending on template conditions +// @ts-ignore +import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction } from '../common'; +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; +/** + * TldrawDocumentApi - axios parameter creator + * @export + */ +export const TldrawDocumentApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @param {string} parentId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteByDocName: async (parentId: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'parentId' is not null or undefined + assertParamExists('deleteByDocName', 'parentId', parentId) + const localVarPath = `/api/tldraw-document/{parentId}` + .replace(`{${"parentId"}}`, encodeURIComponent(String(parentId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * TldrawDocumentApi - functional programming interface + * @export + */ +export const TldrawDocumentApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = TldrawDocumentApiAxiosParamCreator(configuration) + return { + /** + * + * @param {string} parentId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async deleteByDocName(parentId: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.deleteByDocName(parentId, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['TldrawDocumentApi.deleteByDocName']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + } +}; + +/** + * TldrawDocumentApi - factory interface + * @export + */ +export const TldrawDocumentApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = TldrawDocumentApiFp(configuration) + return { + /** + * + * @param {string} parentId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteByDocName(parentId: string, options?: any): AxiosPromise { + return localVarFp.deleteByDocName(parentId, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * TldrawDocumentApi - interface + * @export + * @interface TldrawDocumentApi + */ +export interface TldrawDocumentApiInterface { + /** + * + * @param {string} parentId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof TldrawDocumentApiInterface + */ + deleteByDocName(parentId: string, options?: RawAxiosRequestConfig): AxiosPromise; + +} + +/** + * TldrawDocumentApi - object-oriented interface + * @export + * @class TldrawDocumentApi + * @extends {BaseAPI} + */ +export class TldrawDocumentApi extends BaseAPI implements TldrawDocumentApiInterface { + /** + * + * @param {string} parentId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof TldrawDocumentApi + */ + public deleteByDocName(parentId: string, options?: RawAxiosRequestConfig) { + return TldrawDocumentApiFp(this.configuration).deleteByDocName(parentId, options).then((request) => request(this.axios, this.basePath)); + } +} + diff --git a/apps/server/src/infra/tldraw-client/generated/base.ts b/apps/server/src/infra/tldraw-client/generated/base.ts new file mode 100644 index 00000000000..7b0d3f63e36 --- /dev/null +++ b/apps/server/src/infra/tldraw-client/generated/base.ts @@ -0,0 +1,86 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Tldraw API + * The Tldraw API to persist and share drawings + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import type { Configuration } from './configuration'; +// Some imports not used depending on template conditions +// @ts-ignore +import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; +import globalAxios from 'axios'; + +export const BASE_PATH = "http://localhost".replace(/\/+$/, ""); + +/** + * + * @export + */ +export const COLLECTION_FORMATS = { + csv: ",", + ssv: " ", + tsv: "\t", + pipes: "|", +}; + +/** + * + * @export + * @interface RequestArgs + */ +export interface RequestArgs { + url: string; + options: RawAxiosRequestConfig; +} + +/** + * + * @export + * @class BaseAPI + */ +export class BaseAPI { + protected configuration: Configuration | undefined; + + constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) { + if (configuration) { + this.configuration = configuration; + this.basePath = configuration.basePath ?? basePath; + } + } +}; + +/** + * + * @export + * @class RequiredError + * @extends {Error} + */ +export class RequiredError extends Error { + constructor(public field: string, msg?: string) { + super(msg); + this.name = "RequiredError" + } +} + +interface ServerMap { + [key: string]: { + url: string, + description: string, + }[]; +} + +/** + * + * @export + */ +export const operationServerMap: ServerMap = { +} diff --git a/apps/server/src/infra/tldraw-client/generated/common.ts b/apps/server/src/infra/tldraw-client/generated/common.ts new file mode 100644 index 00000000000..12b45593325 --- /dev/null +++ b/apps/server/src/infra/tldraw-client/generated/common.ts @@ -0,0 +1,150 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Tldraw API + * The Tldraw API to persist and share drawings + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import type { Configuration } from "./configuration"; +import type { RequestArgs } from "./base"; +import type { AxiosInstance, AxiosResponse } from 'axios'; +import { RequiredError } from "./base"; + +/** + * + * @export + */ +export const DUMMY_BASE_URL = 'https://example.com' + +/** + * + * @throws {RequiredError} + * @export + */ +export const assertParamExists = function (functionName: string, paramName: string, paramValue: unknown) { + if (paramValue === null || paramValue === undefined) { + throw new RequiredError(paramName, `Required parameter ${paramName} was null or undefined when calling ${functionName}.`); + } +} + +/** + * + * @export + */ +export const setApiKeyToObject = async function (object: any, keyParamName: string, configuration?: Configuration) { + if (configuration && configuration.apiKey) { + const localVarApiKeyValue = typeof configuration.apiKey === 'function' + ? await configuration.apiKey(keyParamName) + : await configuration.apiKey; + object[keyParamName] = localVarApiKeyValue; + } +} + +/** + * + * @export + */ +export const setBasicAuthToObject = function (object: any, configuration?: Configuration) { + if (configuration && (configuration.username || configuration.password)) { + object["auth"] = { username: configuration.username, password: configuration.password }; + } +} + +/** + * + * @export + */ +export const setBearerAuthToObject = async function (object: any, configuration?: Configuration) { + if (configuration && configuration.accessToken) { + const accessToken = typeof configuration.accessToken === 'function' + ? await configuration.accessToken() + : await configuration.accessToken; + object["Authorization"] = "Bearer " + accessToken; + } +} + +/** + * + * @export + */ +export const setOAuthToObject = async function (object: any, name: string, scopes: string[], configuration?: Configuration) { + if (configuration && configuration.accessToken) { + const localVarAccessTokenValue = typeof configuration.accessToken === 'function' + ? await configuration.accessToken(name, scopes) + : await configuration.accessToken; + object["Authorization"] = "Bearer " + localVarAccessTokenValue; + } +} + +function setFlattenedQueryParams(urlSearchParams: URLSearchParams, parameter: any, key: string = ""): void { + if (parameter == null) return; + if (typeof parameter === "object") { + if (Array.isArray(parameter)) { + (parameter as any[]).forEach(item => setFlattenedQueryParams(urlSearchParams, item, key)); + } + else { + Object.keys(parameter).forEach(currentKey => + setFlattenedQueryParams(urlSearchParams, parameter[currentKey], `${key}${key !== '' ? '.' : ''}${currentKey}`) + ); + } + } + else { + if (urlSearchParams.has(key)) { + urlSearchParams.append(key, parameter); + } + else { + urlSearchParams.set(key, parameter); + } + } +} + +/** + * + * @export + */ +export const setSearchParams = function (url: URL, ...objects: any[]) { + const searchParams = new URLSearchParams(url.search); + setFlattenedQueryParams(searchParams, objects); + url.search = searchParams.toString(); +} + +/** + * + * @export + */ +export const serializeDataIfNeeded = function (value: any, requestOptions: any, configuration?: Configuration) { + const nonString = typeof value !== 'string'; + const needsSerialization = nonString && configuration && configuration.isJsonMime + ? configuration.isJsonMime(requestOptions.headers['Content-Type']) + : nonString; + return needsSerialization + ? JSON.stringify(value !== undefined ? value : {}) + : (value || ""); +} + +/** + * + * @export + */ +export const toPathString = function (url: URL) { + return url.pathname + url.search + url.hash +} + +/** + * + * @export + */ +export const createRequestFunction = function (axiosArgs: RequestArgs, globalAxios: AxiosInstance, BASE_PATH: string, configuration?: Configuration) { + return >(axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => { + const axiosRequestArgs = {...axiosArgs.options, url: (axios.defaults.baseURL ? '' : configuration?.basePath ?? basePath) + axiosArgs.url}; + return axios.request(axiosRequestArgs); + }; +} diff --git a/apps/server/src/infra/tldraw-client/generated/configuration.ts b/apps/server/src/infra/tldraw-client/generated/configuration.ts new file mode 100644 index 00000000000..d8348aeb141 --- /dev/null +++ b/apps/server/src/infra/tldraw-client/generated/configuration.ts @@ -0,0 +1,110 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Tldraw API + * The Tldraw API to persist and share drawings + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface ConfigurationParameters { + apiKey?: string | Promise | ((name: string) => string) | ((name: string) => Promise); + username?: string; + password?: string; + accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise); + basePath?: string; + serverIndex?: number; + baseOptions?: any; + formDataCtor?: new () => any; +} + +export class Configuration { + /** + * parameter for apiKey security + * @param name security name + * @memberof Configuration + */ + apiKey?: string | Promise | ((name: string) => string) | ((name: string) => Promise); + /** + * parameter for basic security + * + * @type {string} + * @memberof Configuration + */ + username?: string; + /** + * parameter for basic security + * + * @type {string} + * @memberof Configuration + */ + password?: string; + /** + * parameter for oauth2 security + * @param name security name + * @param scopes oauth2 scope + * @memberof Configuration + */ + accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise); + /** + * override base path + * + * @type {string} + * @memberof Configuration + */ + basePath?: string; + /** + * override server index + * + * @type {number} + * @memberof Configuration + */ + serverIndex?: number; + /** + * base options for axios calls + * + * @type {any} + * @memberof Configuration + */ + baseOptions?: any; + /** + * The FormData constructor that will be used to create multipart form data + * requests. You can inject this here so that execution environments that + * do not support the FormData class can still run the generated client. + * + * @type {new () => FormData} + */ + formDataCtor?: new () => any; + + constructor(param: ConfigurationParameters = {}) { + this.apiKey = param.apiKey; + this.username = param.username; + this.password = param.password; + this.accessToken = param.accessToken; + this.basePath = param.basePath; + this.serverIndex = param.serverIndex; + this.baseOptions = param.baseOptions; + this.formDataCtor = param.formDataCtor; + } + + /** + * Check if the given MIME is a JSON MIME. + * JSON MIME examples: + * application/json + * application/json; charset=UTF8 + * APPLICATION/JSON + * application/vnd.company+json + * @param mime - MIME (Multipurpose Internet Mail Extensions) + * @return True if the given MIME is JSON, false otherwise. + */ + public isJsonMime(mime: string): boolean { + const jsonMime: RegExp = new RegExp('^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$', 'i'); + return mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === 'application/json-patch+json'); + } +} diff --git a/apps/server/src/infra/tldraw-client/generated/git_push.sh b/apps/server/src/infra/tldraw-client/generated/git_push.sh new file mode 100644 index 00000000000..f53a75d4fab --- /dev/null +++ b/apps/server/src/infra/tldraw-client/generated/git_push.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/ +# +# Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com" + +git_user_id=$1 +git_repo_id=$2 +release_note=$3 +git_host=$4 + +if [ "$git_host" = "" ]; then + git_host="github.com" + echo "[INFO] No command line input provided. Set \$git_host to $git_host" +fi + +if [ "$git_user_id" = "" ]; then + git_user_id="GIT_USER_ID" + echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id" +fi + +if [ "$git_repo_id" = "" ]; then + git_repo_id="GIT_REPO_ID" + echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id" +fi + +if [ "$release_note" = "" ]; then + release_note="Minor update" + echo "[INFO] No command line input provided. Set \$release_note to $release_note" +fi + +# Initialize the local directory as a Git repository +git init + +# Adds the files in the local repository and stages them for commit. +git add . + +# Commits the tracked changes and prepares them to be pushed to a remote repository. +git commit -m "$release_note" + +# Sets the new remote +git_remote=$(git remote) +if [ "$git_remote" = "" ]; then # git remote not defined + + if [ "$GIT_TOKEN" = "" ]; then + echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment." + git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git + else + git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git + fi + +fi + +git pull origin master + +# Pushes (Forces) the changes in the local repository up to the remote repository +echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git" +git push origin master 2>&1 | grep -v 'To https' diff --git a/apps/server/src/infra/tldraw-client/generated/index.ts b/apps/server/src/infra/tldraw-client/generated/index.ts new file mode 100644 index 00000000000..0ffc7eaa936 --- /dev/null +++ b/apps/server/src/infra/tldraw-client/generated/index.ts @@ -0,0 +1,18 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Tldraw API + * The Tldraw API to persist and share drawings + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export * from "./api"; +export * from "./configuration"; +export * from "./models"; diff --git a/apps/server/src/infra/tldraw-client/generated/models/index.ts b/apps/server/src/infra/tldraw-client/generated/models/index.ts new file mode 100644 index 00000000000..0c9ee6b790c --- /dev/null +++ b/apps/server/src/infra/tldraw-client/generated/models/index.ts @@ -0,0 +1 @@ +export * from './tldraw-public-config-response'; diff --git a/apps/server/src/infra/tldraw-client/generated/models/tldraw-public-config-response.ts b/apps/server/src/infra/tldraw-client/generated/models/tldraw-public-config-response.ts new file mode 100644 index 00000000000..c123fb186ba --- /dev/null +++ b/apps/server/src/infra/tldraw-client/generated/models/tldraw-public-config-response.ts @@ -0,0 +1,54 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Tldraw API + * The Tldraw API to persist and share drawings + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * + * @export + * @interface TldrawPublicConfigResponse + */ +export interface TldrawPublicConfigResponse { + /** + * + * @type {string} + * @memberof TldrawPublicConfigResponse + */ + 'TLDRAW_WEBSOCKET_URL': string; + /** + * + * @type {boolean} + * @memberof TldrawPublicConfigResponse + */ + 'TLDRAW_ASSETS_ENABLED': boolean; + /** + * + * @type {number} + * @memberof TldrawPublicConfigResponse + */ + 'TLDRAW_ASSETS_MAX_SIZE_BYTES': number; + /** + * + * @type {Array} + * @memberof TldrawPublicConfigResponse + */ + 'TLDRAW_ASSETS_ALLOWED_MIME_TYPES_LIST': Array; + /** + * + * @type {boolean} + * @memberof TldrawPublicConfigResponse + */ + 'FEATURE_TLDRAW_ENABLED': boolean; +} + diff --git a/apps/server/src/infra/tldraw-client/index.ts b/apps/server/src/infra/tldraw-client/index.ts new file mode 100644 index 00000000000..41131737e43 --- /dev/null +++ b/apps/server/src/infra/tldraw-client/index.ts @@ -0,0 +1,3 @@ +export { TldrawClientAdapter } from './tldraw-client.adapter'; +export { TldrawClientConfig } from './tldraw-client.config'; +export { TldrawClientModule } from './tldraw-client.module'; diff --git a/apps/server/src/infra/tldraw-client/tldraw-client.adapter.spec.ts b/apps/server/src/infra/tldraw-client/tldraw-client.adapter.spec.ts new file mode 100644 index 00000000000..4b7f61ae2f0 --- /dev/null +++ b/apps/server/src/infra/tldraw-client/tldraw-client.adapter.spec.ts @@ -0,0 +1,56 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { TldrawDocumentApi } from './generated'; +import { TldrawClientAdapter } from './tldraw-client.adapter'; + +describe('TldrawClientAdapter', () => { + describe('deleteDrawingBinData', () => { + let module: TestingModule; + let service: TldrawClientAdapter; + let tldrawDocumentApi: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + TldrawClientAdapter, + { + provide: TldrawDocumentApi, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(TldrawClientAdapter); + tldrawDocumentApi = module.get(TldrawDocumentApi); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('when deleteByDocName resolves', () => { + it('should call deleteDrawingBinData', async () => { + const drawingId = 'drawingId'; + + await service.deleteDrawingBinData(drawingId); + + expect(tldrawDocumentApi.deleteByDocName).toHaveBeenCalledWith(drawingId); + }); + }); + + describe('when deleteByDocName rejects', () => { + it('should throw an error', async () => { + const drawingId = 'drawingId'; + const error = new Error('deleteByDocName error'); + + tldrawDocumentApi.deleteByDocName.mockRejectedValue(error); + + await expect(service.deleteDrawingBinData(drawingId)).rejects.toThrowError(error); + }); + }); + }); +}); diff --git a/apps/server/src/infra/tldraw-client/tldraw-client.adapter.ts b/apps/server/src/infra/tldraw-client/tldraw-client.adapter.ts new file mode 100644 index 00000000000..af5d9f8d55e --- /dev/null +++ b/apps/server/src/infra/tldraw-client/tldraw-client.adapter.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { TldrawDocumentApi } from './generated'; + +@Injectable() +export class TldrawClientAdapter { + constructor(private readonly tldrawDocumentApi: TldrawDocumentApi) {} + + async deleteDrawingBinData(parentId: string): Promise { + await this.tldrawDocumentApi.deleteByDocName(parentId); + } +} diff --git a/apps/server/src/modules/tldraw-client/interface/tldraw-client-config.interface.ts b/apps/server/src/infra/tldraw-client/tldraw-client.config.ts similarity index 100% rename from apps/server/src/modules/tldraw-client/interface/tldraw-client-config.interface.ts rename to apps/server/src/infra/tldraw-client/tldraw-client.config.ts diff --git a/apps/server/src/infra/tldraw-client/tldraw-client.module.ts b/apps/server/src/infra/tldraw-client/tldraw-client.module.ts new file mode 100644 index 00000000000..86dde82a12a --- /dev/null +++ b/apps/server/src/infra/tldraw-client/tldraw-client.module.ts @@ -0,0 +1,33 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { Configuration, TldrawDocumentApi } from './generated'; +import { TldrawClientAdapter } from './tldraw-client.adapter'; +import { TldrawClientConfig } from './tldraw-client.config'; + +@Module({}) +export class TldrawClientModule { + public static register(config: TldrawClientConfig): DynamicModule { + const providers = [ + TldrawClientAdapter, + { + provide: TldrawDocumentApi, + useFactory: () => { + const configuration = new Configuration({ + basePath: config.TLDRAW_ADMIN_API_CLIENT_BASE_URL, + baseOptions: { + headers: { + 'X-API-Key': config.TLDRAW_ADMIN_API_CLIENT_API_KEY, + }, + }, + }); + return new TldrawDocumentApi(configuration); + }, + }, + ]; + + return { + module: TldrawClientModule, + providers, + exports: [TldrawClientAdapter], + }; + } +} diff --git a/apps/server/src/migrations/mikro-orm/Migration20241112163538.ts b/apps/server/src/migrations/mikro-orm/Migration20241112163538.ts deleted file mode 100644 index 26e50089fd7..00000000000 --- a/apps/server/src/migrations/mikro-orm/Migration20241112163538.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Migration } from '@mikro-orm/migrations-mongodb'; - -export class Migration20241112163538 extends Migration { - async up(): Promise { - const collection = this.getCollection('room-members'); - - await collection.updateMany({ roomId: { $type: 'string' } }, [ - { - $set: { - roomId: { - $convert: { - input: '$roomId', - to: 'objectId', - onError: '$roomId', // Keep the original value if conversion fails - onNull: '$roomId', // Keep the original value if the input is null - }, - }, - }, - }, - ]); - console.info('Converted roomId from string to ObjectId'); - - await collection.updateMany({}, { $rename: { roomId: 'room' } }); - console.info('Renamed roomId to room'); - } - - async down(): Promise { - await Promise.resolve(); - console.error(`Migration down not implemented. You might need to restore database from backup!`); - } -} diff --git a/apps/server/src/migrations/mikro-orm/Migration20241113100535.ts b/apps/server/src/migrations/mikro-orm/Migration20241113100535.ts index d233b984596..4bfb49bbbe2 100644 --- a/apps/server/src/migrations/mikro-orm/Migration20241113100535.ts +++ b/apps/server/src/migrations/mikro-orm/Migration20241113100535.ts @@ -46,7 +46,7 @@ export class Migration20241113100535 extends Migration { ); if (teacherRoleUpdate.modifiedCount > 0) { - console.info('Rollback: Permission ROOM_CREATE added to role teacher.'); + console.info('Rollback: Permission ROOM_CREATE removed from role teacher.'); } const roomEditorRoleUpdate = await this.getCollection('roles').updateOne( @@ -61,7 +61,7 @@ export class Migration20241113100535 extends Migration { ); if (roomEditorRoleUpdate.modifiedCount > 0) { - console.info('Rollback: Permission ROOM_DELETE added to role roomeditor.'); + console.info('Rollback: Permission ROOM_DELETE removed from role roomeditor.'); } } } diff --git a/apps/server/src/migrations/mikro-orm/Migration20241113152001.ts b/apps/server/src/migrations/mikro-orm/Migration20241113152001.ts deleted file mode 100644 index 9914935c0a9..00000000000 --- a/apps/server/src/migrations/mikro-orm/Migration20241113152001.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Migration } from '@mikro-orm/migrations-mongodb'; - -export class Migration20241113152001 extends Migration { - async up(): Promise { - const roomsToSchoolView = [ - { - $lookup: { - from: 'rooms', - localField: 'room', - foreignField: '_id', - as: 'roomDetails', - }, - }, - { - $unwind: '$roomDetails', - }, - { - $match: { - 'roomDetails.school': { $exists: false, $eq: null }, - }, - }, - { - $lookup: { - from: 'groups', - localField: 'userGroup', - foreignField: '_id', - as: 'groupDetails', - }, - }, - { - $unwind: '$groupDetails', - }, - { - $unwind: '$groupDetails.users', - }, - { - $lookup: { - from: 'roles', - localField: 'groupDetails.users.role', - foreignField: '_id', - as: 'roleDetails', - }, - }, - { - $unwind: '$roleDetails', - }, - { - $match: { - 'roleDetails.name': 'roomeditor', - }, - }, - { - $lookup: { - from: 'users', - localField: 'groupDetails.users.user', - foreignField: '_id', - as: 'userDetails', - }, - }, - { - $unwind: '$userDetails', - }, - { - $group: { - _id: '$userDetails.schoolId', - rooms: { $push: '$roomDetails._id' }, - }, - }, - { - $project: { - _id: 0, - school: '$_id', - rooms: 1, - }, - }, - ]; - - const mappings = await this.driver.aggregate('room-members', roomsToSchoolView); - - for await (const mapping of mappings) { - const schoolUpdate = await this.driver.nativeUpdate( - 'rooms', - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access - { _id: { $in: mapping.rooms } }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access - { $set: { school: mapping.school } } - ); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-call - console.info(`Updated ${schoolUpdate.affectedRows} rooms with school ${mapping.school.toHexString()}`); - } - - if (mappings.length === 0) { - console.info(`No rooms without school to update`); - } - } - - async down(): Promise { - await Promise.resolve(); - console.error(`Migration down not implemented. You might need to restore database from backup!`); - } -} diff --git a/apps/server/src/migrations/mikro-orm/Migration20241127195120.ts b/apps/server/src/migrations/mikro-orm/Migration20241127195120.ts deleted file mode 100644 index 0b74ac61e63..00000000000 --- a/apps/server/src/migrations/mikro-orm/Migration20241127195120.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Migration } from '@mikro-orm/migrations-mongodb'; - -export class Migration20241127195120 extends Migration { - async up(): Promise { - const db = this.driver.getConnection().getDb(); - await db.renameCollection('room-members', 'room-memberships'); - console.info('Collection renamed from room-members to room-memberships'); - } - - async down(): Promise { - const db = this.driver.getConnection().getDb(); - await db.renameCollection('room-memberships', 'room-members'); - console.info('Collection renamed from room-memberships to room-members'); - } -} diff --git a/apps/server/src/migrations/mikro-orm/Migration20241128155801.ts b/apps/server/src/migrations/mikro-orm/Migration20241128155801.ts deleted file mode 100644 index 725ef54ecd3..00000000000 --- a/apps/server/src/migrations/mikro-orm/Migration20241128155801.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Migration } from '@mikro-orm/migrations-mongodb'; - -export class Migration20241128155801 extends Migration { - async up(): Promise { - const roomMembershipToSchoolView = [ - { - $match: { - school: { $exists: false, $eq: null }, - }, - }, - { - $lookup: { - from: 'rooms', - localField: 'room', - foreignField: '_id', - as: 'roomDetails', - }, - }, - { - $unwind: '$roomDetails', - }, - { - $group: { - _id: '$roomDetails.school', - roomMemberships: { $push: '$_id' }, - }, - }, - { - $project: { - _id: 0, - school: '$_id', - roomMemberships: 1, - }, - }, - ]; - - const mappings = await this.driver.aggregate('room-memberships', roomMembershipToSchoolView); - - for await (const mapping of mappings) { - const schoolUpdate = await this.driver.nativeUpdate( - 'room-memberships', - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access - { _id: { $in: mapping.roomMemberships } }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access - { $set: { school: mapping.school } } - ); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-call - console.info(`Updated ${schoolUpdate.affectedRows} rooms with school ${mapping.school.toHexString()}`); - } - - if (mappings.length === 0) { - console.info(`No roomMemberships without school to update`); - } - } - - async down(): Promise { - await Promise.resolve(); - console.error(`Migration down not implemented. You might need to restore database from backup!`); - } -} diff --git a/apps/server/src/migrations/mikro-orm/Migration20241209165812.ts b/apps/server/src/migrations/mikro-orm/Migration20241209165812.ts new file mode 100644 index 00000000000..ffa54bbc778 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20241209165812.ts @@ -0,0 +1,40 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20241209165812 extends Migration { + async up(): Promise { + // Add ROOM_OWNER role + await this.getCollection('roles').insertOne({ + name: 'roomowner', + permissions: [ + 'ROOM_VIEW', + 'ROOM_EDIT', + 'ROOM_DELETE', + 'ROOM_MEMBERS_ADD', + 'ROOM_MEMBERS_REMOVE', + 'ROOM_CHANGE_OWNER', + ], + }); + console.info( + 'Added ROOM_OWNER role with ROOM_VIEW, -_EDIT, _DELETE, -_MEMBERS_ADD, -_MEMBERS_REMOVE AND -_CHANGE_OWNER permission' + ); + + // Add ROOM_ADMIN role + await this.getCollection('roles').insertOne({ + name: 'roomadmin', + permissions: ['ROOM_VIEW', 'ROOM_EDIT', 'ROOM_MEMBERS_ADD', 'ROOM_MEMBERS_REMOVE'], + }); + console.info( + 'Added ROOM_ADMIN role with ROOM_VIEW, ROOM_EDIT, ROOM_MEMBERS_ADD AND ROOM_MEMBERS_REMOVE permissions' + ); + } + + async down(): Promise { + // Remove ROOM_OWNER role + await this.getCollection('roles').deleteOne({ name: 'roomowner' }); + console.info('Rollback: Removed ROOM_OWNER role'); + + // Remove ROOM_ADMIN role + await this.getCollection('roles').deleteOne({ name: 'roomadmin' }); + console.info('Rollback: Removed ROOM_ADMIN role'); + } +} diff --git a/apps/server/src/migrations/mikro-orm/Migration20241210152600.ts b/apps/server/src/migrations/mikro-orm/Migration20241210152600.ts new file mode 100644 index 00000000000..4bd331b5057 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20241210152600.ts @@ -0,0 +1,35 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20241210152600 extends Migration { + async up(): Promise { + const roomEditorRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'roomeditor' }, + { + $set: { + permissions: ['ROOM_VIEW', 'ROOM_EDIT'], + }, + } + ); + + if (roomEditorRoleUpdate.modifiedCount > 0) { + console.info('Permission ROOM_DELETE removed from role roomeditor.'); + } + } + + async down(): Promise { + const roomEditorRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'roomeditor' }, + { + $set: { + permissions: ['ROOM_VIEW', 'ROOM_EDIT', 'ROOM_DELETE'], + }, + } + ); + + if (roomEditorRoleUpdate.modifiedCount > 0) { + console.info( + 'Rollback: Permissions ROOM_DELETE added to and ROOM_MEMBERS_ADD and ROOM_MEMBERS_REMOVE removed from role roomeditor.' + ); + } + } +} diff --git a/apps/server/src/migrations/mikro-orm/Migration20241213145222.ts b/apps/server/src/migrations/mikro-orm/Migration20241213145222.ts new file mode 100644 index 00000000000..3624c006225 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20241213145222.ts @@ -0,0 +1,11 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20241213145222 extends Migration { + public async up(): Promise { + await this.getCollection('files').createIndex({ 'securityCheck.requestToken': 1 }); + } + + public async down(): Promise { + // no need + } +} diff --git a/apps/server/src/modules/authorization/domain/mapper/authorization-context.builder.ts b/apps/server/src/modules/authorization/domain/mapper/authorization-context.builder.ts index 14ba6714cca..5ad33af1d35 100644 --- a/apps/server/src/modules/authorization/domain/mapper/authorization-context.builder.ts +++ b/apps/server/src/modules/authorization/domain/mapper/authorization-context.builder.ts @@ -8,13 +8,13 @@ export class AuthorizationContextBuilder { return context; } - static write(requiredPermissions: Permission[]): AuthorizationContext { + public static write(requiredPermissions: Permission[]): AuthorizationContext { const context = this.build(requiredPermissions, Action.write); return context; } - static read(requiredPermissions: Permission[]): AuthorizationContext { + public static read(requiredPermissions: Permission[]): AuthorizationContext { const context = this.build(requiredPermissions, Action.read); return context; diff --git a/apps/server/src/modules/board/board-collaboration.config.ts b/apps/server/src/modules/board/board-collaboration.config.ts index e824b4ca867..4a0372db638 100644 --- a/apps/server/src/modules/board/board-collaboration.config.ts +++ b/apps/server/src/modules/board/board-collaboration.config.ts @@ -1,9 +1,9 @@ import { Configuration } from '@hpi-schul-cloud/commons'; import { JwtAuthGuardConfig } from '@infra/auth-guard'; +import { TldrawClientConfig } from '@infra/tldraw-client'; import { Algorithm } from 'jsonwebtoken'; -import { getTldrawClientConfig } from '../tldraw-client'; -export interface BoardCollaborationConfig extends JwtAuthGuardConfig { +export interface BoardCollaborationConfig extends JwtAuthGuardConfig, TldrawClientConfig { NEST_LOG_LEVEL: string; } @@ -14,7 +14,8 @@ const boardCollaborationConfig: BoardCollaborationConfig = { JWT_PUBLIC_KEY: (Configuration.get('JWT_PUBLIC_KEY') as string).replace(/\\n/g, '\n'), JWT_SIGNING_ALGORITHM: Configuration.get('JWT_SIGNING_ALGORITHM') as Algorithm, SC_DOMAIN: Configuration.get('SC_DOMAIN') as string, - ...getTldrawClientConfig(), + TLDRAW_ADMIN_API_CLIENT_BASE_URL: Configuration.get('TLDRAW_ADMIN_API_CLIENT__BASE_URL') as string, + TLDRAW_ADMIN_API_CLIENT_API_KEY: Configuration.get('TLDRAW_ADMIN_API_CLIENT__API_KEY') as string, }; export const config = () => boardCollaborationConfig; diff --git a/apps/server/src/modules/board/board.module.ts b/apps/server/src/modules/board/board.module.ts index e8d703431bd..03f8dca192d 100644 --- a/apps/server/src/modules/board/board.module.ts +++ b/apps/server/src/modules/board/board.module.ts @@ -1,7 +1,8 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { TldrawClientModule } from '@infra/tldraw-client'; import { CollaborativeTextEditorModule } from '@modules/collaborative-text-editor'; import { CopyHelperModule } from '@modules/copy-helper'; import { FilesStorageClientModule } from '@modules/files-storage-client'; -import { TldrawClientModule } from '@modules/tldraw-client'; import { ContextExternalToolModule } from '@modules/tool/context-external-tool'; import { UserModule } from '@modules/user'; import { HttpModule } from '@nestjs/axios'; @@ -42,8 +43,11 @@ import { UserModule, ContextExternalToolModule, HttpModule, - TldrawClientModule, CqrsModule, + TldrawClientModule.register({ + TLDRAW_ADMIN_API_CLIENT_BASE_URL: Configuration.get('TLDRAW_ADMIN_API_CLIENT__BASE_URL') as string, + TLDRAW_ADMIN_API_CLIENT_API_KEY: Configuration.get('TLDRAW_ADMIN_API_CLIENT__API_KEY') as string, + }), CollaborativeTextEditorModule, AuthorizationModule, RoomMembershipModule, diff --git a/apps/server/src/modules/board/controller/api-test/board-copy-in-room.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-copy-in-room.api.spec.ts index d833eb510b2..5e02e65be08 100644 --- a/apps/server/src/modules/board/controller/api-test/board-copy-in-room.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/board-copy-in-room.api.spec.ts @@ -6,6 +6,7 @@ import { cleanupCollections, groupEntityFactory, roleFactory, + schoolEntityFactory, TestApiClient, UserAndAccountTestFactory, } from '@shared/testing'; @@ -49,12 +50,17 @@ describe(`board copy with room relation (api)`, () => { name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_EDIT], }); - const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const school = schoolEntityFactory.buildWithId(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); const userGroup = groupEntityFactory.buildWithId({ type: GroupEntityTypes.ROOM, users: [{ role, user: teacherUser }], }); - const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + schoolId: teacherUser.school.id, + }); const columnBoardNode = columnBoardEntityFactory.build({ ...columnBoardProps, context: { id: room.id, type: BoardExternalReferenceType.Room }, diff --git a/apps/server/src/modules/board/controller/api-test/board-create-in-room.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-create-in-room.api.spec.ts index bb10e3fb33c..bb439583707 100644 --- a/apps/server/src/modules/board/controller/api-test/board-create-in-room.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/board-create-in-room.api.spec.ts @@ -4,7 +4,14 @@ import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; import { RoleName } from '@shared/domain/interface/rolename.enum'; -import { cleanupCollections, groupEntityFactory, roleFactory, TestApiClient, userFactory } from '@shared/testing'; +import { + cleanupCollections, + groupEntityFactory, + roleFactory, + schoolEntityFactory, + TestApiClient, + userFactory, +} from '@shared/testing'; import { accountFactory } from '@src/modules/account/testing'; import { GroupEntityTypes } from '@src/modules/group/entity'; import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing'; @@ -42,7 +49,8 @@ describe(`create board in room (api)`, () => { describe('When request is valid', () => { describe('When user is allowed to edit the room', () => { const setup = async () => { - const user = userFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); + const user = userFactory.buildWithId({ school }); const account = accountFactory.withUser(user).build(); const role = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_EDIT] }); @@ -52,9 +60,13 @@ describe(`create board in room (api)`, () => { users: [{ user, role }], }); - const room = roomEntityFactory.buildWithId(); + const room = roomEntityFactory.buildWithId({ schoolId: user.school.id }); - const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + schoolId: user.school.id, + }); await em.persistAndFlush([account, user, role, userGroup, room, roomMembership]); em.clear(); diff --git a/apps/server/src/modules/board/controller/api-test/content-element-delete.api.spec.ts b/apps/server/src/modules/board/controller/api-test/content-element-delete.api.spec.ts index 8f4619fa0b0..14a6484daa7 100644 --- a/apps/server/src/modules/board/controller/api-test/content-element-delete.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/content-element-delete.api.spec.ts @@ -1,9 +1,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ICurrentUser, JwtAuthGuard } from '@infra/auth-guard'; +import { TldrawClientAdapter } from '@infra/tldraw-client'; import { EntityManager } from '@mikro-orm/mongodb'; import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; import { ServerTestModule } from '@modules/server/server.module'; -import { DrawingElementAdapterService } from '@modules/tldraw-client'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; @@ -46,7 +46,7 @@ describe(`content element delete (api)`, () => { let em: EntityManager; let currentUser: ICurrentUser; let filesStorageClientAdapterService: DeepMocked; - let drawingElementAdapterService: DeepMocked; + let drawingElementAdapterService: DeepMocked; let api: API; beforeAll(async () => { @@ -55,8 +55,8 @@ describe(`content element delete (api)`, () => { }) .overrideProvider(FilesStorageClientAdapterService) .useValue(createMock()) - .overrideProvider(DrawingElementAdapterService) - .useValue(createMock()) + .overrideProvider(TldrawClientAdapter) + .useValue(createMock()) .overrideGuard(JwtAuthGuard) .useValue({ canActivate(context: ExecutionContext) { @@ -71,7 +71,7 @@ describe(`content element delete (api)`, () => { await app.init(); em = module.get(EntityManager); filesStorageClientAdapterService = module.get(FilesStorageClientAdapterService); - drawingElementAdapterService = module.get(DrawingElementAdapterService); + drawingElementAdapterService = module.get(TldrawClientAdapter); api = new API(app); }); diff --git a/apps/server/src/modules/board/controller/card.controller.ts b/apps/server/src/modules/board/controller/card.controller.ts index 639a5175a3b..9ddd7190855 100644 --- a/apps/server/src/modules/board/controller/card.controller.ts +++ b/apps/server/src/modules/board/controller/card.controller.ts @@ -31,6 +31,7 @@ import { RenameBodyParams, RichTextElementResponse, SubmissionContainerElementResponse, + VideoConferenceElementResponse, } from './dto'; import { SetHeightBodyParams } from './dto/board/set-height.body.params'; import { CardResponseMapper, ContentElementResponseFactory } from './mapper'; @@ -124,7 +125,8 @@ export class CardController { RichTextElementResponse, SubmissionContainerElementResponse, DrawingElementResponse, - DeletedElementResponse + DeletedElementResponse, + VideoConferenceElementResponse ) @ApiResponse({ status: 201, @@ -137,6 +139,7 @@ export class CardController { { $ref: getSchemaPath(SubmissionContainerElementResponse) }, { $ref: getSchemaPath(DrawingElementResponse) }, { $ref: getSchemaPath(DeletedElementResponse) }, + { $ref: getSchemaPath(VideoConferenceElementResponse) }, ], }, }) diff --git a/apps/server/src/modules/board/controller/dto/card/card.response.ts b/apps/server/src/modules/board/controller/dto/card/card.response.ts index aa641fdd736..9e9be2b7262 100644 --- a/apps/server/src/modules/board/controller/dto/card/card.response.ts +++ b/apps/server/src/modules/board/controller/dto/card/card.response.ts @@ -10,6 +10,7 @@ import { LinkElementResponse, RichTextElementResponse, SubmissionContainerElementResponse, + VideoConferenceElementResponse, } from '../element'; import { TimestampsResponse } from '../timestamps.response'; import { VisibilitySettingsResponse } from './visibility-settings.response'; @@ -22,7 +23,8 @@ import { VisibilitySettingsResponse } from './visibility-settings.response'; DrawingElementResponse, SubmissionContainerElementResponse, CollaborativeTextEditorElementResponse, - DeletedElementResponse + DeletedElementResponse, + VideoConferenceElementResponse ) export class CardResponse { constructor({ id, title, height, elements, visibilitySettings, timestamps }: CardResponse) { @@ -58,6 +60,7 @@ export class CardResponse { { $ref: getSchemaPath(DrawingElementResponse) }, { $ref: getSchemaPath(CollaborativeTextEditorElementResponse) }, { $ref: getSchemaPath(DeletedElementResponse) }, + { $ref: getSchemaPath(VideoConferenceElementResponse) }, ], }, }) diff --git a/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts b/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts index dbe2adc1e01..24dacde8196 100644 --- a/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts +++ b/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts @@ -6,6 +6,7 @@ import { FileElementResponse } from './file-element.response'; import { LinkElementResponse } from './link-element.response'; import { RichTextElementResponse } from './rich-text-element.response'; import { SubmissionContainerElementResponse } from './submission-container-element.response'; +import { VideoConferenceElementResponse } from './video-conference-element.response'; export type AnyContentElementResponse = | FileElementResponse @@ -15,7 +16,8 @@ export type AnyContentElementResponse = | ExternalToolElementResponse | DrawingElementResponse | CollaborativeTextEditorElementResponse - | DeletedElementResponse; + | DeletedElementResponse + | VideoConferenceElementResponse; export const isFileElementResponse = (element: AnyContentElementResponse): element is FileElementResponse => element instanceof FileElementResponse; diff --git a/apps/server/src/modules/board/controller/dto/element/index.ts b/apps/server/src/modules/board/controller/dto/element/index.ts index 0a85fb2c699..3246aee6bed 100644 --- a/apps/server/src/modules/board/controller/dto/element/index.ts +++ b/apps/server/src/modules/board/controller/dto/element/index.ts @@ -8,4 +8,5 @@ export * from './link-element.response'; export * from './rich-text-element.response'; export * from './submission-container-element.response'; export * from './update-element-content.body.params'; +export * from './video-conference-element.response'; export * from './deleted-element.response'; diff --git a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts index efebfda510c..1a662dabd3c 100644 --- a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts +++ b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts @@ -136,13 +136,29 @@ export class ExternalToolElementContentBody extends ElementContentBody { content!: ExternalToolContentBody; } +export class VideoConferenceContentBody { + @IsString() + @ApiProperty() + title!: string; +} + +export class VideoConferenceElementContentBody extends ElementContentBody { + @ApiProperty({ type: ContentElementType.VIDEO_CONFERENCE }) + type!: ContentElementType.VIDEO_CONFERENCE; + + @ValidateNested() + @ApiProperty() + content!: VideoConferenceContentBody; +} + export type AnyElementContentBody = | FileContentBody | DrawingContentBody | LinkContentBody | RichTextContentBody | SubmissionContainerContentBody - | ExternalToolContentBody; + | ExternalToolContentBody + | VideoConferenceContentBody; export class UpdateElementContentBodyParams { @ValidateNested() @@ -156,6 +172,7 @@ export class UpdateElementContentBodyParams { { value: SubmissionContainerElementContentBody, name: ContentElementType.SUBMISSION_CONTAINER }, { value: ExternalToolElementContentBody, name: ContentElementType.EXTERNAL_TOOL }, { value: DrawingElementContentBody, name: ContentElementType.DRAWING }, + { value: VideoConferenceElementContentBody, name: ContentElementType.VIDEO_CONFERENCE }, ], }, keepDiscriminatorProperty: true, @@ -168,6 +185,7 @@ export class UpdateElementContentBodyParams { { $ref: getSchemaPath(SubmissionContainerElementContentBody) }, { $ref: getSchemaPath(ExternalToolElementContentBody) }, { $ref: getSchemaPath(DrawingElementContentBody) }, + { $ref: getSchemaPath(VideoConferenceElementContentBody) }, ], }) data!: @@ -176,5 +194,6 @@ export class UpdateElementContentBodyParams { | RichTextElementContentBody | SubmissionContainerElementContentBody | ExternalToolElementContentBody - | DrawingElementContentBody; + | DrawingElementContentBody + | VideoConferenceElementContentBody; } diff --git a/apps/server/src/modules/board/controller/dto/element/video-conference-element.response.ts b/apps/server/src/modules/board/controller/dto/element/video-conference-element.response.ts new file mode 100644 index 00000000000..8eb6495e710 --- /dev/null +++ b/apps/server/src/modules/board/controller/dto/element/video-conference-element.response.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ContentElementType } from '../../../domain'; +import { TimestampsResponse } from '../timestamps.response'; + +export class VideoConferenceElementContent { + constructor({ title }: VideoConferenceElementContent) { + this.title = title; + } + + @ApiProperty() + title: string; +} + +export class VideoConferenceElementResponse { + constructor({ id, content, timestamps, type }: VideoConferenceElementResponse) { + this.id = id; + this.timestamps = timestamps; + this.type = type; + this.content = content; + } + + @ApiProperty({ pattern: '[a-f0-9]{24}' }) + id: string; + + @ApiProperty({ enum: ContentElementType, enumName: 'ContentElementType' }) + type: ContentElementType.VIDEO_CONFERENCE; + + @ApiProperty() + timestamps: TimestampsResponse; + + @ApiProperty() + content: VideoConferenceElementContent; +} diff --git a/apps/server/src/modules/board/controller/element.controller.ts b/apps/server/src/modules/board/controller/element.controller.ts index a03bcea0126..0e7d8cf750a 100644 --- a/apps/server/src/modules/board/controller/element.controller.ts +++ b/apps/server/src/modules/board/controller/element.controller.ts @@ -35,6 +35,8 @@ import { SubmissionContainerElementResponse, SubmissionItemResponse, UpdateElementContentBodyParams, + VideoConferenceElementContentBody, + VideoConferenceElementResponse, } from './dto'; import { ContentElementResponseFactory, SubmissionItemResponseMapper } from './mapper'; @@ -71,7 +73,8 @@ export class ElementController { SubmissionContainerElementContentBody, ExternalToolElementContentBody, LinkElementContentBody, - DrawingElementContentBody + DrawingElementContentBody, + VideoConferenceElementContentBody ) @ApiResponse({ status: 200, @@ -83,6 +86,7 @@ export class ElementController { { $ref: getSchemaPath(RichTextElementResponse) }, { $ref: getSchemaPath(SubmissionContainerElementResponse) }, { $ref: getSchemaPath(DrawingElementResponse) }, + { $ref: getSchemaPath(VideoConferenceElementResponse) }, ], }, }) diff --git a/apps/server/src/modules/board/controller/mapper/collaborative-text-editor-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/collaborative-text-editor-element-response.mapper.ts index d6dca93b18f..9b70877f522 100644 --- a/apps/server/src/modules/board/controller/mapper/collaborative-text-editor-element-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/collaborative-text-editor-element-response.mapper.ts @@ -25,7 +25,7 @@ export class CollaborativeTextEditorElementResponseMapper implements BaseRespons return result; } - canMap(element: CollaborativeTextEditorElement): boolean { + canMap(element: unknown): boolean { return element instanceof CollaborativeTextEditorElement; } } diff --git a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts index d5c4942a777..90237757717 100644 --- a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts +++ b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts @@ -6,6 +6,7 @@ import { linkElementFactory, richTextElementFactory, submissionContainerElementFactory, + videoConferenceElementFactory, } from '../../testing'; import { DeletedElementResponse, @@ -14,6 +15,7 @@ import { LinkElementResponse, RichTextElementResponse, SubmissionContainerElementResponse, + VideoConferenceElementResponse, } from '../dto'; import { ContentElementResponseFactory } from './content-element-response.factory'; @@ -65,6 +67,14 @@ describe(ContentElementResponseFactory.name, () => { expect(result).toBeInstanceOf(DeletedElementResponse); }); + it('should return instance of VideoConferenceElementResponse', () => { + const videoConferenceElement = videoConferenceElementFactory.build(); + + const result = ContentElementResponseFactory.mapToResponse(videoConferenceElement); + + expect(result).toBeInstanceOf(VideoConferenceElementResponse); + }); + it('should throw NotImplementedException', () => { // @ts-expect-error check unknown type expect(() => ContentElementResponseFactory.mapToResponse('UNKNOWN')).toThrow(NotImplementedException); diff --git a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts index dec7e12420e..c2eafa55de8 100644 --- a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts +++ b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts @@ -16,6 +16,7 @@ import { FileElementResponseMapper } from './file-element-response.mapper'; import { LinkElementResponseMapper } from './link-element-response.mapper'; import { RichTextElementResponseMapper } from './rich-text-element-response.mapper'; import { SubmissionContainerElementResponseMapper } from './submission-container-element-response.mapper'; +import { VideoConferenceElementResponseMapper } from './video-conference-element-response.mapper'; export class ContentElementResponseFactory { private static mappers: BaseResponseMapper[] = [ @@ -27,6 +28,7 @@ export class ContentElementResponseFactory { ExternalToolElementResponseMapper.getInstance(), CollaborativeTextEditorElementResponseMapper.getInstance(), DeletedElementResponseMapper.getInstance(), + VideoConferenceElementResponseMapper.getInstance(), ]; static mapToResponse(element: AnyBoardNode): AnyContentElementResponse { diff --git a/apps/server/src/modules/board/controller/mapper/drawing-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/drawing-element-response.mapper.ts index 089740fe731..1829f4d5cd4 100644 --- a/apps/server/src/modules/board/controller/mapper/drawing-element-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/drawing-element-response.mapper.ts @@ -25,7 +25,7 @@ export class DrawingElementResponseMapper implements BaseResponseMapper { return result; } - canMap(element: DrawingElement): boolean { + canMap(element: unknown): boolean { return element instanceof DrawingElement; } } diff --git a/apps/server/src/modules/board/controller/mapper/external-tool-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/external-tool-element-response.mapper.ts index 226eb65f907..36dd6706ab3 100644 --- a/apps/server/src/modules/board/controller/mapper/external-tool-element-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/external-tool-element-response.mapper.ts @@ -24,7 +24,7 @@ export class ExternalToolElementResponseMapper implements BaseResponseMapper { return result; } - canMap(element: ExternalToolElement): boolean { + canMap(element: unknown): boolean { return element instanceof ExternalToolElement; } } diff --git a/apps/server/src/modules/board/controller/mapper/file-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/file-element-response.mapper.ts index 6bf2eb5d8da..778a48cbbe1 100644 --- a/apps/server/src/modules/board/controller/mapper/file-element-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/file-element-response.mapper.ts @@ -24,7 +24,7 @@ export class FileElementResponseMapper implements BaseResponseMapper { return result; } - canMap(element: FileElement): boolean { + canMap(element: unknown): boolean { return element instanceof FileElement; } } diff --git a/apps/server/src/modules/board/controller/mapper/index.ts b/apps/server/src/modules/board/controller/mapper/index.ts index 980c5be1e45..318260d4276 100644 --- a/apps/server/src/modules/board/controller/mapper/index.ts +++ b/apps/server/src/modules/board/controller/mapper/index.ts @@ -10,4 +10,5 @@ export * from './link-element-response.mapper'; export * from './rich-text-element-response.mapper'; export * from './submission-container-element-response.mapper'; export * from './submission-item-response.mapper'; +export * from './video-conference-element-response.mapper'; export * from './deleted-element-response.mapper'; diff --git a/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts index 3c3cb0cce0b..cde8ef1372f 100644 --- a/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts @@ -30,7 +30,7 @@ export class LinkElementResponseMapper implements BaseResponseMapper { return result; } - canMap(element: LinkElement): boolean { + canMap(element: unknown): boolean { return element instanceof LinkElement; } } diff --git a/apps/server/src/modules/board/controller/mapper/rich-text-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/rich-text-element-response.mapper.ts index c845bc63346..bb9ab6877ca 100644 --- a/apps/server/src/modules/board/controller/mapper/rich-text-element-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/rich-text-element-response.mapper.ts @@ -25,7 +25,7 @@ export class RichTextElementResponseMapper implements BaseResponseMapper { return result; } - canMap(element: RichTextElement): boolean { + canMap(element: unknown): boolean { return element instanceof RichTextElement; } } diff --git a/apps/server/src/modules/board/controller/mapper/submission-container-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/submission-container-element-response.mapper.ts index b65a1b5654e..6ef2a91e601 100644 --- a/apps/server/src/modules/board/controller/mapper/submission-container-element-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/submission-container-element-response.mapper.ts @@ -30,7 +30,7 @@ export class SubmissionContainerElementResponseMapper implements BaseResponseMap return result; } - canMap(element: SubmissionContainerElement): boolean { + canMap(element: unknown): boolean { return element instanceof SubmissionContainerElement; } } diff --git a/apps/server/src/modules/board/controller/mapper/video-conference-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/video-conference-element-response.mapper.ts new file mode 100644 index 00000000000..1f02aefb4d0 --- /dev/null +++ b/apps/server/src/modules/board/controller/mapper/video-conference-element-response.mapper.ts @@ -0,0 +1,30 @@ +import { ContentElementType, VideoConferenceElement } from '../../domain'; +import { TimestampsResponse, VideoConferenceElementContent, VideoConferenceElementResponse } from '../dto'; +import { BaseResponseMapper } from './base-mapper.interface'; + +export class VideoConferenceElementResponseMapper implements BaseResponseMapper { + private static instance: VideoConferenceElementResponseMapper; + + public static getInstance(): VideoConferenceElementResponseMapper { + if (!VideoConferenceElementResponseMapper.instance) { + VideoConferenceElementResponseMapper.instance = new VideoConferenceElementResponseMapper(); + } + + return VideoConferenceElementResponseMapper.instance; + } + + mapToResponse(element: VideoConferenceElement): VideoConferenceElementResponse { + const result = new VideoConferenceElementResponse({ + id: element.id, + timestamps: new TimestampsResponse({ lastUpdatedAt: element.updatedAt, createdAt: element.createdAt }), + type: ContentElementType.VIDEO_CONFERENCE, + content: new VideoConferenceElementContent({ title: element.title }), + }); + + return result; + } + + canMap(element: unknown): boolean { + return element instanceof VideoConferenceElement; + } +} diff --git a/apps/server/src/modules/board/domain/board-node.factory.ts b/apps/server/src/modules/board/domain/board-node.factory.ts index ff59355567e..54a640f6104 100644 --- a/apps/server/src/modules/board/domain/board-node.factory.ts +++ b/apps/server/src/modules/board/domain/board-node.factory.ts @@ -15,6 +15,7 @@ import { SubmissionContainerElement } from './submission-container-element.do'; import { SubmissionItem } from './submission-item.do'; import { handleNonExhaustiveSwitch } from './type-mapping'; import { AnyContentElement, BoardExternalReference, BoardLayout, BoardNodeProps, ContentElementType } from './types'; +import { VideoConferenceElement } from './video-conference-element.do'; @Injectable() export class BoardNodeFactory { @@ -86,6 +87,12 @@ export class BoardNodeFactory { ...this.getBaseProps(), }); break; + case ContentElementType.VIDEO_CONFERENCE: + element = new VideoConferenceElement({ + ...this.getBaseProps(), + title: '', + }); + break; default: handleNonExhaustiveSwitch(type); } diff --git a/apps/server/src/modules/board/domain/index.ts b/apps/server/src/modules/board/domain/index.ts index a052556cf7f..e2c2cd5895c 100644 --- a/apps/server/src/modules/board/domain/index.ts +++ b/apps/server/src/modules/board/domain/index.ts @@ -16,4 +16,5 @@ export * from './submission-item.do'; export * from './path-utils'; export * from './types'; export * from './type-mapping'; +export * from './video-conference-element.do'; export * from './deleted-element.do'; diff --git a/apps/server/src/modules/board/domain/type-mapping.spec.ts b/apps/server/src/modules/board/domain/type-mapping.spec.ts index 55c1917f681..b01fcd2cdd3 100644 --- a/apps/server/src/modules/board/domain/type-mapping.spec.ts +++ b/apps/server/src/modules/board/domain/type-mapping.spec.ts @@ -17,6 +17,7 @@ import { richTextElementFactory, submissionContainerElementFactory, submissionItemFactory, + videoConferenceElementFactory, } from '../testing'; describe('getBoardNodeType', () => { @@ -37,6 +38,7 @@ describe('getBoardNodeType', () => { BoardNodeType.SUBMISSION_CONTAINER_ELEMENT ); expect(getBoardNodeType(submissionItemFactory.build())).toBe(BoardNodeType.SUBMISSION_ITEM); + expect(getBoardNodeType(videoConferenceElementFactory.build())).toBe(BoardNodeType.VIDEO_CONFERENCE_ELEMENT); }); it('should throw error for unknown type', () => { diff --git a/apps/server/src/modules/board/domain/type-mapping.ts b/apps/server/src/modules/board/domain/type-mapping.ts index a4a3b08ab8e..c22feec4d40 100644 --- a/apps/server/src/modules/board/domain/type-mapping.ts +++ b/apps/server/src/modules/board/domain/type-mapping.ts @@ -14,6 +14,7 @@ import { SubmissionContainerElement } from './submission-container-element.do'; import { SubmissionItem } from './submission-item.do'; import type { AnyBoardNode } from './types/any-board-node'; import { BoardNodeType } from './types/board-node-type.enum'; +import { VideoConferenceElement } from './video-conference-element.do'; // register node types const BoardNodeTypeToConstructor = { @@ -31,6 +32,7 @@ const BoardNodeTypeToConstructor = { [BoardNodeType.RICH_TEXT_ELEMENT]: RichTextElement, [BoardNodeType.SUBMISSION_CONTAINER_ELEMENT]: SubmissionContainerElement, [BoardNodeType.SUBMISSION_ITEM]: SubmissionItem, + [BoardNodeType.VIDEO_CONFERENCE_ELEMENT]: VideoConferenceElement, [BoardNodeType.DELETED_ELEMENT]: DeletedElement, } as const; diff --git a/apps/server/src/modules/board/domain/types/any-content-element.ts b/apps/server/src/modules/board/domain/types/any-content-element.ts index c8f6cae8bb3..e48b7c965bf 100644 --- a/apps/server/src/modules/board/domain/types/any-content-element.ts +++ b/apps/server/src/modules/board/domain/types/any-content-element.ts @@ -6,6 +6,7 @@ import { type FileElement, isFileElement } from '../file-element.do'; import { isLinkElement, type LinkElement } from '../link-element.do'; import { isRichTextElement, type RichTextElement } from '../rich-text-element.do'; import { isSubmissionContainerElement, type SubmissionContainerElement } from '../submission-container-element.do'; +import { isVideoConferenceElement, VideoConferenceElement } from '../video-conference-element.do'; import { type AnyBoardNode } from './any-board-node'; export type AnyContentElement = @@ -16,7 +17,8 @@ export type AnyContentElement = | LinkElement | RichTextElement | SubmissionContainerElement - | DeletedElement; + | DeletedElement + | VideoConferenceElement; export const isContentElement = (boardNode: AnyBoardNode): boardNode is AnyContentElement => { const result = @@ -27,7 +29,8 @@ export const isContentElement = (boardNode: AnyBoardNode): boardNode is AnyConte isLinkElement(boardNode) || isRichTextElement(boardNode) || isSubmissionContainerElement(boardNode) || - isDeletedElement(boardNode); + isDeletedElement(boardNode) || + isVideoConferenceElement(boardNode); return result; }; diff --git a/apps/server/src/modules/board/domain/types/board-node-props.ts b/apps/server/src/modules/board/domain/types/board-node-props.ts index 492584f77de..1c681a84135 100644 --- a/apps/server/src/modules/board/domain/types/board-node-props.ts +++ b/apps/server/src/modules/board/domain/types/board-node-props.ts @@ -67,6 +67,10 @@ export interface SubmissionItemProps extends BoardNodeProps { userId: EntityId; } +export interface VideoConferenceElementProps extends BoardNodeProps { + title: string; +} + export interface DeletedElementProps extends BoardNodeProps { title: string; deletedElementType: ContentElementType; @@ -105,4 +109,5 @@ export type AnyBoardNodeProps = | RichTextElementProps | SubmissionContainerElementProps | SubmissionItemProps + | VideoConferenceElementProps | MediaBoardNodeProps; diff --git a/apps/server/src/modules/board/domain/types/board-node-type.enum.ts b/apps/server/src/modules/board/domain/types/board-node-type.enum.ts index 71523c41749..af924b3cecb 100644 --- a/apps/server/src/modules/board/domain/types/board-node-type.enum.ts +++ b/apps/server/src/modules/board/domain/types/board-node-type.enum.ts @@ -11,6 +11,7 @@ export enum BoardNodeType { EXTERNAL_TOOL = 'external-tool', COLLABORATIVE_TEXT_EDITOR = 'collaborative-text-editor', DELETED_ELEMENT = 'deleted-element', + VIDEO_CONFERENCE_ELEMENT = 'video-conference-element', MEDIA_BOARD = 'media-board', MEDIA_LINE = 'media-line', diff --git a/apps/server/src/modules/board/domain/types/content-element-type.enum.ts b/apps/server/src/modules/board/domain/types/content-element-type.enum.ts index 773c63fdbe9..57bae8eb996 100644 --- a/apps/server/src/modules/board/domain/types/content-element-type.enum.ts +++ b/apps/server/src/modules/board/domain/types/content-element-type.enum.ts @@ -6,5 +6,6 @@ export enum ContentElementType { SUBMISSION_CONTAINER = 'submissionContainer', EXTERNAL_TOOL = 'externalTool', COLLABORATIVE_TEXT_EDITOR = 'collaborativeTextEditor', + VIDEO_CONFERENCE = 'videoConference', DELETED = 'deleted', } diff --git a/apps/server/src/modules/board/domain/video-conference-element.do.spec.ts b/apps/server/src/modules/board/domain/video-conference-element.do.spec.ts new file mode 100644 index 00000000000..dfe7fb324ee --- /dev/null +++ b/apps/server/src/modules/board/domain/video-conference-element.do.spec.ts @@ -0,0 +1,44 @@ +import { VideoConferenceElement, isVideoConferenceElement } from './video-conference-element.do'; +import { BoardNodeProps } from './types/board-node-props'; + +describe('VideoConferenceElement', () => { + let videoConferenceElement: VideoConferenceElement; + + const boardNodeProps: BoardNodeProps = { + id: '1', + path: '', + level: 1, + position: 1, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(() => { + videoConferenceElement = new VideoConferenceElement({ + ...boardNodeProps, + title: 'Example', + }); + }); + + it('should be instance of VideoConferenceElement', () => { + expect(isVideoConferenceElement(videoConferenceElement)).toBe(true); + }); + + it('should not be instance of VideoConferenceElement', () => { + expect(isVideoConferenceElement({})).toBe(false); + }); + + it('should return title', () => { + expect(videoConferenceElement.title).toBe('Example'); + }); + + it('should set title', () => { + videoConferenceElement.title = 'New title'; + expect(videoConferenceElement.title).toBe('New title'); + }); + + it('should not have child', () => { + expect(videoConferenceElement.canHaveChild()).toBe(false); + }); +}); diff --git a/apps/server/src/modules/board/domain/video-conference-element.do.ts b/apps/server/src/modules/board/domain/video-conference-element.do.ts new file mode 100644 index 00000000000..de8a5b714b9 --- /dev/null +++ b/apps/server/src/modules/board/domain/video-conference-element.do.ts @@ -0,0 +1,19 @@ +import { BoardNode } from './board-node.do'; +import type { VideoConferenceElementProps } from './types'; + +export class VideoConferenceElement extends BoardNode { + get title(): string { + return this.props.title; + } + + set title(value: string) { + this.props.title = value; + } + + canHaveChild(): boolean { + return false; + } +} + +export const isVideoConferenceElement = (reference: unknown): reference is VideoConferenceElement => + reference instanceof VideoConferenceElement; diff --git a/apps/server/src/modules/board/service/internal/board-context.service.spec.ts b/apps/server/src/modules/board/service/internal/board-context.service.spec.ts index 489c09f8a8f..322d9ea4361 100644 --- a/apps/server/src/modules/board/service/internal/board-context.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/board-context.service.spec.ts @@ -236,6 +236,7 @@ describe(BoardContextService.name, () => { id: 'foo', roomId: columnBoard.context.id, members: [{ userId: user.id, roles: [role] }], + schoolId: user.school.id, }); const result = await service.getUsersWithBoardRoles(columnBoard); @@ -271,6 +272,7 @@ describe(BoardContextService.name, () => { id: 'foo', roomId: columnBoard.context.id, members: [{ userId: user.id, roles: [role] }], + schoolId: user.school.id, }); const result = await service.getUsersWithBoardRoles(columnBoard); @@ -306,6 +308,7 @@ describe(BoardContextService.name, () => { id: 'foo', roomId: columnBoard.context.id, members: [{ userId: user.id, roles: [role] }], + schoolId: user.school.id, }); const result = await service.getUsersWithBoardRoles(columnBoard); diff --git a/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts b/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts index 2a3b6b4c8f1..3d6b27b7ec5 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts @@ -25,6 +25,7 @@ import { richTextElementFactory, submissionContainerElementFactory, submissionItemFactory, + videoConferenceElementFactory, } from '../../testing'; import { BoardNodeCopyContext, BoardNodeCopyContextProps } from './board-node-copy-context'; import { BoardNodeCopyService } from './board-node-copy.service'; @@ -100,6 +101,7 @@ describe(BoardNodeCopyService.name, () => { jest.spyOn(service, 'copyMediaLine').mockResolvedValue(mockStatus); jest.spyOn(service, 'copyMediaExternalToolElement').mockResolvedValue(mockStatus); jest.spyOn(service, 'copyDeletedElement').mockResolvedValue(mockStatus); + jest.spyOn(service, 'copyVideoConferenceElement').mockResolvedValue(mockStatus); return { copyContext, mockStatus }; }; @@ -283,6 +285,18 @@ describe(BoardNodeCopyService.name, () => { expect(result).toEqual(mockStatus); }); }); + + describe('when called with video conference element', () => { + it('should copy deleted element', async () => { + const { copyContext, mockStatus } = setup(); + const node = videoConferenceElementFactory.build(); + + const result = await service.copy(node, copyContext); + + expect(service.copyVideoConferenceElement).toHaveBeenCalledWith(node, copyContext); + expect(result).toEqual(mockStatus); + }); + }); }); }); }); diff --git a/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts b/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts index a2400688f7b..80ce46a5082 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts @@ -42,6 +42,7 @@ import { richTextElementFactory, submissionContainerElementFactory, submissionItemFactory, + videoConferenceElementFactory, } from '../../testing'; import { BoardNodeCopyContext, BoardNodeCopyContextProps } from './board-node-copy-context'; import { BoardNodeCopyService } from './board-node-copy.service'; @@ -676,4 +677,29 @@ describe(BoardNodeCopyService.name, () => { expect(result.copyEntity).toBeInstanceOf(DeletedElement); }); }); + + describe('copy video conference element', () => { + const setup = () => { + const { copyContext } = setupContext(); + const videoConferenceElement = videoConferenceElementFactory.build(); + + return { + copyContext, + videoConferenceElement, + }; + }; + + it('should copy the node', async () => { + const { copyContext, videoConferenceElement } = setup(); + + const result = await service.copyVideoConferenceElement(videoConferenceElement, copyContext); + + const expectedStatus: CopyStatus = { + type: CopyElementType.VIDEO_CONFERENCE_ELEMENT, + status: CopyStatusEnum.NOT_DOING, + }; + + expect(result).toEqual(expectedStatus); + }); + }); }); diff --git a/apps/server/src/modules/board/service/internal/board-node-copy.service.ts b/apps/server/src/modules/board/service/internal/board-node-copy.service.ts index 1f8fcc1e4a6..5c30de96d48 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy.service.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy.service.ts @@ -28,6 +28,7 @@ import { RichTextElement, SubmissionContainerElement, SubmissionItem, + VideoConferenceElement, } from '../../domain'; export interface CopyContext { @@ -82,6 +83,9 @@ export class BoardNodeCopyService { case BoardNodeType.COLLABORATIVE_TEXT_EDITOR: result = await this.copyCollaborativeTextEditorElement(boardNode as CollaborativeTextEditorElement, context); break; + case BoardNodeType.VIDEO_CONFERENCE_ELEMENT: + result = await this.copyVideoConferenceElement(boardNode as VideoConferenceElement, context); + break; case BoardNodeType.DELETED_ELEMENT: result = await this.copyDeletedElement(boardNode as DeletedElement, context); break; @@ -362,6 +366,16 @@ export class BoardNodeCopyService { return Promise.resolve(result); } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async copyVideoConferenceElement(original: VideoConferenceElement, context: CopyContext): Promise { + const result: CopyStatus = { + type: CopyElementType.VIDEO_CONFERENCE_ELEMENT, + status: CopyStatusEnum.NOT_DOING, + }; + + return Promise.resolve(result); + } + async copyMediaBoard(original: MediaBoard, context: CopyContext): Promise { const childrenResults = await this.copyChildrenOf(original, context); diff --git a/apps/server/src/modules/board/service/internal/board-node-delete-hooks.service.spec.ts b/apps/server/src/modules/board/service/internal/board-node-delete-hooks.service.spec.ts index 7134dad1ad0..50105169fdf 100644 --- a/apps/server/src/modules/board/service/internal/board-node-delete-hooks.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/board-node-delete-hooks.service.spec.ts @@ -1,9 +1,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { TldrawClientAdapter } from '@infra/tldraw-client'; import { Test, TestingModule } from '@nestjs/testing'; import { setupEntities } from '@shared/testing'; import { CollaborativeTextEditorService } from '@src/modules/collaborative-text-editor'; import { FilesStorageClientAdapterService } from '@src/modules/files-storage-client'; -import { DrawingElementAdapterService } from '@src/modules/tldraw-client'; import { ContextExternalToolService } from '@src/modules/tool/context-external-tool'; import { contextExternalToolFactory } from '@src/modules/tool/context-external-tool/testing'; import { @@ -19,7 +19,7 @@ describe(BoardNodeDeleteHooksService.name, () => { let module: TestingModule; let service: BoardNodeDeleteHooksService; let filesStorageClientAdapterService: DeepMocked; - let drawingElementAdapterService: DeepMocked; + let drawingElementAdapterService: DeepMocked; let contextExternalToolService: DeepMocked; let collaborativeTextEditorService: CollaborativeTextEditorService; @@ -36,8 +36,8 @@ describe(BoardNodeDeleteHooksService.name, () => { useValue: createMock(), }, { - provide: DrawingElementAdapterService, - useValue: createMock(), + provide: TldrawClientAdapter, + useValue: createMock(), }, { provide: CollaborativeTextEditorService, @@ -48,7 +48,7 @@ describe(BoardNodeDeleteHooksService.name, () => { service = module.get(BoardNodeDeleteHooksService); filesStorageClientAdapterService = module.get(FilesStorageClientAdapterService); - drawingElementAdapterService = module.get(DrawingElementAdapterService); + drawingElementAdapterService = module.get(TldrawClientAdapter); contextExternalToolService = module.get(ContextExternalToolService); collaborativeTextEditorService = module.get(CollaborativeTextEditorService); diff --git a/apps/server/src/modules/board/service/internal/board-node-delete-hooks.service.ts b/apps/server/src/modules/board/service/internal/board-node-delete-hooks.service.ts index bb10e6e579e..bad86d10631 100644 --- a/apps/server/src/modules/board/service/internal/board-node-delete-hooks.service.ts +++ b/apps/server/src/modules/board/service/internal/board-node-delete-hooks.service.ts @@ -1,7 +1,7 @@ +import { TldrawClientAdapter } from '@infra/tldraw-client'; import { Utils } from '@mikro-orm/core'; import { CollaborativeTextEditorService } from '@modules/collaborative-text-editor'; import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; -import { DrawingElementAdapterService } from '@modules/tldraw-client'; import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; import { Injectable } from '@nestjs/common'; import { @@ -25,14 +25,14 @@ export class BoardNodeDeleteHooksService { constructor( private readonly filesStorageClientAdapterService: FilesStorageClientAdapterService, private readonly contextExternalToolService: ContextExternalToolService, - private readonly drawingElementAdapterService: DrawingElementAdapterService, + private readonly drawingElementAdapterService: TldrawClientAdapter, private readonly collaborativeTextEditorService: CollaborativeTextEditorService ) {} async afterDelete(boardNode: AnyBoardNode | AnyBoardNode[]): Promise { const boardNodes = Utils.asArray(boardNode); - await Promise.allSettled(boardNodes.map(async (bn) => this.singleAfterDelete(bn))); + await Promise.all(boardNodes.map(async (bn) => this.singleAfterDelete(bn))); } private async singleAfterDelete(boardNode: AnyBoardNode): Promise { diff --git a/apps/server/src/modules/board/service/internal/content-element-update.service.spec.ts b/apps/server/src/modules/board/service/internal/content-element-update.service.spec.ts index db30e2e7322..105d2726db2 100644 --- a/apps/server/src/modules/board/service/internal/content-element-update.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/content-element-update.service.spec.ts @@ -8,6 +8,7 @@ import { LinkContentBody, RichTextContentBody, SubmissionContainerContentBody, + VideoConferenceContentBody, } from '../../controller/dto'; import { BoardNodeRepo } from '../../repo'; import { @@ -17,6 +18,7 @@ import { linkElementFactory, richTextElementFactory, submissionContainerElementFactory, + videoConferenceElementFactory, } from '../../testing'; import { ContentElementUpdateService } from './content-element-update.service'; @@ -124,6 +126,17 @@ describe('ContentElementUpdateService', () => { expect(repo.save).toHaveBeenCalledWith(element); }); + it('should update VideoConferenceElement', async () => { + const element = videoConferenceElementFactory.build(); + const content = new VideoConferenceContentBody(); + content.title = 'vc title'; + + await service.updateContent(element, content); + + expect(element.title).toBe('vc title'); + expect(repo.save).toHaveBeenCalledWith(element); + }); + it('should throw error for unknown element type', async () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const element = {} as any; diff --git a/apps/server/src/modules/board/service/internal/content-element-update.service.ts b/apps/server/src/modules/board/service/internal/content-element-update.service.ts index 085078f9696..4fe1d2c4eea 100644 --- a/apps/server/src/modules/board/service/internal/content-element-update.service.ts +++ b/apps/server/src/modules/board/service/internal/content-element-update.service.ts @@ -9,6 +9,7 @@ import { LinkContentBody, RichTextContentBody, SubmissionContainerContentBody, + VideoConferenceContentBody, } from '../../controller/dto'; import { AnyContentElement, @@ -21,9 +22,11 @@ import { isLinkElement, isRichTextElement, isSubmissionContainerElement, + isVideoConferenceElement, LinkElement, RichTextElement, SubmissionContainerElement, + VideoConferenceElement, } from '../../domain'; import { BoardNodeRepo } from '../../repo'; @@ -45,6 +48,8 @@ export class ContentElementUpdateService { this.updateSubmissionContainerElement(element, content); } else if (isExternalToolElement(element) && content instanceof ExternalToolContentBody) { this.updateExternalToolElement(element, content); + } else if (isVideoConferenceElement(element) && content instanceof VideoConferenceContentBody) { + this.updateVideoConferenceElement(element, content); } else { throw new Error(`Cannot update element of type: '${element.constructor.name}'`); } @@ -95,4 +100,8 @@ export class ContentElementUpdateService { element.contextExternalToolId = content.contextExternalToolId; } } + + updateVideoConferenceElement(element: VideoConferenceElement, content: VideoConferenceContentBody): void { + element.title = content.title; + } } diff --git a/apps/server/src/modules/board/testing/entity/index.ts b/apps/server/src/modules/board/testing/entity/index.ts index d5cb04e7d62..5305c950360 100644 --- a/apps/server/src/modules/board/testing/entity/index.ts +++ b/apps/server/src/modules/board/testing/entity/index.ts @@ -12,3 +12,4 @@ export * from './media-line-entity.factory'; export * from './rich-text-element-entity.factory'; export * from './submission-container-element-entity.factory'; export * from './submission-item-entity.factory'; +export * from './video-conference-element-entity.factory'; diff --git a/apps/server/src/modules/board/testing/entity/video-conference-element-entity.factory.ts b/apps/server/src/modules/board/testing/entity/video-conference-element-entity.factory.ts new file mode 100644 index 00000000000..f744a89fa82 --- /dev/null +++ b/apps/server/src/modules/board/testing/entity/video-conference-element-entity.factory.ts @@ -0,0 +1,19 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BoardNodeEntityFactory, PropsWithType } from './board-node-entity.factory'; +import { BoardNodeType, ROOT_PATH, VideoConferenceElementProps } from '../../domain'; + +export const videoConferenceElementEntityFactory = BoardNodeEntityFactory.define< + PropsWithType +>(({ sequence }) => { + return { + id: new ObjectId().toHexString(), + path: ROOT_PATH, + level: 0, + title: `video conference element #${sequence}`, + position: 0, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + type: BoardNodeType.VIDEO_CONFERENCE_ELEMENT, + }; +}); diff --git a/apps/server/src/modules/board/testing/index.ts b/apps/server/src/modules/board/testing/index.ts index 898a9b9f965..9a1a17999d6 100644 --- a/apps/server/src/modules/board/testing/index.ts +++ b/apps/server/src/modules/board/testing/index.ts @@ -16,4 +16,5 @@ export * from './media-line.factory'; export * from './rich-text-element.factory'; export * from './submission-container-element.factory'; export * from './submission-item.factory'; +export * from './video-conference-element.factory'; export * from './deleted-element.factory'; diff --git a/apps/server/src/modules/board/testing/video-conference-element.factory.ts b/apps/server/src/modules/board/testing/video-conference-element.factory.ts new file mode 100644 index 00000000000..3a6e146ed3f --- /dev/null +++ b/apps/server/src/modules/board/testing/video-conference-element.factory.ts @@ -0,0 +1,19 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseFactory } from '@shared/testing'; +import { ROOT_PATH, VideoConferenceElement, VideoConferenceElementProps } from '../domain'; + +export const videoConferenceElementFactory = BaseFactory.define( + VideoConferenceElement, + ({ sequence }) => { + return { + id: new ObjectId().toHexString(), + path: ROOT_PATH, + level: 0, + position: 0, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + title: `video conference element #${sequence}`, + }; + } +); diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/.openapi-generator/FILES b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/.openapi-generator/FILES index 87cd4d4af4d..101580959b7 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/.openapi-generator/FILES +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/.openapi-generator/FILES @@ -35,3 +35,5 @@ models/submission-container-element-content.ts models/submission-container-element-response.ts models/timestamps-response.ts models/visibility-settings-response.ts +models/video-conference-element-content.ts +models/video-conference-element-response.ts diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/content-element-type.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/content-element-type.ts index 391ec522db3..29c4989aa62 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/content-element-type.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/content-element-type.ts @@ -5,33 +5,29 @@ * This is v3 of Schulcloud-Verbund-Software Server. Checkout /docs for v1. * * The version of the OpenAPI document: 3.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech * Do not edit the class manually. */ - - /** - * + * * @export * @enum {string} */ export const ContentElementType = { - FILE: 'file', - DRAWING: 'drawing', - LINK: 'link', - RICH_TEXT: 'richText', - SUBMISSION_CONTAINER: 'submissionContainer', - EXTERNAL_TOOL: 'externalTool', - COLLABORATIVE_TEXT_EDITOR: 'collaborativeTextEditor', - DELETED: 'deleted' + FILE: 'file', + DRAWING: 'drawing', + LINK: 'link', + RICH_TEXT: 'richText', + SUBMISSION_CONTAINER: 'submissionContainer', + EXTERNAL_TOOL: 'externalTool', + COLLABORATIVE_TEXT_EDITOR: 'collaborativeTextEditor', + DELETED: 'deleted', + VIDEO_CONFERENCE: 'videoConference', } as const; export type ContentElementType = typeof ContentElementType[keyof typeof ContentElementType]; - - - diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/index.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/index.ts index c68fe9f28ba..7100b3a7b72 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/index.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/index.ts @@ -24,4 +24,6 @@ export * from './set-height-body-params'; export * from './submission-container-element-content'; export * from './submission-container-element-response'; export * from './timestamps-response'; +export * from './video-conference-element-content'; +export * from './video-conference-element-response'; export * from './visibility-settings-response'; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/video-conference-element-content.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/video-conference-element-content.ts new file mode 100644 index 00000000000..b9a4bdc58d5 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/video-conference-element-content.ts @@ -0,0 +1,27 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * This is v3 of Schulcloud-Verbund-Software Server. Checkout /docs for v1. + * + * The version of the OpenAPI document: 3.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + * @interface VideoConferenceElementContent + */ +export interface VideoConferenceElementContent { + /** + * + * @type {string} + * @memberof VideoConferenceElementContent + */ + title: string; +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/video-conference-element-response.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/video-conference-element-response.ts new file mode 100644 index 00000000000..b2cab72ea79 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/video-conference-element-response.ts @@ -0,0 +1,55 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * This is v3 of Schulcloud-Verbund-Software Server. Checkout /docs for v1. + * + * The version of the OpenAPI document: 3.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +// May contain unused imports in some cases +// @ts-ignore +import type { ContentElementType } from './content-element-type'; +// May contain unused imports in some cases +// @ts-ignore +import type { VideoConferenceElementContent } from './video-conference-element-content'; +// May contain unused imports in some cases +// @ts-ignore +import type { TimestampsResponse } from './timestamps-response'; + +/** + * + * @export + * @interface VideoConferenceElementResponse + */ +export interface VideoConferenceElementResponse { + /** + * + * @type {string} + * @memberof VideoConferenceElementResponse + */ + id: string; + /** + * + * @type {ContentElementType} + * @memberof VideoConferenceElementResponse + */ + type: ContentElementType; + /** + * + * @type {TimestampsResponse} + * @memberof VideoConferenceElementResponse + */ + timestamps: TimestampsResponse; + /** + * + * @type {VideoConferenceElementContent} + * @memberof VideoConferenceElementResponse + */ + content: VideoConferenceElementContent; +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/card-list-response.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/card-list-response.dto.ts index ce011649d1b..0d71a967efa 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/card-list-response.dto.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/card-list-response.dto.ts @@ -1,7 +1,7 @@ import { CardResponseDto } from './card-response.dto'; export class CardListResponseDto { - data: CardResponseDto[]; + public data: CardResponseDto[]; constructor(data: CardResponseDto[]) { this.data = data; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/card-response.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/card-response.dto.ts index 2139dfcbd1e..c08d4f134ad 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/card-response.dto.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/card-response.dto.ts @@ -3,31 +3,24 @@ import { TimestampResponseDto } from './timestamp-response.dto'; import { VisibilitySettingsResponseDto } from './visibility-settings-response.dto'; export class CardResponseDto { - id: string; + public id: string; - title?: string; + public title?: string; - height: number; + public height: number; - elements: Array; + public elements: Array; - visibilitySettings: VisibilitySettingsResponseDto; + public visibilitySettings: VisibilitySettingsResponseDto; - timeStamps: TimestampResponseDto; + public timeStamps: TimestampResponseDto; - constructor( - id: string, - title: string, - height: number, - elements: CardResponseElementsInnerDto[], - visibilitySettings: VisibilitySettingsResponseDto, - timestamps: TimestampResponseDto - ) { - this.id = id; - this.title = title; - this.height = height; - this.elements = elements; - this.visibilitySettings = visibilitySettings; - this.timeStamps = timestamps; + constructor(props: Readonly) { + this.id = props.id; + this.title = props.title; + this.height = props.height; + this.elements = props.elements; + this.visibilitySettings = props.visibilitySettings; + this.timeStamps = props.timeStamps; } } diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/collaborative-text-editor-element-response.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/collaborative-text-editor-element-response.dto.ts index 7d864480759..b950d0f383d 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/collaborative-text-editor-element-response.dto.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/collaborative-text-editor-element-response.dto.ts @@ -2,13 +2,13 @@ import { ContentElementType } from '../cards-api-client'; import { TimestampResponseDto } from './timestamp-response.dto'; export class CollaborativeTextEditorElementResponseDto { - id: string; + public id: string; - type: ContentElementType; + public type: ContentElementType; - timestamps: TimestampResponseDto; + public timestamps: TimestampResponseDto; - content: object; + public content: object; constructor(id: string, type: ContentElementType, content: object, timestamps: TimestampResponseDto) { this.id = id; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/index.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/index.ts new file mode 100644 index 00000000000..64c051f06c7 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/index.ts @@ -0,0 +1,8 @@ +export { RichTextElementResponseDto } from './rich-text-element-response.dto'; +export { RichTextElementContentDto } from './rich-text-element-content.dto'; +export { LinkElementContentDto } from './link-element-content.dto'; +export { LinkElementResponseDto } from './link-element-response.dto'; +export { CardResponseDto } from './card-response.dto'; +export { CardListResponseDto } from './card-list-response.dto'; +export { TimestampResponseDto } from './timestamp-response.dto'; +export { VisibilitySettingsResponseDto } from './visibility-settings-response.dto'; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/link-element-content.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/link-element-content.dto.ts index 6b257248f30..1783f4b8def 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/link-element-content.dto.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/link-element-content.dto.ts @@ -1,16 +1,16 @@ export class LinkElementContentDto { - url: string; + public url: string; - title: string; + public title: string; - description?: string; + public description?: string; - imageUrl?: string; + public imageUrl?: string; - constructor(url: string, title: string, description: string, imageUrl: string) { - this.url = url; - this.title = title; - this.description = description; - this.imageUrl = imageUrl; + constructor(props: Readonly) { + this.url = props.url; + this.title = props.title; + this.description = props.description; + this.imageUrl = props.imageUrl; } } diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/link-element-response.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/link-element-response.dto.ts index 4c4fa40a48a..1e11e34450a 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/link-element-response.dto.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/link-element-response.dto.ts @@ -3,18 +3,22 @@ import { LinkElementContentDto } from './link-element-content.dto'; import { TimestampResponseDto } from './timestamp-response.dto'; export class LinkElementResponseDto { - id: string; + public id: string; - type: ContentElementType; + public type: ContentElementType; - content: LinkElementContentDto; + public content: LinkElementContentDto; - timestamps: TimestampResponseDto; + public timestamps: TimestampResponseDto; - constructor(id: string, type: ContentElementType, content: LinkElementContentDto, timestamps: TimestampResponseDto) { - this.id = id; - this.type = type; - this.content = content; - this.timestamps = timestamps; + constructor(props: LinkElementResponseDto) { + this.id = props.id; + this.type = props.type; + this.content = props.content; + this.timestamps = props.timestamps; + } + + public static isLinkElement(reference: unknown): reference is LinkElementResponseDto { + return reference instanceof LinkElementResponseDto; } } diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/rich-text-element-content.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/rich-text-element-content.dto.ts index 7726852b1dd..b5649341f87 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/rich-text-element-content.dto.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/rich-text-element-content.dto.ts @@ -1,7 +1,7 @@ export class RichTextElementContentDto { - text: string; + public text: string; - inputFormat: string; + public inputFormat: string; constructor(text: string, inputFormat: string) { this.text = text; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/rich-text-element-response.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/rich-text-element-response.dto.ts index b8e5c3811b5..162575ade32 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/rich-text-element-response.dto.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/rich-text-element-response.dto.ts @@ -3,23 +3,22 @@ import { RichTextElementContentDto } from './rich-text-element-content.dto'; import { TimestampResponseDto } from './timestamp-response.dto'; export class RichTextElementResponseDto { - id: string; + public id: string; - type: ContentElementType; + public type: ContentElementType; - content: RichTextElementContentDto; + public content: RichTextElementContentDto; - timestamps: TimestampResponseDto; + public timestamps: TimestampResponseDto; - constructor( - id: string, - type: ContentElementType, - content: RichTextElementContentDto, - timestamps: TimestampResponseDto - ) { - this.id = id; - this.type = type; - this.content = content; - this.timestamps = timestamps; + constructor(props: Readonly) { + this.id = props.id; + this.type = props.type; + this.content = props.content; + this.timestamps = props.timestamps; + } + + public static isRichTextElement(reference: unknown): reference is RichTextElementResponseDto { + return reference instanceof RichTextElementResponseDto; } } diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/timestamp-response.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/timestamp-response.dto.ts index a75998154b1..dcf83d2e696 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/timestamp-response.dto.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/timestamp-response.dto.ts @@ -1,13 +1,13 @@ export class TimestampResponseDto { - lastUpdatedAt: string; + public lastUpdatedAt: string; - createdAt: string; + public createdAt: string; - deletedAt?: string; + public deletedAt?: string; - constructor(lastUpdatedAt: string, createdAt: string, deletedAt: string) { - this.lastUpdatedAt = lastUpdatedAt; - this.createdAt = createdAt; - this.deletedAt = deletedAt; + constructor(props: Readonly) { + this.lastUpdatedAt = props.lastUpdatedAt; + this.createdAt = props.createdAt; + this.deletedAt = props.deletedAt; } } diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/video-conference-element-content.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/video-conference-element-content.dto.ts new file mode 100644 index 00000000000..9f909f21498 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/video-conference-element-content.dto.ts @@ -0,0 +1,7 @@ +export class VideoConferenceElementContentDto { + title: string; + + constructor(title: string) { + this.title = title; + } +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/video-conference-element-response.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/video-conference-element-response.dto.ts new file mode 100644 index 00000000000..83b387fafed --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/video-conference-element-response.dto.ts @@ -0,0 +1,25 @@ +import { ContentElementType } from '../enums/content-element-type.enum'; +import { TimestampResponseDto } from './timestamp-response.dto'; +import { VideoConferenceElementContentDto } from './video-conference-element-content.dto'; + +export class VideoConferenceElementResponseDto { + id: string; + + type: ContentElementType; + + timestamps: TimestampResponseDto; + + content: VideoConferenceElementContentDto; + + constructor( + id: string, + type: ContentElementType, + content: VideoConferenceElementContentDto, + timestamps: TimestampResponseDto + ) { + this.id = id; + this.type = type; + this.timestamps = timestamps; + this.content = content; + } +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/enums/content-element-type.enum.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/enums/content-element-type.enum.ts index 773c63fdbe9..83c424aa136 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/enums/content-element-type.enum.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/enums/content-element-type.enum.ts @@ -7,4 +7,5 @@ export enum ContentElementType { EXTERNAL_TOOL = 'externalTool', COLLABORATIVE_TEXT_EDITOR = 'collaborativeTextEditor', DELETED = 'deleted', + VIDEO_CONFERENCE = 'videoConference', } diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/mapper/card-response.mapper.spec.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/mapper/card-response.mapper.spec.ts index 29052e30911..b7662e27f22 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/mapper/card-response.mapper.spec.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/mapper/card-response.mapper.spec.ts @@ -11,6 +11,7 @@ import { RichTextElementResponse, CollaborativeTextEditorElementResponse, CardResponseElementsInner, + VideoConferenceElementResponse, } from '../cards-api-client'; import { ContentElementType } from '../enums/content-element-type.enum'; import { CardContentElementInner } from '../types/card-content-elements-inner.type'; @@ -99,6 +100,10 @@ describe('CardResponseMapper', () => { inputFormat: faker.internet.domainName(), }) as RichTextElementResponse, + createMockElement(faker.string.uuid(), ContentElementType.VIDEO_CONFERENCE, { + title: faker.lorem.word(), + }) as VideoConferenceElementResponse, + createMockElement(faker.string.uuid(), 'UNKNOWN_TYPE' as ContentElementType, {}) as CardResponseElementsInner, ]); @@ -113,7 +118,7 @@ describe('CardResponseMapper', () => { expect(cardResponseDto.height).toBe(100); expect(cardResponseDto.visibilitySettings.publishedAt).toBe('2024-10-03T12:00:00Z'); expect(cardResponseDto.timeStamps.lastUpdatedAt).toBe('2024-10-03T11:00:00Z'); - expect(cardResponseDto.elements).toHaveLength(8); + expect(cardResponseDto.elements).toHaveLength(9); expect(cardResponseDto.elements[0].type).toBe(ContentElementType.COLLABORATIVE_TEXT_EDITOR); expect(cardResponseDto.elements[1].type).toBe(ContentElementType.DELETED); expect(cardResponseDto.elements[2].type).toBe(ContentElementType.SUBMISSION_CONTAINER); @@ -122,6 +127,7 @@ describe('CardResponseMapper', () => { expect(cardResponseDto.elements[5].type).toBe(ContentElementType.FILE); expect(cardResponseDto.elements[6].type).toBe(ContentElementType.LINK); expect(cardResponseDto.elements[7].type).toBe(ContentElementType.RICH_TEXT); + expect(cardResponseDto.elements[8].type).toBe(ContentElementType.VIDEO_CONFERENCE); }); }); @@ -172,17 +178,5 @@ describe('CardResponseMapper', () => { expect(cardResponse.visibilitySettings.publishedAt).toBe(''); }); }); - - describe('when deletedAt in TimestampsResponse is null', () => { - const mockList: CardListResponse = setup([]); - mockList.data[0].timestamps.deletedAt = undefined; - - it('should return an empty string', () => { - const mapperResult = CardResponseMapper.mapToCardListResponseDto(mockList); - const cardResponse: CardResponseDto = mapperResult.data[0]; - - expect(cardResponse.timeStamps.deletedAt).toBe(''); - }); - }); }); }); diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/mapper/card-response.mapper.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/mapper/card-response.mapper.ts index ecf86629756..da13bb72a9d 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/mapper/card-response.mapper.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/mapper/card-response.mapper.ts @@ -11,6 +11,7 @@ import { FileElementContent, LinkElementContent, RichTextElementContent, + VideoConferenceElementContent, } from '../cards-api-client'; import { CardResponseDto } from '../dto/card-response.dto'; import { CollaborativeTextEditorElementResponseDto } from '../dto/collaborative-text-editor-element-response.dto'; @@ -33,6 +34,8 @@ import { VisibilitySettingsResponseDto } from '../dto/visibility-settings-respon import { TimestampResponseDto } from '../dto/timestamp-response.dto'; import { CardResponseElementsInnerDto } from '../types/card-response-elements-inner.type'; import { CardListResponseDto } from '../dto/card-list-response.dto'; +import { VideoConferenceElementResponseDto } from '../dto/video-conference-element-response.dto'; +import { VideoConferenceElementContentDto } from '../dto/video-conference-element-content.dto'; export class CardResponseMapper { public static mapToCardListResponseDto(cardListResponse: CardListResponse): CardListResponseDto { @@ -45,14 +48,14 @@ export class CardResponseMapper { } private static mapToCardResponseDto(cardResponse: CardResponse): CardResponseDto { - return new CardResponseDto( - cardResponse.id, - cardResponse.title ?? '', - cardResponse.height, - this.mapToCardResponseElementsInnerDto(cardResponse.elements), - this.mapToVisibilitySettingsDto(cardResponse.visibilitySettings), - this.mapToTimestampDto(cardResponse.timestamps) - ); + return new CardResponseDto({ + id: cardResponse.id, + title: cardResponse.title, + height: cardResponse.height, + elements: this.mapToCardResponseElementsInnerDto(cardResponse.elements), + visibilitySettings: this.mapToVisibilitySettingsDto(cardResponse.visibilitySettings), + timeStamps: this.mapToTimestampDto(cardResponse.timestamps), + }); } private static mapToCardResponseElementsInnerDto( @@ -135,22 +138,38 @@ export class CardResponseMapper { case ContentElementType.LINK: { const content: LinkElementContent = element.content as LinkElementContent; elements.push( - new LinkElementResponseDto( - element.id, - ContentElementType.LINK, - new LinkElementContentDto(content.url, content.title, content.description ?? '', content.imageUrl ?? ''), - this.mapToTimestampDto(element.timestamps) - ) + new LinkElementResponseDto({ + id: element.id, + type: ContentElementType.LINK, + content: new LinkElementContentDto({ + url: content.url, + title: content.title, + description: content.description, + }), + timestamps: this.mapToTimestampDto(element.timestamps), + }) ); break; } case ContentElementType.RICH_TEXT: { const content: RichTextElementContent = element.content as RichTextElementContent; elements.push( - new RichTextElementResponseDto( + new RichTextElementResponseDto({ + id: element.id, + type: ContentElementType.RICH_TEXT, + content: new RichTextElementContentDto(content.text, content.inputFormat), + timestamps: this.mapToTimestampDto(element.timestamps), + }) + ); + break; + } + case ContentElementType.VIDEO_CONFERENCE: { + const content: VideoConferenceElementContent = element.content as VideoConferenceElementContent; + elements.push( + new VideoConferenceElementResponseDto( element.id, - ContentElementType.RICH_TEXT, - new RichTextElementContentDto(content.text, content.inputFormat), + ContentElementType.VIDEO_CONFERENCE, + new VideoConferenceElementContentDto(content.title), this.mapToTimestampDto(element.timestamps) ) ); @@ -170,6 +189,10 @@ export class CardResponseMapper { } private static mapToTimestampDto(timestamp: TimestampsResponse): TimestampResponseDto { - return new TimestampResponseDto(timestamp.lastUpdatedAt, timestamp.createdAt, timestamp.deletedAt ?? ''); + return new TimestampResponseDto({ + lastUpdatedAt: timestamp.lastUpdatedAt, + createdAt: timestamp.createdAt, + deletedAt: timestamp.deletedAt, + }); } } diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/types/card-content-elements-inner.type.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/types/card-content-elements-inner.type.ts index b8a32bc9479..8bf6b91e95b 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/types/card-content-elements-inner.type.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/types/card-content-elements-inner.type.ts @@ -5,6 +5,7 @@ import { FileElementContentDto } from '../dto/file-element-content.dto'; import { LinkElementContentDto } from '../dto/link-element-content.dto'; import { RichTextElementContentDto } from '../dto/rich-text-element-content.dto'; import { SubmissionContainerElementContentDto } from '../dto/submission-container-element-content.dto'; +import { VideoConferenceElementContentDto } from '../dto/video-conference-element-content.dto'; export type CardContentElementInner = | LinkElementContentDto @@ -14,4 +15,5 @@ export type CardContentElementInner = | FileElementContentDto | RichTextElementContentDto | SubmissionContainerElementContentDto + | VideoConferenceElementContentDto | object; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/types/card-response-elements-inner.type.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/types/card-response-elements-inner.type.ts index 7b4c77dafd8..3ebb58fc88c 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/types/card-response-elements-inner.type.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/types/card-response-elements-inner.type.ts @@ -6,6 +6,7 @@ import { FileElementResponseDto } from '../dto/file-element-response.dto'; import { LinkElementResponseDto } from '../dto/link-element-response.dto'; import { RichTextElementResponseDto } from '../dto/rich-text-element-response.dto'; import { SubmissionContainerElementResponseDto } from '../dto/submission-container-element-response.dto'; +import { VideoConferenceElementResponseDto } from '../dto/video-conference-element-response.dto'; export type CardResponseElementsInnerDto = | CollaborativeTextEditorElementResponseDto @@ -15,4 +16,5 @@ export type CardResponseElementsInnerDto = | FileElementResponseDto | LinkElementResponseDto | RichTextElementResponseDto - | SubmissionContainerElementResponseDto; + | SubmissionContainerElementResponseDto + | VideoConferenceElementResponseDto; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.adapter.spec.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.adapter.spec.ts index 058a7517f98..abd0d86d4a7 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.adapter.spec.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.adapter.spec.ts @@ -74,7 +74,7 @@ describe(CoursesClientAdapter.name, () => { expect(coursesApi.courseControllerGetCourseCcMetadataById).toHaveBeenCalledWith(courseId, expectedOptions); expect(result.id).toBeDefined(); - expect(result.title).toBeDefined(); + expect(result.courseName).toBeDefined(); expect(result.creationDate).toBeDefined(); expect(result.copyRightOwners).toBeDefined(); }); diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.adapter.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.adapter.ts index 324f907f06f..5b6955bb36b 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.adapter.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/courses-client.adapter.ts @@ -15,7 +15,7 @@ export class CoursesClientAdapter { const response = await this.coursesApi.courseControllerGetCourseCcMetadataById(courseId, options); const courseCommonCartridgeMetadata: CourseCommonCartridgeMetadataDto = new CourseCommonCartridgeMetadataDto({ id: response.data.id, - title: response.data.title, + courseName: response.data.title, creationDate: response.data.creationDate, copyRightOwners: response.data.copyRightOwners, }); diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/dto/course-common-cartridge-metadata.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/dto/course-common-cartridge-metadata.dto.ts index 117963823ca..179bb62e7b4 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/dto/course-common-cartridge-metadata.dto.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/course-client/dto/course-common-cartridge-metadata.dto.ts @@ -1,7 +1,7 @@ export class CourseCommonCartridgeMetadataDto { id: string; - title: string; + courseName: string; creationDate?: string; @@ -9,7 +9,7 @@ export class CourseCommonCartridgeMetadataDto { constructor(props: CourseCommonCartridgeMetadataDto) { this.id = props.id; - this.title = props.title; + this.courseName = props.courseName; this.creationDate = props.creationDate; this.copyRightOwners = props.copyRightOwners; } diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/component-etherpad-props.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/component-etherpad-props.dto.ts new file mode 100644 index 00000000000..085dcb12af5 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/component-etherpad-props.dto.ts @@ -0,0 +1,13 @@ +export class ComponentEtherpadPropsDto { + public description: string; + + public title: string; + + public url: string; + + constructor(props: Readonly) { + this.description = props.description; + this.title = props.title; + this.url = props.url; + } +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/component-geogebra-props.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/component-geogebra-props.dto.ts new file mode 100644 index 00000000000..2688370b192 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/component-geogebra-props.dto.ts @@ -0,0 +1,9 @@ +import { ComponentGeogebraPropsImpl } from '../lessons-api-client'; + +export class ComponentGeogebraPropsDto { + public materialId: string; + + constructor(geogebraContent: ComponentGeogebraPropsImpl) { + this.materialId = geogebraContent.materialId; + } +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/component-internal-props.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/component-internal-props.dto.ts new file mode 100644 index 00000000000..d896caeee76 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/component-internal-props.dto.ts @@ -0,0 +1,9 @@ +import { ComponentInternalPropsImpl } from '../lessons-api-client'; + +export class ComponentInternalPropsDto { + public url: string; + + constructor(internalContent: ComponentInternalPropsImpl) { + this.url = internalContent.url; + } +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/component-lernstore-props.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/component-lernstore-props.dto.ts new file mode 100644 index 00000000000..10895739f98 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/component-lernstore-props.dto.ts @@ -0,0 +1,9 @@ +import { ComponentLernstorePropsImpl } from '../lessons-api-client'; + +export class ComponentLernstorePropsDto { + public resources: string[]; + + constructor(lernstoreContent: ComponentLernstorePropsImpl) { + this.resources = lernstoreContent.resources; + } +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/component-nexboard-props-dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/component-nexboard-props-dto.ts new file mode 100644 index 00000000000..7115667cffc --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/component-nexboard-props-dto.ts @@ -0,0 +1,18 @@ +import { ComponentNexboardPropsImpl } from '../lessons-api-client'; + +export class ComponentNexboardPropsDto { + public board: string; + + public description: string; + + public title: string; + + public url: string; + + constructor(nexboardContent: ComponentNexboardPropsImpl) { + this.board = nexboardContent.board; + this.description = nexboardContent.description; + this.title = nexboardContent.title; + this.url = nexboardContent.url; + } +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/component-text-props.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/component-text-props.dto.ts new file mode 100644 index 00000000000..8e5eb7f8493 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/component-text-props.dto.ts @@ -0,0 +1,9 @@ +import { ComponentTextPropsImpl } from '../lessons-api-client'; + +export class ComponentTextPropsDto { + public text: string; + + constructor(textContent: ComponentTextPropsImpl) { + this.text = textContent.text; + } +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/lesson-content-response-inner.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/lesson-content-response-inner.dto.ts new file mode 100644 index 00000000000..bebfcd784eb --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/lesson-content-response-inner.dto.ts @@ -0,0 +1,14 @@ +import { ComponentEtherpadPropsDto } from './component-etherpad-props.dto'; +import { ComponentGeogebraPropsDto } from './component-geogebra-props.dto'; +import { ComponentInternalPropsDto } from './component-internal-props.dto'; +import { ComponentLernstorePropsDto } from './component-lernstore-props.dto'; +import { ComponentNexboardPropsDto } from './component-nexboard-props-dto'; +import { ComponentTextPropsDto } from './component-text-props.dto'; + +export type LessonContentResponseContentInnerDto = + | ComponentEtherpadPropsDto + | ComponentGeogebraPropsDto + | ComponentInternalPropsDto + | ComponentLernstorePropsDto + | ComponentTextPropsDto + | ComponentNexboardPropsDto; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/lesson-contents.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/lesson-contents.dto.ts index e9f00a471d4..78e5e97e5d3 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/lesson-contents.dto.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/lesson-contents.dto.ts @@ -1,11 +1,15 @@ +import { LessonContentResponseContentInnerDto } from './lesson-content-response-inner.dto'; + export class LessonContentDto { - content: object; + public id: string | undefined; + + public content: LessonContentResponseContentInnerDto; - title: string; + public title: string; - component: LessonContentDtoComponent; + public component: LessonContentDtoComponent; - hidden: boolean; + public hidden: boolean; constructor(props: LessonContentDto) { this.content = props.content; @@ -22,6 +26,7 @@ export const LessonContentDtoComponentValues = { RESOURCES: 'resources', TEXT: 'text', NE_XBOARD: 'neXboard', + LERNSTORE: 'lernstore', } as const; export type LessonContentDtoComponent = diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/lesson.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/lesson.dto.ts index 864b6502f19..1385d6136e1 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/lesson.dto.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/dto/lesson.dto.ts @@ -1,22 +1,25 @@ import { LessonContentDto } from './lesson-contents.dto'; +import { LessonLinkedTaskDto } from './lesson-linked-task.dto'; import { LessonMaterialsDto } from './lesson-materials.dto'; export class LessonDto { - lessonId: string; + public lessonId: string; - name: string; + public name: string; - courseId?: string; + public courseId?: string; - courseGroupId?: string; + public courseGroupId?: string; - hidden: boolean; + public hidden: boolean; - position: number; + public position: number; - contents: LessonContentDto[]; + public contents: LessonContentDto[]; - materials: LessonMaterialsDto[]; + public materials: LessonMaterialsDto[]; + + public linkedTasks: LessonLinkedTaskDto[]; constructor(props: LessonDto) { this.lessonId = props.lessonId; @@ -27,5 +30,6 @@ export class LessonDto { this.position = props.position; this.contents = props.contents; this.materials = props.materials; + this.linkedTasks = props.linkedTasks; } } diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lesson-client.adapter.spec.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lesson-client.adapter.spec.ts index fef7a941220..50590bb8f59 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lesson-client.adapter.spec.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lesson-client.adapter.spec.ts @@ -52,10 +52,26 @@ describe(LessonClientAdapter.name, () => { describe('getLessonById', () => { describe('When getLessonById is called', () => { const setup = () => { + const lessonId = faker.string.uuid(); + const linkedTasks = createMock>({ + data: [ + { + name: faker.lorem.sentence(), + description: faker.lorem.sentence(), + descriptionInputFormat: faker.helpers.arrayElement(['plainText', 'richTextCk4', 'richTextCk5Simple']), + availableDate: faker.date.recent().toString(), + dueDate: faker.date.future().toString(), + private: faker.datatype.boolean(), + publicSubmissions: faker.datatype.boolean(), + teamSubmissions: faker.datatype.boolean(), + }, + ], + }); + const response = createMock>({ data: { _id: faker.string.uuid(), - id: faker.string.uuid(), + id: lessonId, name: faker.lorem.sentence(), courseId: faker.string.uuid(), courseGroupId: faker.string.uuid(), @@ -86,6 +102,7 @@ describe(LessonClientAdapter.name, () => { }, }); + lessonApiMock.lessonControllerGetLessonTasks.mockResolvedValue(linkedTasks); lessonApiMock.lessonControllerGetLesson.mockResolvedValue(response); return { lessonId: response.data.id }; @@ -137,75 +154,4 @@ describe(LessonClientAdapter.name, () => { }); }); }); - - describe('getLessonTasks', () => { - describe('When getLessonTasks is called', () => { - const setup = () => { - const lessonId = faker.string.uuid(); - const response = createMock>({ - data: [ - { - name: faker.lorem.sentence(), - description: faker.lorem.sentence(), - descriptionInputFormat: faker.helpers.arrayElement(['plainText', 'richTextCk4', 'richTextCk5Simple']), - availableDate: faker.date.recent().toString(), - dueDate: faker.date.future().toString(), - private: faker.datatype.boolean(), - publicSubmissions: faker.datatype.boolean(), - teamSubmissions: faker.datatype.boolean(), - }, - ], - }); - - lessonApiMock.lessonControllerGetLessonTasks.mockResolvedValue(response); - - return { lessonId }; - }; - - it('should call lessonControllerGetLessonTasks', async () => { - const { lessonId } = setup(); - - await sut.getLessonTasks(lessonId); - - expect(lessonApiMock.lessonControllerGetLessonTasks).toHaveBeenCalled(); - }); - }); - - describe('When getLessonTasks is called with invalid id', () => { - const setup = () => { - const lessonResponseId = faker.string.uuid(); - - lessonApiMock.lessonControllerGetLessonTasks.mockRejectedValueOnce(new Error('error')); - - return { lessonResponseId }; - }; - - it('should throw an error', async () => { - const { lessonResponseId } = setup(); - - const result = sut.getLessonTasks(lessonResponseId); - - await expect(result).rejects.toThrowError('error'); - }); - }); - - describe('When no JWT token is found', () => { - const setup = () => { - const lessonResponseId = faker.string.uuid(); - const request = createMock({ - headers: {}, - }) as Request; - - const adapter: LessonClientAdapter = new LessonClientAdapter(lessonApiMock, request); - - return { lessonResponseId, adapter }; - }; - - it('should throw an UnauthorizedError', async () => { - const { lessonResponseId, adapter } = setup(); - - await expect(adapter.getLessonTasks(lessonResponseId)).rejects.toThrowError(UnauthorizedException); - }); - }); - }); }); diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lesson-client.adapter.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lesson-client.adapter.ts index 22e8abf7eec..2f3930ad9f9 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lesson-client.adapter.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lesson-client.adapter.ts @@ -15,6 +15,7 @@ export class LessonClientAdapter { const options = this.createOptionParams(); const response = await this.lessonApi.lessonControllerGetLesson(lessonId, options); const lessonDto = LessonDtoMapper.mapToLessonDto(response.data); + lessonDto.linkedTasks = await this.getLessonTasks(lessonId); return lessonDto; } diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/.openapi-generator/FILES b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/.openapi-generator/FILES index 96db8953479..3cfa46d40b7 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/.openapi-generator/FILES +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/.openapi-generator/FILES @@ -8,7 +8,14 @@ common.ts configuration.ts git_push.sh index.ts +models/component-etherpad-props-impl.ts +models/component-geogebra-props-impl.ts +models/component-internal-props-impl.ts +models/component-lernstore-props-impl.ts +models/component-nexboard-props-impl.ts +models/component-text-props-impl.ts models/index.ts +models/lesson-content-response-content.ts models/lesson-content-response.ts models/lesson-linked-task-response.ts models/lesson-metadata-list-response.ts diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/component-etherpad-props-impl.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/component-etherpad-props-impl.ts new file mode 100644 index 00000000000..2c18cef729c --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/component-etherpad-props-impl.ts @@ -0,0 +1,42 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * This is v3 of Schulcloud-Verbund-Software Server. Checkout /docs for v1. + * + * The version of the OpenAPI document: 3.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * + * @export + * @interface ComponentEtherpadPropsImpl + */ +export interface ComponentEtherpadPropsImpl { + /** + * description of a Etherpad component + * @type {string} + * @memberof ComponentEtherpadPropsImpl + */ + 'description': string; + /** + * title of a Etherpad component + * @type {string} + * @memberof ComponentEtherpadPropsImpl + */ + 'title': string; + /** + * url of a Etherpad component + * @type {string} + * @memberof ComponentEtherpadPropsImpl + */ + 'url': string; +} + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/component-geogebra-props-impl.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/component-geogebra-props-impl.ts new file mode 100644 index 00000000000..9f8f609c364 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/component-geogebra-props-impl.ts @@ -0,0 +1,30 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * This is v3 of Schulcloud-Verbund-Software Server. Checkout /docs for v1. + * + * The version of the OpenAPI document: 3.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * + * @export + * @interface ComponentGeogebraPropsImpl + */ +export interface ComponentGeogebraPropsImpl { + /** + * materialId of a Geogebra component + * @type {string} + * @memberof ComponentGeogebraPropsImpl + */ + 'materialId': string; +} + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/component-internal-props-impl.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/component-internal-props-impl.ts new file mode 100644 index 00000000000..97b65de6639 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/component-internal-props-impl.ts @@ -0,0 +1,30 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * This is v3 of Schulcloud-Verbund-Software Server. Checkout /docs for v1. + * + * The version of the OpenAPI document: 3.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * + * @export + * @interface ComponentInternalPropsImpl + */ +export interface ComponentInternalPropsImpl { + /** + * url of a Internal component + * @type {string} + * @memberof ComponentInternalPropsImpl + */ + 'url': string; +} + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/component-lernstore-props-impl.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/component-lernstore-props-impl.ts new file mode 100644 index 00000000000..b8f4ad7ec17 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/component-lernstore-props-impl.ts @@ -0,0 +1,30 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * This is v3 of Schulcloud-Verbund-Software Server. Checkout /docs for v1. + * + * The version of the OpenAPI document: 3.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * + * @export + * @interface ComponentLernstorePropsImpl + */ +export interface ComponentLernstorePropsImpl { + /** + * resources of a Lernstore component + * @type {Array} + * @memberof ComponentLernstorePropsImpl + */ + 'resources': Array; +} + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/component-nexboard-props-impl.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/component-nexboard-props-impl.ts new file mode 100644 index 00000000000..e12f6298288 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/component-nexboard-props-impl.ts @@ -0,0 +1,48 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * This is v3 of Schulcloud-Verbund-Software Server. Checkout /docs for v1. + * + * The version of the OpenAPI document: 3.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * + * @export + * @interface ComponentNexboardPropsImpl + */ +export interface ComponentNexboardPropsImpl { + /** + * board of a Nexboard component + * @type {string} + * @memberof ComponentNexboardPropsImpl + */ + 'board': string; + /** + * description of a Nexboard component + * @type {string} + * @memberof ComponentNexboardPropsImpl + */ + 'description': string; + /** + * title of a Nexboard component + * @type {string} + * @memberof ComponentNexboardPropsImpl + */ + 'title': string; + /** + * url of a Nexboard component + * @type {string} + * @memberof ComponentNexboardPropsImpl + */ + 'url': string; +} + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/component-text-props-impl.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/component-text-props-impl.ts new file mode 100644 index 00000000000..6ee414db6d5 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/component-text-props-impl.ts @@ -0,0 +1,30 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * This is v3 of Schulcloud-Verbund-Software Server. Checkout /docs for v1. + * + * The version of the OpenAPI document: 3.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * + * @export + * @interface ComponentTextPropsImpl + */ +export interface ComponentTextPropsImpl { + /** + * + * @type {string} + * @memberof ComponentTextPropsImpl + */ + 'text': string; +} + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/index.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/index.ts index 9abd938430d..bced2458355 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/index.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/index.ts @@ -1,4 +1,11 @@ +export * from './component-etherpad-props-impl'; +export * from './component-geogebra-props-impl'; +export * from './component-internal-props-impl'; +export * from './component-lernstore-props-impl'; +export * from './component-nexboard-props-impl'; +export * from './component-text-props-impl'; export * from './lesson-content-response'; +export * from './lesson-content-response-content'; export * from './lesson-linked-task-response'; export * from './lesson-metadata-list-response'; export * from './lesson-metadata-response'; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/lesson-content-response-content.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/lesson-content-response-content.ts new file mode 100644 index 00000000000..87cf1155cf7 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/lesson-content-response-content.ts @@ -0,0 +1,41 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * This is v3 of Schulcloud-Verbund-Software Server. Checkout /docs for v1. + * + * The version of the OpenAPI document: 3.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { ComponentEtherpadPropsImpl } from './component-etherpad-props-impl'; +// May contain unused imports in some cases +// @ts-ignore +import type { ComponentGeogebraPropsImpl } from './component-geogebra-props-impl'; +// May contain unused imports in some cases +// @ts-ignore +import type { ComponentInternalPropsImpl } from './component-internal-props-impl'; +// May contain unused imports in some cases +// @ts-ignore +import type { ComponentLernstorePropsImpl } from './component-lernstore-props-impl'; +// May contain unused imports in some cases +// @ts-ignore +import type { ComponentNexboardPropsImpl } from './component-nexboard-props-impl'; +// May contain unused imports in some cases +// @ts-ignore +import type { ComponentTextPropsImpl } from './component-text-props-impl'; + +/** + * @type LessonContentResponseContent + * @export + */ +export type LessonContentResponseContent = ComponentEtherpadPropsImpl | ComponentGeogebraPropsImpl | ComponentInternalPropsImpl | ComponentLernstorePropsImpl | ComponentNexboardPropsImpl | ComponentTextPropsImpl; + + diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/lesson-content-response.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/lesson-content-response.ts index da12106638f..668e233e326 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/lesson-content-response.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/lessons-api-client/models/lesson-content-response.ts @@ -13,6 +13,9 @@ */ +// May contain unused imports in some cases +// @ts-ignore +import type { LessonContentResponseContent } from './lesson-content-response-content'; /** * @@ -22,10 +25,10 @@ export interface LessonContentResponse { /** * - * @type {object} + * @type {LessonContentResponseContent} * @memberof LessonContentResponse */ - 'content': object; + 'content': LessonContentResponseContent; /** * The id of the Material entity * @type {string} @@ -65,7 +68,7 @@ export const LessonContentResponseComponent = { INTERNAL: 'internal', RESOURCES: 'resources', TEXT: 'text', - NE_XBOARD: 'neXboard' + NEX_BOARD: 'neXboard' } as const; export type LessonContentResponseComponent = typeof LessonContentResponseComponent[keyof typeof LessonContentResponseComponent]; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/mapper/lesson-dto.mapper.spec.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/mapper/lesson-dto.mapper.spec.ts index 3b294527520..03402407a43 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/mapper/lesson-dto.mapper.spec.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/mapper/lesson-dto.mapper.spec.ts @@ -5,30 +5,78 @@ import { LessonResponse, LessonLinkedTaskResponse, LessonLinkedTaskResponseDescriptionInputFormat, + ComponentEtherpadPropsImpl, + ComponentGeogebraPropsImpl, + ComponentInternalPropsImpl, + ComponentTextPropsImpl, + ComponentLernstorePropsImpl, + ComponentNexboardPropsImpl, + LessonContentResponseComponent, } from '../lessons-api-client'; import { LessonDtoMapper } from './lesson-dto.mapper'; +import { LessonDto } from '../dto'; +import { ComponentGeogebraPropsDto } from '../dto/component-geogebra-props.dto'; +import { ComponentTextPropsDto } from '../dto/component-text-props.dto'; +import { ComponentInternalPropsDto } from '../dto/component-internal-props.dto'; +import { ComponentLernstorePropsDto } from '../dto/component-lernstore-props.dto'; +import { ComponentNexboardPropsDto } from '../dto/component-nexboard-props-dto'; describe('LessonDtoMapper', () => { describe('mapToLessonDto', () => { - describe('when mapping to LessonResponse', () => { + const materialResponse: MaterialResponse = { + _id: faker.string.uuid(), + id: faker.string.uuid(), + title: faker.lorem.sentence(), + relatedResources: [faker.lorem.sentence()], + url: faker.internet.url(), + client: faker.lorem.sentence(), + license: [faker.lorem.sentence()], + merlinReference: faker.lorem.sentence(), + }; + + describe('when mapping LessonResponse to lesson DTO with etherpad contnet', () => { const setup = () => { - const materialResponse: MaterialResponse = { + const lessonContentResponse: LessonContentResponse = { + content: { title: faker.lorem.sentence() } as ComponentEtherpadPropsImpl, _id: faker.string.uuid(), id: faker.string.uuid(), title: faker.lorem.sentence(), - relatedResources: [faker.lorem.sentence()], - url: faker.internet.url(), - client: faker.lorem.sentence(), - license: [faker.lorem.sentence()], - merlinReference: faker.lorem.sentence(), + component: faker.helpers.arrayElement(['Etherpad']), + hidden: faker.datatype.boolean(), + }; + + const lessonResponse: LessonResponse = { + _id: faker.string.uuid(), + id: faker.string.uuid(), + name: faker.lorem.sentence(), + courseId: faker.string.uuid(), + courseGroupId: faker.string.uuid(), + hidden: faker.datatype.boolean(), + position: faker.number.int(), + contents: [lessonContentResponse], + materials: [materialResponse], }; + return { lessonResponse, lessonContentResponse }; + }; + + it('should return LessonDto with etherpad content', () => { + const { lessonResponse } = setup(); + + const result = LessonDtoMapper.mapToLessonDto(lessonResponse); + + expect(result).toBeInstanceOf(LessonDto); + }); + }); + + describe('when mapping LessonResponse to lesson DTO with GeoGebra content', () => { + const setup = () => { const lessonContentResponse: LessonContentResponse = { - content: { text: faker.lorem.sentence() }, + content: { materialId: faker.string.uuid() } as ComponentGeogebraPropsImpl, _id: faker.string.uuid(), id: faker.string.uuid(), title: faker.lorem.sentence(), - component: faker.helpers.arrayElement(['Etherpad', 'neXboard', 'geoGebra']), + component: faker.helpers.arrayElement(['geoGebra']), hidden: faker.datatype.boolean(), }; @@ -44,41 +92,210 @@ describe('LessonDtoMapper', () => { materials: [materialResponse], }; - return { lessonResponse }; + return { lessonResponse, lessonContentResponse }; }; - it('should return LessonDto', () => { + it('should return LessonDto with GeoGebra content', () => { const { lessonResponse } = setup(); const result = LessonDtoMapper.mapToLessonDto(lessonResponse); - expect(result).toEqual({ - lessonId: lessonResponse.id, - name: lessonResponse.name, - courseId: lessonResponse.courseId, - courseGroupId: lessonResponse.courseGroupId, - hidden: lessonResponse.hidden, - position: lessonResponse.position, - contents: [ - { - content: lessonResponse.contents[0].content, - title: lessonResponse.contents[0].title, - component: lessonResponse.contents[0].component, - hidden: lessonResponse.contents[0].hidden, - }, - ], - materials: [ - { - materialsId: lessonResponse.materials[0].id, - title: lessonResponse.materials[0].title, - relatedResources: [lessonResponse.materials[0].relatedResources[0]], - url: lessonResponse.materials[0].url, - client: lessonResponse.materials[0].client, - license: lessonResponse.materials[0].license, - merlinReference: lessonResponse.materials[0].merlinReference, - }, - ], - }); + expect(result).toBeInstanceOf(LessonDto); + expect(result.contents[0].component).toEqual('geoGebra'); + expect(result.contents[0].content).toBeInstanceOf(ComponentGeogebraPropsDto); + }); + }); + + describe('when mapping LessonResponse to lesson DTO with Text content', () => { + const setup = () => { + const lessonContentResponse: LessonContentResponse = { + content: { text: faker.lorem.sentence() } as ComponentTextPropsImpl, + _id: faker.string.uuid(), + id: faker.string.uuid(), + title: faker.lorem.sentence(), + component: faker.helpers.arrayElement(['text']), + hidden: faker.datatype.boolean(), + }; + + const lessonResponse: LessonResponse = { + _id: faker.string.uuid(), + id: faker.string.uuid(), + name: faker.lorem.sentence(), + courseId: faker.string.uuid(), + courseGroupId: faker.string.uuid(), + hidden: faker.datatype.boolean(), + position: faker.number.int(), + contents: [lessonContentResponse], + materials: [materialResponse], + }; + + return { lessonResponse, lessonContentResponse }; + }; + + it('should return LessonDto with text content', () => { + const { lessonResponse } = setup(); + + const result = LessonDtoMapper.mapToLessonDto(lessonResponse); + + expect(result).toBeInstanceOf(LessonDto); + expect(result.contents[0].component).toEqual('text'); + expect(result.contents[0].content).toBeInstanceOf(ComponentTextPropsDto); + }); + }); + + describe('when mapping LessonResponse to lesson DTO with internal content', () => { + const setup = () => { + const lessonContentResponse: LessonContentResponse = { + content: { url: faker.internet.url() } as ComponentInternalPropsImpl, + _id: faker.string.uuid(), + id: faker.string.uuid(), + title: faker.lorem.sentence(), + component: faker.helpers.arrayElement(['internal']), + hidden: faker.datatype.boolean(), + }; + + const lessonResponse: LessonResponse = { + _id: faker.string.uuid(), + id: faker.string.uuid(), + name: faker.lorem.sentence(), + courseId: faker.string.uuid(), + courseGroupId: faker.string.uuid(), + hidden: faker.datatype.boolean(), + position: faker.number.int(), + contents: [lessonContentResponse], + materials: [materialResponse], + }; + + return { lessonResponse, lessonContentResponse }; + }; + + it('should return LessonDto with internal content', () => { + const { lessonResponse } = setup(); + + const result = LessonDtoMapper.mapToLessonDto(lessonResponse); + + expect(result).toBeInstanceOf(LessonDto); + expect(result.contents[0].component).toEqual('internal'); + expect(result.contents[0].content).toBeInstanceOf(ComponentInternalPropsDto); + }); + }); + + describe('when mapping LessonResponse to lesson DTO with lernstore content', () => { + const setup = () => { + const lessonContentResponse: LessonContentResponse = { + content: { resources: [faker.internet.url(), faker.lorem.text()] } as ComponentLernstorePropsImpl, + _id: faker.string.uuid(), + id: faker.string.uuid(), + title: faker.lorem.sentence(), + component: faker.helpers.arrayElement(['resources']), + hidden: faker.datatype.boolean(), + }; + + const lessonResponse: LessonResponse = { + _id: faker.string.uuid(), + id: faker.string.uuid(), + name: faker.lorem.sentence(), + courseId: faker.string.uuid(), + courseGroupId: faker.string.uuid(), + hidden: faker.datatype.boolean(), + position: faker.number.int(), + contents: [lessonContentResponse], + materials: [materialResponse], + }; + + return { lessonResponse, lessonContentResponse }; + }; + + it('should return LessonDto with lernstore content', () => { + const { lessonResponse } = setup(); + + const result = LessonDtoMapper.mapToLessonDto(lessonResponse); + + expect(result).toBeInstanceOf(LessonDto); + expect(result.contents[0].component).toEqual('resources'); + expect(result.contents[0].content).toBeInstanceOf(ComponentLernstorePropsDto); + }); + }); + + describe('when mapping LessonResponse to lesson DTO with next board content', () => { + const setup = () => { + const lessonContentResponse: LessonContentResponse = { + content: { + board: faker.lorem.text(), + description: faker.lorem.word(), + title: faker.lorem.text(), + url: faker.internet.url(), + } as ComponentNexboardPropsImpl, + _id: faker.string.uuid(), + id: faker.string.uuid(), + title: faker.lorem.sentence(), + component: faker.helpers.arrayElement(['neXboard']), + hidden: faker.datatype.boolean(), + }; + + const lessonResponse: LessonResponse = { + _id: faker.string.uuid(), + id: faker.string.uuid(), + name: faker.lorem.sentence(), + courseId: faker.string.uuid(), + courseGroupId: faker.string.uuid(), + hidden: faker.datatype.boolean(), + position: faker.number.int(), + contents: [lessonContentResponse], + materials: [materialResponse], + }; + + return { lessonResponse, lessonContentResponse }; + }; + + it('should return LessonDto with nexboard content', () => { + const { lessonResponse } = setup(); + + const result = LessonDtoMapper.mapToLessonDto(lessonResponse); + + expect(result).toBeInstanceOf(LessonDto); + expect(result.contents[0].component).toEqual('neXboard'); + expect(result.contents[0].content).toBeInstanceOf(ComponentNexboardPropsDto); + }); + }); + + describe('when mapping LessonResponse to lesson DTO with an empty content', () => { + const setup = () => { + const lessonContentResponse: LessonContentResponse = { + content: { + board: faker.lorem.text(), + description: faker.lorem.word(), + title: faker.lorem.text(), + url: faker.internet.url(), + } as ComponentNexboardPropsImpl, + _id: faker.string.uuid(), + id: faker.string.uuid(), + title: faker.lorem.sentence(), + component: faker.helpers.arrayElement(['unknown']) as unknown as LessonContentResponseComponent, + hidden: faker.datatype.boolean(), + }; + + const lessonResponse: LessonResponse = { + _id: faker.string.uuid(), + id: faker.string.uuid(), + name: faker.lorem.sentence(), + courseId: faker.string.uuid(), + courseGroupId: faker.string.uuid(), + hidden: faker.datatype.boolean(), + position: faker.number.int(), + contents: [lessonContentResponse], + materials: [], + }; + + return { lessonResponse }; + }; + it('should return an empty array of contents', () => { + const { lessonResponse } = setup(); + + const result = LessonDtoMapper.mapToLessonDto(lessonResponse); + + expect(result).toBeInstanceOf(LessonDto); + expect(result.contents).toEqual([]); }); }); }); diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/mapper/lesson-dto.mapper.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/mapper/lesson-dto.mapper.ts index 150037ddaee..aeeeba2cf3a 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/mapper/lesson-dto.mapper.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/mapper/lesson-dto.mapper.ts @@ -1,6 +1,19 @@ import { LessonContentDto, LessonDto, LessonLinkedTaskDto, LessonMaterialsDto } from '../dto'; +import { ComponentEtherpadPropsDto } from '../dto/component-etherpad-props.dto'; +import { ComponentGeogebraPropsDto } from '../dto/component-geogebra-props.dto'; +import { ComponentInternalPropsDto } from '../dto/component-internal-props.dto'; +import { ComponentLernstorePropsDto } from '../dto/component-lernstore-props.dto'; +import { ComponentNexboardPropsDto } from '../dto/component-nexboard-props-dto'; +import { ComponentTextPropsDto } from '../dto/component-text-props.dto'; import { + ComponentEtherpadPropsImpl, + ComponentGeogebraPropsImpl, + ComponentInternalPropsImpl, + ComponentLernstorePropsImpl, + ComponentNexboardPropsImpl, + ComponentTextPropsImpl, LessonContentResponse, + LessonContentResponseComponent, LessonLinkedTaskResponse, LessonResponse, MaterialResponse, @@ -34,7 +47,10 @@ export class LessonDtoMapper { courseGroupId: lessonResponse.courseGroupId, hidden: lessonResponse.hidden, position: lessonResponse.position, - contents: lessonResponse.contents.map((content) => this.mapToLessenContentDto(content)), + contents: lessonResponse.contents + .map((content) => this.mapToLessenContentDto(content)) + .filter((contetnDto) => contetnDto !== null), + linkedTasks: [], materials: lessonResponse.materials.map((material) => this.mapToLessonMaterialDto(material)), }); @@ -55,14 +71,58 @@ export class LessonDtoMapper { return lessonMaterialsDto; } - private static mapToLessenContentDto(lessonContentResponse: LessonContentResponse): LessonContentDto { - const lessonContentDto = new LessonContentDto({ - content: lessonContentResponse.content, - title: lessonContentResponse.title, - component: lessonContentResponse.component, - hidden: lessonContentResponse.hidden, - }); - - return lessonContentDto; + private static mapToLessenContentDto(lessonContentResponse: LessonContentResponse): LessonContentDto | null { + switch (lessonContentResponse.component) { + case LessonContentResponseComponent.TEXT: + return new LessonContentDto({ + id: lessonContentResponse.id, + title: lessonContentResponse.title, + component: lessonContentResponse.component, + hidden: lessonContentResponse.hidden, + content: new ComponentTextPropsDto(lessonContentResponse.content as ComponentTextPropsImpl), + }); + case LessonContentResponseComponent.ETHERPAD: + return new LessonContentDto({ + id: lessonContentResponse.id, + title: lessonContentResponse.title, + component: lessonContentResponse.component, + hidden: lessonContentResponse.hidden, + content: new ComponentEtherpadPropsDto(lessonContentResponse.content as ComponentEtherpadPropsImpl), + }); + case LessonContentResponseComponent.GEO_GEBRA: + return new LessonContentDto({ + id: lessonContentResponse.id, + title: lessonContentResponse.title, + component: lessonContentResponse.component, + hidden: lessonContentResponse.hidden, + content: new ComponentGeogebraPropsDto(lessonContentResponse.content as ComponentGeogebraPropsImpl), + }); + case LessonContentResponseComponent.INTERNAL: + return new LessonContentDto({ + id: lessonContentResponse.id, + title: lessonContentResponse.title, + component: lessonContentResponse.component, + hidden: lessonContentResponse.hidden, + content: new ComponentInternalPropsDto(lessonContentResponse.content as ComponentInternalPropsImpl), + }); + case LessonContentResponseComponent.RESOURCES: + return new LessonContentDto({ + id: lessonContentResponse.id, + title: lessonContentResponse.title, + component: lessonContentResponse.component, + hidden: lessonContentResponse.hidden, + content: new ComponentLernstorePropsDto(lessonContentResponse.content as ComponentLernstorePropsImpl), + }); + case LessonContentResponseComponent.NEX_BOARD: + return new LessonContentDto({ + id: lessonContentResponse.id, + title: lessonContentResponse.title, + component: lessonContentResponse.component, + hidden: lessonContentResponse.hidden, + content: new ComponentNexboardPropsDto(lessonContentResponse.content as ComponentNexboardPropsImpl), + }); + default: + return null; + } } } diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/dto/index.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/dto/index.ts new file mode 100644 index 00000000000..eff692a98e4 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/dto/index.ts @@ -0,0 +1,6 @@ +export { RoomBoardDto } from './room-board.dto'; +export { BoardElementDto } from './board-element.dto'; +export { BoardTaskDto } from './board-task.dto'; +export { BoardTaskStatusDto } from './board-task-status.dto'; +export { BoardLessonDto } from './board-lesson.dto'; +export { BoardColumnBoardDto } from './board-column-board.dto'; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/mapper/board-task-status-dto.mapper.spec.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/mapper/board-task-status-dto.mapper.spec.ts index f38859c9bad..aabb717c7aa 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/mapper/board-task-status-dto.mapper.spec.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/mapper/board-task-status-dto.mapper.spec.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import { BoardTaskStatusResponse } from '../room-api-client'; import { BoardTaskStatusMapper } from './board-task-status-dto.mapper'; -import { BoardTaskStatusDto } from '../dto/board-task-status.dto'; +import { BoardTaskStatusDto } from '../dto'; describe(BoardTaskStatusMapper.name, () => { describe('mapBoardTaskStatusToDto', () => { diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/mapper/board-task-status-dto.mapper.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/mapper/board-task-status-dto.mapper.ts index c4834f148f9..709533afaeb 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/mapper/board-task-status-dto.mapper.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/mapper/board-task-status-dto.mapper.ts @@ -1,4 +1,4 @@ -import { BoardTaskStatusDto } from '../dto/board-task-status.dto'; +import { BoardTaskStatusDto } from '../dto'; import { BoardTaskStatusResponse } from '../room-api-client'; export class BoardTaskStatusMapper { diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/mapper/room-board-dto.mapper.spec.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/mapper/room-board-dto.mapper.spec.ts index 037ba49cae7..f1fc542b2b0 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/mapper/room-board-dto.mapper.spec.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/mapper/room-board-dto.mapper.spec.ts @@ -8,7 +8,7 @@ import { SingleColumnBoardResponse, } from '../room-api-client'; import { RoomBoardDtoMapper } from './room-board-dto.mapper'; -import { BoardTaskStatusDto } from '../dto/board-task-status.dto'; +import { BoardTaskStatusDto } from '../dto'; describe(RoomBoardDtoMapper.name, () => { describe('mapResponseToRoomBoardDto', () => { diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/mapper/room-board-dto.mapper.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/mapper/room-board-dto.mapper.ts index 6bacca7eff6..5074e143e98 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/mapper/room-board-dto.mapper.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/mapper/room-board-dto.mapper.ts @@ -1,8 +1,4 @@ -import { BoardColumnBoardDto } from '../dto/board-column-board.dto'; -import { BoardElementDto } from '../dto/board-element.dto'; -import { BoardLessonDto } from '../dto/board-lesson.dto'; -import { BoardTaskDto } from '../dto/board-task.dto'; -import { RoomBoardDto } from '../dto/room-board.dto'; +import { RoomBoardDto, BoardTaskDto, BoardLessonDto, BoardElementDto, BoardColumnBoardDto } from '../dto'; import { BoardElementDtoType } from '../enums/board-element.enum'; import { BoardColumnBoardResponse, diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/room-client.adapter.spec.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/room-client.adapter.spec.ts index 63ff0dbf697..56264183d89 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/room-client.adapter.spec.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/room-client.adapter.spec.ts @@ -8,7 +8,7 @@ import { jest } from '@jest/globals'; import { CourseRoomsApi, SingleColumnBoardResponse } from './room-api-client'; import { CourseRoomsClientAdapter } from './room-client.adapter'; import { RoomBoardDtoMapper } from './mapper/room-board-dto.mapper'; -import { RoomBoardDto } from './dto/room-board.dto'; +import { RoomBoardDto } from './dto'; const jwtToken = 'dummyJwtToken'; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/room-client.adapter.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/room-client.adapter.ts index b53c3932334..b11354c8d4d 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/room-client.adapter.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/room-client/room-client.adapter.ts @@ -4,7 +4,7 @@ import { Request } from 'express'; import { extractJwtFromHeader } from '@shared/common'; import { RawAxiosRequestConfig } from 'axios'; import { CourseRoomsApi } from './room-api-client'; -import { RoomBoardDto } from './dto/room-board.dto'; +import { RoomBoardDto } from './dto'; import { RoomBoardDtoMapper } from './mapper/room-board-dto.mapper'; @Injectable() diff --git a/apps/server/src/modules/common-cartridge/common-cartridge.module.ts b/apps/server/src/modules/common-cartridge/common-cartridge.module.ts index c5beb84df62..5997ab8f977 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge.module.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge.module.ts @@ -1,6 +1,5 @@ import { Configuration } from '@hpi-schul-cloud/commons'; import { MikroOrmModule } from '@mikro-orm/nestjs'; -import { FilesStorageClientModule } from '@modules/files-storage-client'; import { Module } from '@nestjs/common'; import { ALL_ENTITIES } from '@shared/domain/entity'; import { DB_PASSWORD, DB_URL, DB_USERNAME } from '@src/config'; @@ -13,11 +12,11 @@ import { CommonCartridgeUc } from './uc/common-cartridge.uc'; import { CourseRoomsModule } from './common-cartridge-client/room-client'; import { CardClientModule } from './common-cartridge-client/card-client/card-client.module'; import { LessonClientModule } from './common-cartridge-client/lesson-client/lesson-client.module'; +import { CommonCartridgeExportMapper } from './service/common-cartridge.mapper'; @Module({ imports: [ RabbitMQWrapperModule, - FilesStorageClientModule, MikroOrmModule.forRoot({ ...defaultMikroOrmOptions, type: 'mongo', @@ -43,7 +42,7 @@ import { LessonClientModule } from './common-cartridge-client/lesson-client/less basePath: `${Configuration.get('API_HOST') as string}/v3/`, }), ], - providers: [CommonCartridgeUc, CommonCartridgeExportService], + providers: [CommonCartridgeExportMapper, CommonCartridgeUc, CommonCartridgeExportService], exports: [CommonCartridgeUc], }) export class CommonCartridgeModule {} diff --git a/apps/server/src/modules/common-cartridge/controller/common-cartridge.controller.spec.ts b/apps/server/src/modules/common-cartridge/controller/common-cartridge.controller.spec.ts index a274eff5f7c..245579d7364 100644 --- a/apps/server/src/modules/common-cartridge/controller/common-cartridge.controller.spec.ts +++ b/apps/server/src/modules/common-cartridge/controller/common-cartridge.controller.spec.ts @@ -1,10 +1,14 @@ import { faker } from '@faker-js/faker'; import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; +import { Response } from 'express'; +import { StreamableFile } from '@nestjs/common'; import { CommonCartridgeUc } from '../uc/common-cartridge.uc'; import { CommonCartridgeController } from './common-cartridge.controller'; -import { CourseFileIdsResponse, ExportCourseParams } from './dto'; -import { CourseExportBodyResponse } from './dto/course-export-body.response'; +import { ExportCourseParams } from './dto'; +import { CourseQueryParams } from './dto/course.query.params'; +import { CourseExportBodyParams } from './dto/course-export.body.params'; +import { CommonCartridgeVersion } from '../export/common-cartridge.enums'; describe('CommonCartridgeController', () => { let module: TestingModule; @@ -37,28 +41,33 @@ describe('CommonCartridgeController', () => { describe('exportCourse', () => { const setup = () => { const courseId = faker.string.uuid(); - const request = new ExportCourseParams(); - const expected = new CourseExportBodyResponse({ - courseFileIds: new CourseFileIdsResponse([]), - courseCommonCartridgeMetadata: { - id: courseId, - title: faker.lorem.sentence(), - copyRightOwners: [faker.lorem.words()], - }, - }); + const params = { courseId } as ExportCourseParams; + const query = { version: CommonCartridgeVersion.V_1_1_0 } as CourseQueryParams; + const body = { + topics: [faker.string.uuid(), faker.string.uuid()], + tasks: [faker.string.uuid()], + columnBoards: [faker.string.uuid(), faker.string.uuid()], + } as CourseExportBodyParams; + const expected = Buffer.from(faker.lorem.paragraphs(100)); + const mockResponse = { + set: jest.fn(), + } as unknown as Response; - Reflect.set(request, 'parentId', courseId); commonCartridgeUcMock.exportCourse.mockResolvedValue(expected); - return { request, expected }; + return { params, expected, query, body, mockResponse }; }; - it('should return a list of found FileRecords', async () => { - const { request, expected } = setup(); + it('should return a streamable file', async () => { + const { params, query, body, mockResponse } = setup(); - const result = await sut.exportCourse(request); + const result = await sut.exportCourse(params, query, body, mockResponse); - expect(result).toEqual(expected); + expect(mockResponse.set).toHaveBeenCalledWith({ + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename=course_${params.courseId}.zip`, + }); + expect(result).toBeInstanceOf(StreamableFile); }); }); }); diff --git a/apps/server/src/modules/common-cartridge/controller/common-cartridge.controller.ts b/apps/server/src/modules/common-cartridge/controller/common-cartridge.controller.ts index bd609c4ff88..80e1e5d10ea 100644 --- a/apps/server/src/modules/common-cartridge/controller/common-cartridge.controller.ts +++ b/apps/server/src/modules/common-cartridge/controller/common-cartridge.controller.ts @@ -1,16 +1,34 @@ -import { Controller, Get, Param } from '@nestjs/common'; +import { Body, Controller, Param, Post, Query, Res, StreamableFile } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; import { CommonCartridgeUc } from '../uc/common-cartridge.uc'; -import { ExportCourseParams } from './dto'; -import { CourseExportBodyResponse } from './dto/course-export-body.response'; +import { ExportCourseParams, CourseQueryParams, CourseExportBodyParams } from './dto'; @ApiTags('common-cartridge') @Controller('common-cartridge') export class CommonCartridgeController { constructor(private readonly commonCartridgeUC: CommonCartridgeUc) {} - @Get('export/:parentId') - public async exportCourse(@Param() exportCourseParams: ExportCourseParams): Promise { - return this.commonCartridgeUC.exportCourse(exportCourseParams.parentId); + @Post('export/:courseId') + public async exportCourse( + @Param() exportCourseParams: ExportCourseParams, + @Query() queryParams: CourseQueryParams, + @Body() bodyParams: CourseExportBodyParams, + @Res({ passthrough: true }) response: Response + ): Promise { + const result = await this.commonCartridgeUC.exportCourse( + exportCourseParams.courseId, + queryParams.version, + bodyParams.topics, + bodyParams.tasks, + bodyParams.columnBoards + ); + + response.set({ + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename=course_${exportCourseParams.courseId}.zip`, + }); + + return new StreamableFile(result); } } diff --git a/apps/server/src/modules/common-cartridge/controller/dto/common-cartridge.params.ts b/apps/server/src/modules/common-cartridge/controller/dto/common-cartridge.params.ts index a93c604f793..59f13b82182 100644 --- a/apps/server/src/modules/common-cartridge/controller/dto/common-cartridge.params.ts +++ b/apps/server/src/modules/common-cartridge/controller/dto/common-cartridge.params.ts @@ -5,5 +5,5 @@ import { IsMongoId } from 'class-validator'; export class ExportCourseParams { @IsMongoId() @ApiProperty() - public readonly parentId!: EntityId; + public readonly courseId!: EntityId; } diff --git a/apps/server/src/modules/common-cartridge/controller/dto/course-export.body.params.ts b/apps/server/src/modules/common-cartridge/controller/dto/course-export.body.params.ts new file mode 100644 index 00000000000..4bfe89851bc --- /dev/null +++ b/apps/server/src/modules/common-cartridge/controller/dto/course-export.body.params.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray } from 'class-validator'; + +export class CourseExportBodyParams { + @IsArray() + @ApiProperty({ + description: 'The list of ids of topics which should be exported. If empty no topics are exported.', + type: [String], + }) + public readonly topics!: string[]; + + @IsArray() + @ApiProperty({ + description: 'The list of ids of tasks which should be exported. If empty no tasks are exported.', + type: [String], + }) + public readonly tasks!: string[]; + + @IsArray() + @ApiProperty({ + description: 'The list of ids of column boards which should be exported. If empty no column boards are exported.', + type: [String], + }) + public readonly columnBoards!: string[]; +} diff --git a/apps/server/src/modules/common-cartridge/controller/dto/course.query.params.ts b/apps/server/src/modules/common-cartridge/controller/dto/course.query.params.ts new file mode 100644 index 00000000000..aa0a1b8bd2b --- /dev/null +++ b/apps/server/src/modules/common-cartridge/controller/dto/course.query.params.ts @@ -0,0 +1,14 @@ +import { IsString, Matches } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { CommonCartridgeVersion } from '../../export/common-cartridge.enums'; + +export class CourseQueryParams { + @IsString() + @Matches(Object.values(CommonCartridgeVersion).join('|')) + @ApiProperty({ + description: 'The version of CC export', + nullable: false, + enum: CommonCartridgeVersion, + }) + public readonly version!: CommonCartridgeVersion; +} diff --git a/apps/server/src/modules/common-cartridge/controller/dto/index.ts b/apps/server/src/modules/common-cartridge/controller/dto/index.ts index e93173f89f7..6f0d5c3e079 100644 --- a/apps/server/src/modules/common-cartridge/controller/dto/index.ts +++ b/apps/server/src/modules/common-cartridge/controller/dto/index.ts @@ -1,2 +1,4 @@ export * from './common-cartridge.params'; export * from './common-cartridge.response'; +export * from './course.query.params'; +export * from './course-export.body.params'; diff --git a/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.spec.ts b/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.spec.ts index f6d1066f5b0..769de8f23ed 100644 --- a/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.spec.ts +++ b/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.spec.ts @@ -1,28 +1,111 @@ -import { faker } from '@faker-js/faker'; import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; import { Test, TestingModule } from '@nestjs/testing'; -import { BoardClientAdapter } from '../common-cartridge-client/board-client'; +import AdmZip from 'adm-zip'; +import { BoardClientAdapter, BoardSkeletonDto } from '../common-cartridge-client/board-client'; import { CommonCartridgeExportService } from './common-cartridge-export.service'; -import { CoursesClientAdapter } from '../common-cartridge-client/course-client'; +import { CourseCommonCartridgeMetadataDto, CoursesClientAdapter } from '../common-cartridge-client/course-client'; import { CourseRoomsClientAdapter } from '../common-cartridge-client/room-client'; import { CardClientAdapter } from '../common-cartridge-client/card-client/card-client.adapter'; -import { CardListResponseDto } from '../common-cartridge-client/card-client/dto/card-list-response.dto'; -import { CardResponseDto } from '../common-cartridge-client/card-client/dto/card-response.dto'; -import { ContentElementType } from '../common-cartridge-client/card-client/enums/content-element-type.enum'; +import { LessonClientAdapter } from '../common-cartridge-client/lesson-client/lesson-client.adapter'; +import { CommonCartridgeExportMapper } from './common-cartridge.mapper'; +import { CommonCartridgeVersion } from '../export/common-cartridge.enums'; +import { + RoomBoardDto, + BoardTaskDto, + BoardLessonDto, + BoardColumnBoardDto, +} from '../common-cartridge-client/room-client/dto'; +import { + RichTextElementContentDto, + LinkElementContentDto, + CardListResponseDto, +} from '../common-cartridge-client/card-client/dto'; +import { + boardCloumnBoardFactory, + boardLessonFactory, + boardTaskFactory, + columnBoardFactory, + courseMetadataFactory, + lessonFactory, + listOfCardResponseFactory, + roomFactory, +} from '../testing/common-cartridge-dtos.factory'; describe('CommonCartridgeExportService', () => { let module: TestingModule; let sut: CommonCartridgeExportService; - let filesStorageServiceMock: DeepMocked; let coursesClientAdapterMock: DeepMocked; let courseRoomsClientAdapterMock: DeepMocked; let cardClientAdapterMock: DeepMocked; + let boardClientAdapterMock: DeepMocked; + let lessonClientAdapterMock: DeepMocked; + + const createXmlString = (nodeName: string, value: boolean | number | string): string => + `<${nodeName}>${value.toString()}`; + const getFileContent = (archive: AdmZip, filePath: string): string | undefined => + archive.getEntry(filePath)?.getData().toString(); + const setupParams = async ( + version: CommonCartridgeVersion, + exportTopics: boolean, + exportTasks: boolean, + exportColumnBoards: boolean + ) => { + const courseMetadata: CourseCommonCartridgeMetadataDto = courseMetadataFactory.build(); + const lessons = lessonFactory.buildList(2); + const [lesson] = lessons; + lesson.courseId = courseMetadata.id; + + const boardSkeleton: BoardSkeletonDto = columnBoardFactory.build(); + const listOfCardsResponse: CardListResponseDto = listOfCardResponseFactory.build(); + const boardTask: BoardTaskDto = boardTaskFactory.build(); + boardTask.courseName = courseMetadata.courseName; + + const room: RoomBoardDto = roomFactory.build(); + room.title = courseMetadata.courseName; + room.elements[0].content = boardTask; + room.elements[1].content = new BoardLessonDto(boardLessonFactory.build()); + room.elements[1].content.id = lesson.lessonId; + room.elements[1].content.name = lesson.name; + room.elements[2].content = new BoardColumnBoardDto(boardCloumnBoardFactory.build()); + + coursesClientAdapterMock.getCourseCommonCartridgeMetadata.mockResolvedValue(courseMetadata); + lessonClientAdapterMock.getLessonById.mockResolvedValue(lesson); + lessonClientAdapterMock.getLessonTasks.mockResolvedValue(lesson.linkedTasks ?? []); + boardClientAdapterMock.getBoardSkeletonById.mockResolvedValue(boardSkeleton); + cardClientAdapterMock.getAllBoardCardsByIds.mockResolvedValue(listOfCardsResponse); + courseRoomsClientAdapterMock.getRoomBoardByCourseId.mockResolvedValue(room); + + const buffer = await sut.exportCourse( + courseMetadata.id, + version, + exportTopics ? [room.elements[1].content.id] : [], + exportTasks ? [room.elements[0].content.id] : [], + exportColumnBoards ? [room.elements[2].content.id] : [] + ); + + const archive = new AdmZip(buffer); + + return { + courseMetadata, + archive, + version, + room, + lesson, + lessons, + boardTask, + boardSkeleton, + listOfCardsResponse, + textElement: listOfCardsResponse.data[0].elements[0].content as RichTextElementContentDto, + linkElement: listOfCardsResponse.data[0].elements[1].content as LinkElementContentDto, + }; + }; beforeAll(async () => { module = await Test.createTestingModule({ providers: [ CommonCartridgeExportService, + CommonCartridgeExportMapper, { provide: FilesStorageClientAdapterService, useValue: createMock(), @@ -43,14 +126,19 @@ describe('CommonCartridgeExportService', () => { provide: CardClientAdapter, useValue: createMock(), }, + { + provide: LessonClientAdapter, + useValue: createMock(), + }, ], }).compile(); sut = module.get(CommonCartridgeExportService); - filesStorageServiceMock = module.get(FilesStorageClientAdapterService); coursesClientAdapterMock = module.get(CoursesClientAdapter); courseRoomsClientAdapterMock = module.get(CourseRoomsClientAdapter); cardClientAdapterMock = module.get(CardClientAdapter); + boardClientAdapterMock = module.get(BoardClientAdapter); + lessonClientAdapterMock = module.get(LessonClientAdapter); }); afterAll(async () => { @@ -61,115 +149,193 @@ describe('CommonCartridgeExportService', () => { expect(sut).toBeDefined(); }); - describe('findCourseFileRecords', () => { - const setup = () => { - const courseId = faker.string.uuid(); - const expected = []; + describe('exportCourse', () => { + describe('when using version 1.1', () => { + const setup = async () => setupParams(CommonCartridgeVersion.V_1_1_0, true, true, true); - filesStorageServiceMock.listFilesOfParent.mockResolvedValue([]); + it('should use schema version 1.1.0', async () => { + const { archive } = await setup(); - return { courseId, expected }; - }; + expect(getFileContent(archive, 'imsmanifest.xml')).toContain(createXmlString('schemaversion', '1.1.0')); + }); + + it('should add course', async () => { + const { archive, courseMetadata } = await setup(); + + expect(getFileContent(archive, 'imsmanifest.xml')).toContain( + createXmlString('mnf:string', courseMetadata.courseName) + ); + }); + + it('should add lesson', async () => { + const { archive, lesson } = await setup(); - it('should return a list of FileRecords', async () => { - const { courseId, expected } = setup(); + expect(getFileContent(archive, 'imsmanifest.xml')).toContain(createXmlString('title', lesson.name)); + }); - const result = await sut.findCourseFileRecords(courseId); + it('should add task', async () => { + const { archive, boardTask } = await setup(); - expect(result).toEqual(expected); + expect(getFileContent(archive, 'imsmanifest.xml')).toContain(createXmlString('title', boardTask.name)); + + expect(getFileContent(archive, 'imsmanifest.xml')).toContain(` { + const { archive, lesson } = await setup(); + const manifest = archive.getEntry('imsmanifest.xml')?.getData().toString(); + + lesson.linkedTasks.forEach((linkedTask) => { + expect(manifest).toContain(`${linkedTask.name}`); + }); + }); + + it('should add lernstore element of lesson to manifest file', async () => { + const { archive, lesson } = await setup(); + const manifest = archive.getEntry('imsmanifest.xml')?.getData().toString(); + + lesson.contents.forEach((content) => { + expect(manifest).toContain(`${content.title}`); + }); + }); + + it('should add column boards', async () => { + const { archive, boardSkeleton } = await setup(); + const manifest = getFileContent(archive, 'imsmanifest.xml'); + + expect(manifest).toContain(createXmlString('title', boardSkeleton.title)); + }); + + it('should add column', async () => { + const { archive, boardSkeleton } = await setup(); + const manifest = getFileContent(archive, 'imsmanifest.xml'); + + expect(manifest).toContain(createXmlString('title', boardSkeleton.columns[0].title ?? '')); + }); + + it('should add card', async () => { + const { archive, listOfCardsResponse } = await setup(); + const manifest = getFileContent(archive, 'imsmanifest.xml'); + + expect(manifest).toContain(createXmlString('title', listOfCardsResponse.data[0].title ?? '')); + }); }); - }); - describe('findCourseCcMetadata', () => { - const setup = () => { - const courseId = faker.string.uuid(); - const expected = { - id: courseId, - title: faker.lorem.sentence(), - copyRightOwners: [faker.lorem.word()], - }; + describe('when using version 1.3', () => { + const setup = async () => setupParams(CommonCartridgeVersion.V_1_3_0, true, true, true); - coursesClientAdapterMock.getCourseCommonCartridgeMetadata.mockResolvedValue(expected); + it('should use schema version 1.3.0', async () => { + const { archive } = await setup(); - return { courseId, expected }; - }; + expect(getFileContent(archive, 'imsmanifest.xml')).toContain(createXmlString('schemaversion', '1.3.0')); + }); - it('should return a CourseCommonCartridgeMetadataDto', async () => { - const { courseId, expected } = setup(); + it('should add course', async () => { + const { archive, courseMetadata } = await setup(); - const result = await sut.findCourseCommonCartridgeMetadata(courseId); + expect(getFileContent(archive, 'imsmanifest.xml')).toContain( + createXmlString('mnf:string', courseMetadata.courseName) + ); + }); - expect(result).toEqual(expected); + it('should add lesson', async () => { + const { archive, lesson } = await setup(); + + expect(getFileContent(archive, 'imsmanifest.xml')).toContain(createXmlString('title', lesson.name)); + }); + + it('should add tasks', async () => { + const { archive, boardTask } = await setup(); + + expect(getFileContent(archive, 'imsmanifest.xml')).toContain(` { + const { archive, lesson } = await setup(); + const manifest = archive.getEntry('imsmanifest.xml')?.getData().toString(); + + lesson.linkedTasks.forEach((linkedTask) => { + expect(manifest).toContain(`${linkedTask.name}`); + }); + }); + + it('should add lernstore element of lesson to manifest file', async () => { + const { archive, lesson } = await setup(); + const manifest = archive.getEntry('imsmanifest.xml')?.getData().toString(); + + lesson.contents.forEach((content) => { + expect(manifest).toContain(`${content.title}`); + }); + }); + + it('should add column boards', async () => { + const { archive, boardSkeleton } = await setup(); + const manifest = getFileContent(archive, 'imsmanifest.xml'); + + expect(manifest).toContain(createXmlString('title', boardSkeleton.title)); + }); + + it('should add column', async () => { + const { archive, boardSkeleton } = await setup(); + const manifest = getFileContent(archive, 'imsmanifest.xml'); + + expect(manifest).toContain(createXmlString('title', boardSkeleton.columns[0].title ?? '')); + }); + + it('should add card', async () => { + const { archive, listOfCardsResponse } = await setup(); + const manifest = getFileContent(archive, 'imsmanifest.xml'); + + expect(manifest).toContain(createXmlString('title', listOfCardsResponse.data[0].title ?? '')); + }); + + it('should add link element of card', async () => { + const { archive, linkElement } = await setup(); + const manifest = getFileContent(archive, 'imsmanifest.xml'); + + expect(manifest).toContain(createXmlString('title', linkElement.title)); + }); + + it('should add text element of card', async () => { + const { archive, textElement } = await setup(); + const manifest = getFileContent(archive, 'imsmanifest.xml'); + + expect(manifest).toContain(createXmlString('title', textElement.text)); + }); }); - }); - describe('findCourseRoomBoard', () => { - const setup = () => { - const roomId = faker.string.uuid(); - const expected = { - roomId, - title: faker.lorem.word(), - displayColor: faker.date.recent().toString(), - isSynchronized: faker.datatype.boolean(), - elements: [], - isArchived: faker.datatype.boolean(), - }; - - courseRoomsClientAdapterMock.getRoomBoardByCourseId.mockResolvedValue(expected); - - return { roomId, expected }; - }; + describe('when topics array is empty', () => { + const setup = async () => setupParams(CommonCartridgeVersion.V_1_1_0, false, true, true); - it('should return a room board', async () => { - const { roomId, expected } = setup(); + it("shouldn't add lessons", async () => { + const { archive, lessons } = await setup(); - const result = await sut.findRoomBoardByCourseId(roomId); + lessons.forEach((lesson) => { + expect(getFileContent(archive, 'imsmanifest.xml')).not.toContain(createXmlString('title', lesson.name)); + }); + }); + }); - expect(result).toEqual(expected); + describe('when tasks array is empty', () => { + const setup = async () => setupParams(CommonCartridgeVersion.V_1_1_0, true, false, true); + + it("shouldn't add tasks", async () => { + const { archive, boardTask } = await setup(); + + expect(getFileContent(archive, 'imsmanifest.xml')).not.toContain(createXmlString('title', boardTask.name)); + }); }); - }); - describe('findAllCardsByIds', () => { - const setup = () => { - const cardsIds: Array = new Array(faker.string.uuid()); - const mockCard: CardResponseDto = { - id: cardsIds[0], - title: faker.lorem.word(), - height: faker.number.int(), - elements: [ - { - id: 'element-1', - type: ContentElementType.RICH_TEXT, - content: { - text: faker.string.alphanumeric.toString(), - inputFormat: 'HTML', - }, - timestamps: { - lastUpdatedAt: faker.date.anytime.toString(), - createdAt: faker.date.anytime.toString(), - deletedAt: '', - }, - }, - ], - visibilitySettings: { - publishedAt: '2024-10-01T12:00:00Z', - }, - timeStamps: { - lastUpdatedAt: '2024-10-01T11:00:00Z', - createdAt: faker.date.anytime.toString(), - deletedAt: faker.date.anytime.toString(), - }, - }; - const expected: CardListResponseDto = new CardListResponseDto(new Array(mockCard)); - cardClientAdapterMock.getAllBoardCardsByIds.mockResolvedValue(expected); + describe('when columnBoards array is empty', () => { + const setup = async () => setupParams(CommonCartridgeVersion.V_1_1_0, true, true, false); - return { cardsIds, expected }; - }; - it('should return a card', async () => { - const { cardsIds, expected } = setup(); - const result = await sut.findAllCardsByIds(cardsIds); + it("shouldn't add column boards", async () => { + const { archive, boardSkeleton } = await setup(); - expect(result).toEqual(expected); + expect(getFileContent(archive, 'imsmanifest.xml')).not.toContain( + createXmlString('title', boardSkeleton.columns[0].title) + ); + }); }); }); }); diff --git a/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.ts b/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.ts index 05895643bf1..49fe182de05 100644 --- a/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.ts +++ b/apps/server/src/modules/common-cartridge/service/common-cartridge-export.service.ts @@ -1,43 +1,256 @@ -import { FileDto, FilesStorageClientAdapterService } from '@modules/files-storage-client'; import { Injectable } from '@nestjs/common'; -import { BoardClientAdapter } from '../common-cartridge-client/board-client'; +import { BoardClientAdapter, BoardSkeletonDto, ColumnSkeletonDto } from '../common-cartridge-client/board-client'; import { CourseCommonCartridgeMetadataDto, CoursesClientAdapter } from '../common-cartridge-client/course-client'; import { CourseRoomsClientAdapter } from '../common-cartridge-client/room-client'; -import { RoomBoardDto } from '../common-cartridge-client/room-client/dto/room-board.dto'; +import { + RoomBoardDto, + BoardElementDto, + BoardColumnBoardDto, + BoardLessonDto, + BoardTaskDto, +} from '../common-cartridge-client/room-client/dto'; import { CardClientAdapter } from '../common-cartridge-client/card-client/card-client.adapter'; -import { CardListResponseDto } from '../common-cartridge-client/card-client/dto/card-list-response.dto'; +import { LessonClientAdapter } from '../common-cartridge-client/lesson-client/lesson-client.adapter'; +import { LessonContentDto, LessonDto } from '../common-cartridge-client/lesson-client/dto'; +import { CommonCartridgeFileBuilder } from '../export/builders/common-cartridge-file-builder'; +import { CommonCartridgeVersion } from '../export/common-cartridge.enums'; +import { CommonCartridgeExportMapper } from './common-cartridge.mapper'; +import { CommonCartridgeOrganizationNode } from '../export/builders/common-cartridge-organization-node'; +import { createIdentifier } from '../export/utils'; +import { BoardElementDtoType } from '../common-cartridge-client/room-client/enums/board-element.enum'; +import { CardResponseElementsInnerDto } from '../common-cartridge-client/card-client/types/card-response-elements-inner.type'; +import { + RichTextElementResponseDto, + LinkElementResponseDto, + CardListResponseDto, + CardResponseDto, +} from '../common-cartridge-client/card-client/dto'; @Injectable() export class CommonCartridgeExportService { constructor( - private readonly filesService: FilesStorageClientAdapterService, private readonly boardClientAdapter: BoardClientAdapter, private readonly cardClientAdapter: CardClientAdapter, private readonly coursesClientAdapter: CoursesClientAdapter, - private readonly courseRoomsClientAdapter: CourseRoomsClientAdapter + private readonly courseRoomsClientAdapter: CourseRoomsClientAdapter, + private readonly lessonClientAdapter: LessonClientAdapter, + private readonly mapper: CommonCartridgeExportMapper ) {} - public async findCourseFileRecords(courseId: string): Promise { - const courseFiles = await this.filesService.listFilesOfParent(courseId); + public async exportCourse( + courseId: string, + version: CommonCartridgeVersion, + exportedTopics: string[], + exportedTasks: string[], + exportedColumnBoards: string[] + ): Promise { + const builder = new CommonCartridgeFileBuilder(this.mapper.mapCourseToManifest(version, courseId)); - return courseFiles; + const courseCommonCartridgeMetadata: CourseCommonCartridgeMetadataDto = + await this.findCourseCommonCartridgeMetadata(courseId); + + builder.addMetadata(this.mapper.mapCourseToMetadata(courseCommonCartridgeMetadata)); + + // get room board and the structure of the course + const roomBoard: RoomBoardDto = await this.findRoomBoardByCourseId(courseId); + + // add lessons to organization + await this.addLessons(builder, version, roomBoard.elements, exportedTopics); + + // add tasks to organization + this.addTasks(builder, version, roomBoard.elements, exportedTasks); + + // add column boards and cards to organization + await this.addColumnBoards(builder, roomBoard.elements, exportedColumnBoards); + + return builder.build(); + } + + private addComponentToOrganization( + component: LessonContentDto, + lessonOrganization: CommonCartridgeOrganizationNode + ): void { + const resources = this.mapper.mapContentToResources(component); + + if (Array.isArray(resources)) { + const componentOrganization = lessonOrganization.createChild(this.mapper.mapContentToOrganization(component)); + + resources.forEach((resource) => { + componentOrganization.addResource(resource); + }); + } else { + lessonOrganization.addResource(resources); + } + } + + private async addLessons( + builder: CommonCartridgeFileBuilder, + version: CommonCartridgeVersion, + elements: BoardElementDto[], + topics: string[] + ): Promise { + const filteredLessons = this.filterLessonFromBoardElements(elements); + const lessonsIds = filteredLessons.filter((lesson) => topics.includes(lesson.id)).map((lesson) => lesson.id); + const lessons = await Promise.all(lessonsIds.map((elementId) => this.findLessonById(elementId))); + + lessons.forEach((lesson) => { + const lessonsOrganization = builder.createOrganization(this.mapper.mapLessonToOrganization(lesson)); + + lesson.contents.forEach((content) => { + this.addComponentToOrganization(content, lessonsOrganization); + }); + + lesson.linkedTasks.forEach((task) => { + lessonsOrganization.addResource(this.mapper.mapLinkedTaskToResource(task, version)); + }); + }); + } + + private addTasks( + builder: CommonCartridgeFileBuilder, + version: CommonCartridgeVersion, + elements: BoardElementDto[], + exportedTasks: string[] + ): void { + const tasks: BoardTaskDto[] = this.filterTasksFromBoardElements(elements).filter((task) => + exportedTasks.includes(task.id) + ); + const tasksOrganization = builder.createOrganization({ + title: 'Aufgaben', + identifier: createIdentifier(), + }); + + tasks.forEach((task) => { + tasksOrganization.addResource(this.mapper.mapTaskToResource(task, version)); + }); + } + + private async addColumnBoards( + builder: CommonCartridgeFileBuilder, + elements: BoardElementDto[], + exportedColumnBoards: string[] + ): Promise { + const columnBoards = this.filterColumnBoardFromBoardElement(elements); + const columnBoardsIds = columnBoards + .filter((columnBoard) => exportedColumnBoards.includes(columnBoard.id)) + .map((columBoard) => columBoard.columnBoardId); + const boardSkeletons: BoardSkeletonDto[] = await Promise.all( + columnBoardsIds.map((columnBoardId) => this.findBoardSkeletonById(columnBoardId)) + ); + + await Promise.all( + boardSkeletons.map(async (boardSkeleton) => { + const columnBoardOrganization = builder.createOrganization({ + title: boardSkeleton.title, + identifier: createIdentifier(boardSkeleton.boardId), + }); + + await Promise.all( + boardSkeleton.columns.map((column) => this.addColumnToOrganization(column, columnBoardOrganization)) + ); + }) + ); + } + + private async addColumnToOrganization( + column: ColumnSkeletonDto, + columnBoardOrganization: CommonCartridgeOrganizationNode + ): Promise { + const { columnId } = column; + const columnOrganization = columnBoardOrganization.createChild({ + title: column.title ?? '', + identifier: createIdentifier(columnId), + }); + + if (column.cards.length) { + const cardsIds = column.cards.map((card) => card.cardId); + const listOfCards: CardListResponseDto = await this.findAllCardsByIds(cardsIds); + + listOfCards.data.forEach((card) => { + this.addCardToOrganization(card, columnOrganization); + }); + } + } + + private addCardToOrganization(card: CardResponseDto, columnOrganization: CommonCartridgeOrganizationNode): void { + const cardOrganization = columnOrganization.createChild({ + title: card.title ?? '', + identifier: createIdentifier(card.id), + }); + + card.elements.forEach((element) => { + this.addCardElementToOrganization(element, cardOrganization); + }); + } + + private addCardElementToOrganization( + element: CardResponseElementsInnerDto, + cardOrganization: CommonCartridgeOrganizationNode + ): void { + if (RichTextElementResponseDto.isRichTextElement(element)) { + const resource = this.mapper.mapRichTextElementToResource(element); + + cardOrganization.addResource(resource); + } + + if (LinkElementResponseDto.isLinkElement(element)) { + const resource = this.mapper.mapLinkElementToResource(element); + + cardOrganization.addResource(resource); + } + } + + private filterTasksFromBoardElements(elements: BoardElementDto[]): BoardTaskDto[] { + const tasks = elements + .filter((element) => element.type === BoardElementDtoType.TASK) + .map((element) => element.content as BoardTaskDto); + + return tasks; + } + + private filterLessonFromBoardElements(elements: BoardElementDto[]): BoardLessonDto[] { + const lessons = elements + .filter((element) => element.content instanceof BoardLessonDto) + .map((element) => element.content as BoardLessonDto); + + return lessons; + } + + private filterColumnBoardFromBoardElement(elements: BoardElementDto[]): BoardColumnBoardDto[] { + const columnBoard = elements + .filter((element) => element.type === BoardElementDtoType.COLUMN_BOARD) + .map((element) => element.content as BoardColumnBoardDto); + + return columnBoard; + } + + private async findCourseCommonCartridgeMetadata(courseId: string): Promise { + const courseMetadata = await this.coursesClientAdapter.getCourseCommonCartridgeMetadata(courseId); + + return courseMetadata; + } + + private async findRoomBoardByCourseId(courseId: string): Promise { + const roomBoardDto = await this.courseRoomsClientAdapter.getRoomBoardByCourseId(courseId); + + return roomBoardDto; } - public async findCourseCommonCartridgeMetadata(courseId: string): Promise { - const courseCommonCartridgeMetadata = await this.coursesClientAdapter.getCourseCommonCartridgeMetadata(courseId); + private async findBoardSkeletonById(boardId: string): Promise { + const boardSkeletonDto = await this.boardClientAdapter.getBoardSkeletonById(boardId); - return courseCommonCartridgeMetadata; + return boardSkeletonDto; } - public async findRoomBoardByCourseId(courseId: string): Promise { - const courseRooms = await this.courseRoomsClientAdapter.getRoomBoardByCourseId(courseId); + private async findAllCardsByIds(ids: Array): Promise { + const cardListResponseDto = await this.cardClientAdapter.getAllBoardCardsByIds(ids); - return courseRooms; + return cardListResponseDto; } - public async findAllCardsByIds(ids: Array): Promise { - const cards = await this.cardClientAdapter.getAllBoardCardsByIds(ids); + private async findLessonById(lessonId: string): Promise { + const lessonDto = await this.lessonClientAdapter.getLessonById(lessonId); - return cards; + return lessonDto; } } diff --git a/apps/server/src/modules/common-cartridge/service/common-cartridge.mapper.spec.ts b/apps/server/src/modules/common-cartridge/service/common-cartridge.mapper.spec.ts new file mode 100644 index 00000000000..a4bbafa1423 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/service/common-cartridge.mapper.spec.ts @@ -0,0 +1,375 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { faker } from '@faker-js/faker'; +import { CommonCartridgeExportMapper } from './common-cartridge.mapper'; +import { + boardTaskFactory, + courseMetadataFactory, + lessonContentFactory, + lessonFactory, + lessonLinkedTaskFactory, +} from '../testing/common-cartridge-dtos.factory'; +import { linkElementFactory } from '../testing/link-element.factory'; +import { richTextElementFactroy } from '../testing/rich-text-element.factory'; +import { + CommonCartridgeElementType, + CommonCartridgeIntendedUseType, + CommonCartridgeResourceType, + CommonCartridgeVersion, +} from '../export/common-cartridge.enums'; +import { ComponentGeogebraPropsDto } from '../common-cartridge-client/lesson-client/dto/component-geogebra-props.dto'; +import { ComponentEtherpadPropsDto } from '../common-cartridge-client/lesson-client/dto/component-etherpad-props.dto'; +import { createIdentifier } from '../export/utils'; +import { LessonContentResponseContentInnerDto } from '../common-cartridge-client/lesson-client/dto/lesson-content-response-inner.dto'; +import { + LessonContentDtoComponent, + LessonContentDtoComponentValues, +} from '../common-cartridge-client/lesson-client/dto'; + +const GEOGEBRA_BASE_URL = 'https://geogebra.org'; + +describe('CommonCartridgeExportMapper', () => { + let module: TestingModule; + let sut: CommonCartridgeExportMapper; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [CommonCartridgeExportMapper], + }).compile(); + + sut = module.get(CommonCartridgeExportMapper); + }); + + afterAll(async () => { + await module.close(); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('mapCourseToManifest', () => { + const setup = () => { + const courseId = faker.string.uuid(); + const version = CommonCartridgeVersion.V_1_1_0; + return { courseId, version }; + }; + + describe('when mapping course to manifest', () => { + const { courseId, version } = setup(); + it('should map course to manifest', () => { + const result = sut.mapCourseToManifest(version, courseId); + + expect(result).toEqual({ + version, + identifier: createIdentifier(courseId), + }); + }); + }); + }); + + describe('mapCourseToMetadata', () => { + const setup = () => { + const courseMetadata = courseMetadataFactory.build(); + return { courseMetadata }; + }; + + describe('when mapping metadata of a course to DTO', () => { + const { courseMetadata } = setup(); + it('should map metadata to a CourseCommonCartridgeMetadataDto', () => { + const result = sut.mapCourseToMetadata(courseMetadata); + + expect(result).toEqual({ + type: CommonCartridgeElementType.METADATA, + title: courseMetadata.courseName, + copyrightOwners: courseMetadata.copyRightOwners, + creationDate: courseMetadata.creationDate ? new Date(courseMetadata.creationDate) : new Date(), + }); + }); + }); + }); + + describe('mapLessonToOrganization', () => { + const setup = () => { + const lesson = lessonFactory.build(); + + return { lesson }; + }; + + describe('when mapping lesson to organization', () => { + const { lesson } = setup(); + + it('should map lesson identifier and title to organization', () => { + const result = sut.mapLessonToOrganization(lesson); + + expect(result).toEqual({ + identifier: createIdentifier(lesson.lessonId), + title: lesson.name, + }); + }); + }); + }); + + describe('mapContentToResources', () => { + describe('when lesson content is GeoGebra', () => { + const setup = () => { + const lessonContent = lessonContentFactory.build(); + lessonContent.component = LessonContentDtoComponentValues.GEO_GEBRA; + lessonContent.content = { + materialId: faker.string.uuid(), + }; + return { lessonContent }; + }; + + it('should map lesson content to GeoGebra resources', () => { + const { lessonContent } = setup(); + const result = sut.mapContentToResources(lessonContent); + + expect(result).toEqual({ + type: CommonCartridgeResourceType.WEB_LINK, + identifier: `i${lessonContent.id ?? ''}`, + title: lessonContent.title, + url: `${GEOGEBRA_BASE_URL}/m/${(lessonContent.content as ComponentGeogebraPropsDto).materialId}`, + }); + }); + }); + + describe('when lesson content is Etherpad', () => { + const setup = () => { + const lessonContent = lessonContentFactory.build(); + lessonContent.component = LessonContentDtoComponentValues.ETHERPAD; + lessonContent.content = { + description: faker.lorem.sentence(), + title: faker.lorem.sentence(), + url: faker.internet.url(), + }; + return { lessonContent }; + }; + + it('should map lesson content to Etherpad resources', () => { + const { lessonContent } = setup(); + const result = sut.mapContentToResources(lessonContent); + + expect(result).toEqual({ + type: CommonCartridgeResourceType.WEB_LINK, + identifier: `i${lessonContent.id ?? ''}`, + title: `${(lessonContent.content as ComponentEtherpadPropsDto).title} - ${ + (lessonContent.content as ComponentEtherpadPropsDto).description + }`, + url: (lessonContent.content as ComponentEtherpadPropsDto).url, + }); + }); + }); + + describe('when lesson content is Lernstore', () => { + const setup = () => { + const lessonContent = lessonContentFactory.build(); + lessonContent.component = LessonContentDtoComponentValues.LERNSTORE; + lessonContent.content = { + resources: [faker.internet.url(), faker.internet.url()], + }; + return { lessonContent }; + }; + + it('should map lesson content to Lernstore resources', () => { + const { lessonContent } = setup(); + const result = sut.mapContentToResources(lessonContent); + + expect(result[0]).toEqual({ + type: CommonCartridgeResourceType.WEB_LINK, + identifier: `i${lessonContent.id ?? ''}`, + title: '', + url: '', + }); + }); + }); + + describe('when lesson has no content', () => { + const setup = () => { + const lessonContent = lessonContentFactory.build(); + lessonContent.content = {} as unknown as LessonContentResponseContentInnerDto; + lessonContent.component = {} as unknown as LessonContentDtoComponent; + + return { lessonContent }; + }; + + it('should return an empty array of contents', () => { + const { lessonContent } = setup(); + const result = sut.mapContentToResources(lessonContent); + + expect(result).toBeInstanceOf(Array); + }); + }); + }); + + describe('mapContentToOrganization', () => { + const setup = () => { + const lessonContent = lessonContentFactory.build(); + return { lessonContent }; + }; + describe('when mapping lesson to organization', () => { + it('should map lesson identifier and title to organization', () => { + const { lessonContent } = setup(); + const result = sut.mapContentToOrganization(lessonContent); + + expect(result).toEqual({ + identifier: `i${lessonContent.id ?? ''}`, + title: lessonContent.title, + }); + }); + }); + }); + + describe('mapTaskToResources', () => { + const setup = () => { + const task = boardTaskFactory.build(); + + return { task }; + }; + + describe('when mapping task to resources with version 1.1.0', () => { + const { task } = setup(); + it('should map task to resources with version 1.1.0', () => { + const result = sut.mapTaskToResource(task, CommonCartridgeVersion.V_1_1_0); + + expect(result).toEqual({ + identifier: `i${task.id}`, + title: task.name, + html: `

${task.name}

${task.description ?? ''}

`, + intendedUse: CommonCartridgeIntendedUseType.UNSPECIFIED, + type: CommonCartridgeResourceType.WEB_CONTENT, + }); + }); + }); + + describe('when mapping task to resources with version 1.3.0', () => { + const { task } = setup(); + it('should map task to resources with version 1.3.0', () => { + const result = sut.mapTaskToResource(task, CommonCartridgeVersion.V_1_3_0); + + expect(result).toEqual({ + identifier: `i${task.id}`, + title: task.name, + html: `

${task.name}

${task.description ?? ''}

`, + intendedUse: CommonCartridgeIntendedUseType.ASSIGNMENT, + type: CommonCartridgeResourceType.WEB_CONTENT, + }); + }); + }); + + describe('when mapping task to resources with not supported version', () => { + const { task } = setup(); + it('should map task to resources with version 1.1.0 as default', () => { + const result = sut.mapTaskToResource(task, CommonCartridgeVersion.V_1_4_0); + + expect(result).toEqual({ + identifier: `i${task.id}`, + title: task.name, + html: `

${task.name}

${task.description ?? ''}

`, + intendedUse: CommonCartridgeIntendedUseType.UNSPECIFIED, + type: CommonCartridgeResourceType.WEB_CONTENT, + }); + }); + }); + }); + + describe('mapLinkedTaskToResource', () => { + const setup = () => { + const linkedTask = lessonLinkedTaskFactory.build(); + + return { linkedTask }; + }; + + describe('when mapping linked task to resources with version 1.1.0', () => { + const { linkedTask } = setup(); + it('should map linked task to resources with version 1.1.0', () => { + const result = sut.mapLinkedTaskToResource(linkedTask, CommonCartridgeVersion.V_1_1_0); + + expect(result).toStrictEqual( + expect.objectContaining({ + type: CommonCartridgeResourceType.WEB_CONTENT, + title: linkedTask.name, + html: `

${linkedTask.name}

${linkedTask.description}

`, + intendedUse: CommonCartridgeIntendedUseType.UNSPECIFIED, + }) + ); + }); + }); + + describe('when mapping linked task to resources with version 1.3.0', () => { + const { linkedTask } = setup(); + it('should map linked task to resources with version 1.3.0', () => { + const result = sut.mapLinkedTaskToResource(linkedTask, CommonCartridgeVersion.V_1_3_0); + + expect(result).toStrictEqual( + expect.objectContaining({ + type: CommonCartridgeResourceType.WEB_CONTENT, + title: linkedTask.name, + html: `

${linkedTask.name}

${linkedTask.description}

`, + intendedUse: CommonCartridgeIntendedUseType.ASSIGNMENT, + }) + ); + }); + }); + + describe('when mapping linked task to resources with not supported version', () => { + const { linkedTask } = setup(); + it('should map linked task to resources with version 1.1.0 as default', () => { + const result = sut.mapLinkedTaskToResource(linkedTask, CommonCartridgeVersion.V_1_4_0); + + expect(result).toStrictEqual( + expect.objectContaining({ + type: CommonCartridgeResourceType.WEB_CONTENT, + title: linkedTask.name, + html: `

${linkedTask.name}

${linkedTask.description}

`, + intendedUse: CommonCartridgeIntendedUseType.UNSPECIFIED, + }) + ); + }); + }); + }); + + describe('mapRichTextElementToResource', () => { + const setup = () => { + const richTextElement = richTextElementFactroy.build(); + + return { richTextElement }; + }; + + describe('when mapping rich text element to resources', () => { + const { richTextElement } = setup(); + it('should map rich text element to resources', () => { + const result = sut.mapRichTextElementToResource(richTextElement); + + expect(result).toEqual({ + type: CommonCartridgeResourceType.WEB_CONTENT, + identifier: createIdentifier(richTextElement.id), + title: richTextElement.content.text, + html: `

${richTextElement.content.text}

`, + intendedUse: CommonCartridgeIntendedUseType.UNSPECIFIED, + }); + }); + }); + }); + + describe('mapLinkElementToResource', () => { + const setup = () => { + const linkElement = linkElementFactory.build(); + + return { linkElement }; + }; + + describe('when mapping link element to resources', () => { + const { linkElement } = setup(); + it('should map link element to resources', () => { + const result = sut.mapLinkElementToResource(linkElement); + + expect(result).toEqual({ + type: CommonCartridgeResourceType.WEB_LINK, + identifier: createIdentifier(linkElement.id), + title: linkElement.content.title, + url: linkElement.content.url, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/service/common-cartridge.mapper.ts b/apps/server/src/modules/common-cartridge/service/common-cartridge.mapper.ts new file mode 100644 index 00000000000..7e594081f99 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/service/common-cartridge.mapper.ts @@ -0,0 +1,203 @@ +import sanitizeHtml from 'sanitize-html'; +import { CourseCommonCartridgeMetadataDto } from '../common-cartridge-client/course-client'; +import { + LessonContentDto, + LessonContentDtoComponentValues, + LessonDto, + LessonLinkedTaskDto, +} from '../common-cartridge-client/lesson-client/dto'; +import { CommonCartridgeOrganizationProps } from '../export/builders/common-cartridge-file-builder'; +import { + CommonCartridgeElementType, + CommonCartridgeIntendedUseType, + CommonCartridgeResourceType, + CommonCartridgeVersion, +} from '../export/common-cartridge.enums'; +import { CommonCartridgeElementProps } from '../export/elements/common-cartridge-element-factory'; +import { createIdentifier } from '../export/utils'; +import { CommonCartridgeResourceProps } from '../export/resources/common-cartridge-resource-factory'; +import { BoardTaskDto } from '../common-cartridge-client/room-client/dto/board-task.dto'; +import { RichTextElementResponseDto } from '../common-cartridge-client/card-client/dto/rich-text-element-response.dto'; +import { LinkElementResponseDto } from '../common-cartridge-client/card-client/dto/link-element-response.dto'; +import { ComponentTextPropsDto } from '../common-cartridge-client/lesson-client/dto/component-text-props.dto'; +import { ComponentGeogebraPropsDto } from '../common-cartridge-client/lesson-client/dto/component-geogebra-props.dto'; +import { ComponentLernstorePropsDto } from '../common-cartridge-client/lesson-client/dto/component-lernstore-props.dto'; +import { ComponentEtherpadPropsDto } from '../common-cartridge-client/lesson-client/dto/component-etherpad-props.dto'; + +export class CommonCartridgeExportMapper { + private static readonly GEOGEBRA_BASE_URL: string = 'https://geogebra.org'; + + public mapCourseToManifest( + version: CommonCartridgeVersion, + courseId: string + ): { version: CommonCartridgeVersion; identifier: string } { + return { + version, + identifier: createIdentifier(courseId), + }; + } + + public mapCourseToMetadata(courseMetadata: CourseCommonCartridgeMetadataDto): CommonCartridgeElementProps { + return { + type: CommonCartridgeElementType.METADATA, + title: courseMetadata.courseName, + copyrightOwners: courseMetadata.copyRightOwners, + creationDate: courseMetadata.creationDate ? new Date(courseMetadata.creationDate) : new Date(), + }; + } + + public mapLessonToOrganization(lesson: LessonDto): CommonCartridgeOrganizationProps { + return { + identifier: createIdentifier(lesson.lessonId), + title: lesson.name, + }; + } + + public mapContentToResources( + lessonContent: LessonContentDto + ): CommonCartridgeResourceProps | CommonCartridgeResourceProps[] { + switch (lessonContent.component) { + case LessonContentDtoComponentValues.TEXT: + return { + type: CommonCartridgeResourceType.WEB_CONTENT, + identifier: createIdentifier(lessonContent.id), + title: lessonContent.title, + html: `

${lessonContent.title ?? ''}

${ + (lessonContent.content as ComponentTextPropsDto).text ?? '' + }

`, + intendedUse: CommonCartridgeIntendedUseType.UNSPECIFIED, + }; + case LessonContentDtoComponentValues.GEO_GEBRA: + return { + type: CommonCartridgeResourceType.WEB_LINK, + identifier: createIdentifier(lessonContent.id), + title: lessonContent.title, + url: `${CommonCartridgeExportMapper.GEOGEBRA_BASE_URL}/m/${ + (lessonContent.content as ComponentGeogebraPropsDto).materialId + }`, + }; + case LessonContentDtoComponentValues.ETHERPAD: + return { + type: CommonCartridgeResourceType.WEB_LINK, + identifier: createIdentifier(lessonContent.id), + title: `${(lessonContent.content as ComponentEtherpadPropsDto).title} - ${ + (lessonContent.content as ComponentEtherpadPropsDto).description + }`, + url: (lessonContent.content as ComponentEtherpadPropsDto).url, + }; + case LessonContentDtoComponentValues.LERNSTORE: { + const { resources } = lessonContent.content as ComponentLernstorePropsDto; + const extractedResources = this.extractResources(resources); + return ( + extractedResources.map((resource) => { + return { + type: CommonCartridgeResourceType.WEB_LINK, + identifier: createIdentifier(lessonContent.id), + title: resource.title, + url: resource.url, + }; + }) || [] + ); + } + default: + return []; + } + } + + // should be removed after fixing the issue with the Lernstore component + private extractResources(resources: string[]): { title: string; url: string }[] { + return resources.map((resource) => { + const fields = resource.split(',').map((field) => field.trim()); + let title = ''; + let url = ''; + + fields.forEach((field) => { + const [key, value] = field.split('=').map((part) => part.trim()); + if (key === 'title') title = value; + if (key === 'url') url = value; + }); + + return { title, url }; + }); + } + + public mapContentToOrganization(content: LessonContentDto): CommonCartridgeOrganizationProps { + return { + identifier: createIdentifier(content.id), + title: content.title, + }; + } + + public mapTaskToResource(task: BoardTaskDto, version: CommonCartridgeVersion): CommonCartridgeResourceProps { + const intendedUse = ((): CommonCartridgeIntendedUseType => { + switch (version) { + case CommonCartridgeVersion.V_1_1_0: + return CommonCartridgeIntendedUseType.UNSPECIFIED; + case CommonCartridgeVersion.V_1_3_0: + return CommonCartridgeIntendedUseType.ASSIGNMENT; + default: + return CommonCartridgeIntendedUseType.UNSPECIFIED; + } + })(); + + return { + type: CommonCartridgeResourceType.WEB_CONTENT, + identifier: createIdentifier(task.id), + title: task.name, + html: `

${task.name}

${task.description ?? ''}

`, + intendedUse, + }; + } + + public mapLinkedTaskToResource( + task: LessonLinkedTaskDto, + version: CommonCartridgeVersion + ): CommonCartridgeResourceProps { + const intendedUse = ((): CommonCartridgeIntendedUseType => { + switch (version) { + case CommonCartridgeVersion.V_1_1_0: + return CommonCartridgeIntendedUseType.UNSPECIFIED; + case CommonCartridgeVersion.V_1_3_0: + return CommonCartridgeIntendedUseType.ASSIGNMENT; + default: + return CommonCartridgeIntendedUseType.UNSPECIFIED; + } + })(); + + return { + type: CommonCartridgeResourceType.WEB_CONTENT, + identifier: createIdentifier(), + title: task.name, + html: `

${task.name}

${task.description}

`, + intendedUse, + }; + } + + public mapRichTextElementToResource(element: RichTextElementResponseDto): CommonCartridgeResourceProps { + return { + type: CommonCartridgeResourceType.WEB_CONTENT, + identifier: createIdentifier(element.id), + title: this.getTextTitle(element.content.text), + html: `

${element.content.text}

`, + intendedUse: CommonCartridgeIntendedUseType.UNSPECIFIED, + }; + } + + public mapLinkElementToResource(element: LinkElementResponseDto): CommonCartridgeResourceProps { + return { + type: CommonCartridgeResourceType.WEB_LINK, + identifier: createIdentifier(element.id), + title: element.content.title, + url: element.content.url, + }; + } + + private getTextTitle(text: string): string { + const title = sanitizeHtml(text, { + allowedTags: [], + allowedAttributes: {}, + }).slice(0, 20); + + return title.length > 20 ? `${title}...` : title; + } +} diff --git a/apps/server/src/modules/common-cartridge/testing/common-cartridge-dtos.factory.ts b/apps/server/src/modules/common-cartridge/testing/common-cartridge-dtos.factory.ts new file mode 100644 index 00000000000..f4978330dc3 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/testing/common-cartridge-dtos.factory.ts @@ -0,0 +1,200 @@ +import { faker } from '@faker-js/faker'; +import { Factory } from 'fishery'; +import { CourseCommonCartridgeMetadataDto } from '../common-cartridge-client/course-client'; +import { LessonContentDto, LessonDto, LessonLinkedTaskDto } from '../common-cartridge-client/lesson-client/dto'; +import { BoardSkeletonDto, CardSkeletonDto, ColumnSkeletonDto } from '../common-cartridge-client/board-client'; +import { CardListResponseDto } from '../common-cartridge-client/card-client/dto/card-list-response.dto'; +import { CardResponseDto } from '../common-cartridge-client/card-client/dto/card-response.dto'; +import { + RoomBoardDto, + BoardTaskStatusDto, + BoardTaskDto, + BoardLessonDto, + BoardColumnBoardDto, +} from '../common-cartridge-client/room-client/dto'; +import { BoardElementDtoType } from '../common-cartridge-client/room-client/enums/board-element.enum'; +import { BoardLayout } from '../common-cartridge-client/room-client/enums/board-layout.enum'; +import { richTextElementFactroy } from './rich-text-element.factory'; +import { linkElementFactory } from './link-element.factory'; + +export const courseMetadataFactory = Factory.define(({ sequence }) => { + return { + id: sequence.toString(), + courseName: faker.lorem.sentence(), + creationDate: faker.date.recent().toISOString(), + copyRightOwners: [faker.person.fullName(), faker.person.fullName()], + }; +}); + +export const cardFactory = Factory.define(({ sequence }) => { + return { + cardId: sequence.toString(), + height: faker.number.int(), + }; +}); + +export const columnFactory = Factory.define(({ sequence }) => { + return { + columnId: sequence.toString(), + title: faker.lorem.sentence(), + cards: [cardFactory.build(), cardFactory.build()], + }; +}); + +export const columnBoardFactory = Factory.define(({ sequence }) => { + return { + boardId: sequence.toString(), + title: faker.lorem.sentence(), + columns: [columnFactory.build(), columnFactory.build()], + isVisible: faker.datatype.boolean(), + layout: faker.lorem.word(), + }; +}); + +export const cardResponseFactory = Factory.define(({ sequence }) => { + return { + id: sequence.toString(), + height: faker.number.int(), + elements: [richTextElementFactroy.build(), linkElementFactory.build()], + visibilitySettings: { + publishedAt: faker.date.recent().toISOString(), + }, + timeStamps: { + lastUpdatedAt: faker.date.recent().toISOString(), + createdAt: faker.date.recent().toISOString(), + deletedAt: undefined, + }, + title: faker.lorem.sentence(), + }; +}); + +export const listOfCardResponseFactory = Factory.define(() => { + return { + data: [cardResponseFactory.build(), cardResponseFactory.build()], + }; +}); + +export const lessonLinkedTaskFactory = Factory.define(() => { + return { + name: faker.lorem.word(), + description: faker.lorem.paragraph(), + descriptionInputFormat: 'plainText', + availableDate: faker.date.recent().toISOString(), + dueDate: faker.date.future().toISOString(), + private: faker.datatype.boolean(), + publicSubmissions: faker.datatype.boolean(), + teamSubmissions: faker.datatype.boolean(), + creator: faker.internet.email(), + courseId: null, + submissionIds: [faker.string.uuid(), faker.string.uuid()], + finishedIds: [faker.string.uuid(), faker.string.uuid()], + }; +}); + +export const lernstoreContentFactory = Factory.define(({ sequence }) => { + return { + id: sequence.toString(), + type: 'lernstore', + content: { resources: [faker.internet.url(), faker.internet.url(), faker.internet.url()] }, + title: faker.lorem.sentence(), + component: 'lernstore', + hidden: faker.datatype.boolean(), + }; +}); + +export const lessonContentFactory = Factory.define(({ sequence }) => { + return { + id: sequence.toString(), + type: faker.lorem.word(), + content: { text: 'text' }, + title: faker.lorem.sentence(), + component: 'text', + hidden: faker.datatype.boolean(), + }; +}); + +export const lessonFactory = Factory.define(({ sequence }) => { + return { + lessonId: sequence.toString(), + name: faker.lorem.word(), + courseId: undefined, + courseGroupId: faker.string.uuid(), + hidden: faker.datatype.boolean(), + position: faker.number.int(), + contents: [lessonContentFactory.build(), lernstoreContentFactory.build()], + materials: [], + linkedTasks: [lessonLinkedTaskFactory.build(), lessonLinkedTaskFactory.build()], + }; +}); + +export const boardLessonFactory = Factory.define(() => { + return { + id: faker.string.uuid(), + name: faker.lorem.word(), + courseName: undefined, + hidden: faker.datatype.boolean(), + numberOfPublishedTasks: faker.number.int(), + numberOfDraftTasks: faker.number.int(), + numberOfPlannedTasks: faker.number.int(), + createdAt: faker.date.recent().toISOString(), + updatedAt: faker.date.recent().toISOString(), + }; +}); + +export const boardTaskFactory = Factory.define(({ sequence }) => { + return { + id: sequence.toString(), + name: faker.lorem.word(), + createdAt: faker.date.recent().toISOString(), + updatedAt: faker.date.recent().toISOString(), + availableDate: faker.date.recent().toISOString(), + courseName: undefined, + description: faker.lorem.word(), + displayColor: faker.lorem.word(), + dueDate: faker.date.recent().toISOString(), + status: new BoardTaskStatusDto({ + submitted: faker.number.int(), + maxSubmissions: faker.number.int(), + graded: faker.number.int(), + isDraft: faker.datatype.boolean(), + isSubstitutionTeacher: faker.datatype.boolean(), + isFinished: faker.datatype.boolean(), + }), + }; +}); + +export const boardCloumnBoardFactory = Factory.define(() => { + return { + id: faker.string.uuid(), + title: faker.lorem.word(), + published: faker.datatype.boolean(), + createdAt: faker.date.recent().toISOString(), + updatedAt: faker.date.recent().toISOString(), + columnBoardId: faker.string.uuid(), + layout: BoardLayout.COLUMNS, + }; +}); + +export const roomFactory = Factory.define(({ sequence }) => { + return { + roomId: sequence.toString(), + title: faker.lorem.word(), + displayColor: faker.lorem.word(), + elements: [ + { + type: BoardElementDtoType.TASK, + content: boardTaskFactory.build(), + }, + { + type: BoardElementDtoType.LESSON, + content: boardLessonFactory.build(), + }, + { + type: BoardElementDtoType.COLUMN_BOARD, + content: boardCloumnBoardFactory.build(), + }, + ], + isArchived: faker.datatype.boolean(), + isSynchronized: faker.datatype.boolean(), + }; +}); diff --git a/apps/server/src/modules/common-cartridge/testing/link-element.factory.ts b/apps/server/src/modules/common-cartridge/testing/link-element.factory.ts new file mode 100644 index 00000000000..726e711d765 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/testing/link-element.factory.ts @@ -0,0 +1,28 @@ +import { faker } from '@faker-js/faker'; +import { BaseFactory } from '@shared/testing'; +import { Factory } from 'fishery'; +import { LinkElementContentDto } from '../common-cartridge-client/card-client/dto/link-element-content.dto'; +import { LinkElementResponseDto } from '../common-cartridge-client/card-client/dto/link-element-response.dto'; +import { ContentElementType } from '../common-cartridge-client/card-client/enums/content-element-type.enum'; + +export const linkElementContentFactory = Factory.define(() => { + return { + url: faker.internet.url(), + title: faker.lorem.word(), + description: faker.lorem.sentence(), + }; +}); + +class LinkElementFactory extends BaseFactory> {} +export const linkElementFactory = LinkElementFactory.define(LinkElementResponseDto, () => { + return { + id: faker.string.uuid(), + type: ContentElementType.LINK, + content: linkElementContentFactory.build(), + timestamps: { + lastUpdatedAt: faker.date.recent().toISOString(), + createdAt: faker.date.recent().toISOString(), + deletedAt: undefined, + }, + }; +}); diff --git a/apps/server/src/modules/common-cartridge/testing/rich-text-element.factory.ts b/apps/server/src/modules/common-cartridge/testing/rich-text-element.factory.ts new file mode 100644 index 00000000000..01e880ae0de --- /dev/null +++ b/apps/server/src/modules/common-cartridge/testing/rich-text-element.factory.ts @@ -0,0 +1,27 @@ +import { BaseFactory } from '@shared/testing'; +import { faker } from '@faker-js/faker'; +import { Factory } from 'fishery'; +import { RichTextElementResponseDto } from '../common-cartridge-client/card-client/dto/rich-text-element-response.dto'; +import { RichTextElementContentDto } from '../common-cartridge-client/card-client/dto/rich-text-element-content.dto'; +import { ContentElementType } from '../common-cartridge-client/card-client/enums/content-element-type.enum'; + +export const richTextElementContentFactory = Factory.define(() => { + return { + text: faker.lorem.word(), + inputFormat: 'plainText', + }; +}); + +class RichTextElement extends BaseFactory> {} +export const richTextElementFactroy = RichTextElement.define(RichTextElementResponseDto, () => { + return { + id: faker.string.uuid(), + type: ContentElementType.RICH_TEXT, + content: richTextElementContentFactory.build(), + timestamps: { + lastUpdatedAt: faker.date.recent().toISOString(), + createdAt: faker.date.recent().toISOString(), + deletedAt: undefined, + }, + }; +}); diff --git a/apps/server/src/modules/common-cartridge/uc/common-cartridge.uc.spec.ts b/apps/server/src/modules/common-cartridge/uc/common-cartridge.uc.spec.ts index 8fc9bcba92b..f047c162ef2 100644 --- a/apps/server/src/modules/common-cartridge/uc/common-cartridge.uc.spec.ts +++ b/apps/server/src/modules/common-cartridge/uc/common-cartridge.uc.spec.ts @@ -1,10 +1,9 @@ import { faker } from '@faker-js/faker'; import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { CourseFileIdsResponse } from '../controller/dto'; import { CommonCartridgeExportService } from '../service/common-cartridge-export.service'; import { CommonCartridgeUc } from './common-cartridge.uc'; -import { CourseExportBodyResponse } from '../controller/dto/course-export-body.response'; +import { CommonCartridgeVersion } from '../export/common-cartridge.enums'; describe('CommonCartridgeUc', () => { let module: TestingModule; @@ -37,31 +36,28 @@ describe('CommonCartridgeUc', () => { describe('exportCourse', () => { const setup = () => { const courseId = faker.string.uuid(); - const expected = new CourseExportBodyResponse({ - courseFileIds: new CourseFileIdsResponse([]), - courseCommonCartridgeMetadata: { - id: courseId, - title: faker.lorem.sentence(), - copyRightOwners: [], - }, - }); + const version = CommonCartridgeVersion.V_1_1_0; + const topics = [faker.lorem.sentence(), faker.lorem.sentence()]; + const tasks = [faker.lorem.sentence(), faker.lorem.sentence()]; + const columnBoards = [faker.lorem.sentence(), faker.lorem.sentence()]; + const expected = Buffer.alloc(0); - commonCartridgeExportServiceMock.findCourseFileRecords.mockResolvedValue([]); - commonCartridgeExportServiceMock.findCourseCommonCartridgeMetadata.mockResolvedValue({ - id: expected.courseCommonCartridgeMetadata?.id ?? '', - title: expected.courseCommonCartridgeMetadata?.title ?? '', - copyRightOwners: expected.courseCommonCartridgeMetadata?.copyRightOwners ?? [], - }); + commonCartridgeExportServiceMock.exportCourse.mockResolvedValue(expected); - return { courseId, expected }; + return { courseId, version, topics, tasks, columnBoards, expected }; }; it('should return a course export response with file IDs and metadata of a course', async () => { - const { courseId, expected } = setup(); - - const result = await sut.exportCourse(courseId); - - expect(result).toEqual(expected); + const { courseId, expected, version, tasks, columnBoards, topics } = setup(); + + expect(await sut.exportCourse(courseId, version, topics, tasks, columnBoards)).toEqual(expected); + expect(commonCartridgeExportServiceMock.exportCourse).toHaveBeenCalledWith( + courseId, + version, + topics, + tasks, + columnBoards + ); }); }); }); diff --git a/apps/server/src/modules/common-cartridge/uc/common-cartridge.uc.ts b/apps/server/src/modules/common-cartridge/uc/common-cartridge.uc.ts index 8caa9381633..d7d00e6e02e 100644 --- a/apps/server/src/modules/common-cartridge/uc/common-cartridge.uc.ts +++ b/apps/server/src/modules/common-cartridge/uc/common-cartridge.uc.ts @@ -1,25 +1,21 @@ import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; -import { CourseFileIdsResponse } from '../controller/dto'; import { CommonCartridgeExportService } from '../service/common-cartridge-export.service'; -import { CourseExportBodyResponse } from '../controller/dto/course-export-body.response'; -import { CourseCommonCartridgeMetadataDto } from '../common-cartridge-client/course-client'; +import { CommonCartridgeVersion } from '../export/common-cartridge.enums'; @Injectable() export class CommonCartridgeUc { constructor(private readonly exportService: CommonCartridgeExportService) {} - public async exportCourse(courseId: EntityId): Promise { - const files = await this.exportService.findCourseFileRecords(courseId); - const courseFileIds = new CourseFileIdsResponse(files.map((file) => file.id)); - const courseCommonCartridgeMetadata: CourseCommonCartridgeMetadataDto = - await this.exportService.findCourseCommonCartridgeMetadata(courseId); + public async exportCourse( + courseId: EntityId, + version: CommonCartridgeVersion, + topics: string[], + tasks: string[], + columnBoards: string[] + ): Promise { + const exportedCourse = await this.exportService.exportCourse(courseId, version, topics, tasks, columnBoards); - const response = new CourseExportBodyResponse({ - courseFileIds, - courseCommonCartridgeMetadata, - }); - - return response; + return exportedCourse; } } diff --git a/apps/server/src/modules/copy-helper/types/copy.types.ts b/apps/server/src/modules/copy-helper/types/copy.types.ts index 703ab28aa56..733b898112d 100644 --- a/apps/server/src/modules/copy-helper/types/copy.types.ts +++ b/apps/server/src/modules/copy-helper/types/copy.types.ts @@ -52,6 +52,7 @@ export enum CopyElementType { TASK_GROUP = 'TASK_GROUP', TIME_GROUP = 'TIME_GROUP', USER_GROUP = 'USER_GROUP', + VIDEO_CONFERENCE_ELEMENT = 'VIDEO_CONFERENCE_ELEMENT', } export enum CopyStatusEnum { diff --git a/apps/server/src/modules/idp-console/idp-console.config.ts b/apps/server/src/modules/idp-console/idp-console.config.ts index 08a1e9fe301..30b14264858 100644 --- a/apps/server/src/modules/idp-console/idp-console.config.ts +++ b/apps/server/src/modules/idp-console/idp-console.config.ts @@ -1,12 +1,12 @@ +import { Configuration } from '@hpi-schul-cloud/commons'; import { ConsoleWriterConfig } from '@infra/console'; -import { LoggerConfig } from '@src/core/logger'; +import { RabbitMqConfig } from '@infra/rabbitmq'; +import { SchulconnexClientConfig } from '@infra/schulconnex-client'; import { AccountConfig } from '@modules/account'; -import { UserConfig } from '@modules/user'; import { SynchronizationConfig } from '@modules/synchronization'; -import { SchulconnexClientConfig } from '@infra/schulconnex-client'; -import { Configuration } from '@hpi-schul-cloud/commons'; +import { UserConfig } from '@modules/user'; import { LanguageType } from '@shared/domain/interface'; -import { RabbitMqConfig } from '@infra/rabbitmq'; +import { LoggerConfig } from '@src/core/logger'; export interface IdpConsoleConfig extends ConsoleWriterConfig, @@ -33,6 +33,9 @@ const config: IdpConsoleConfig = { TEACHER_VISIBILITY_FOR_EXTERNAL_TEAM_INVITATION: Configuration.get( 'TEACHER_VISIBILITY_FOR_EXTERNAL_TEAM_INVITATION' ) as string, + SCHULCONNEX_CLIENT__PERSON_INFO_TIMEOUT_IN_MS: Configuration.get( + 'SCHULCONNEX_CLIENT__PERSON_INFO_TIMEOUT_IN_MS' + ) as number, SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS: Configuration.get( 'SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS' ) as number, diff --git a/apps/server/src/modules/learnroom/controller/dto/course-export.body.params.ts b/apps/server/src/modules/learnroom/controller/dto/course-export.body.params.ts index 1edbcf7869e..a2e86355e8a 100644 --- a/apps/server/src/modules/learnroom/controller/dto/course-export.body.params.ts +++ b/apps/server/src/modules/learnroom/controller/dto/course-export.body.params.ts @@ -1,8 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsArray } from 'class-validator'; +import { IsArray, IsString } from 'class-validator'; export class CourseExportBodyParams { @IsArray() + @IsString({ each: true }) @ApiProperty({ description: 'The list of ids of topics which should be exported. If empty no topics are exported.', type: [String], @@ -10,14 +11,15 @@ export class CourseExportBodyParams { public readonly topics!: string[]; @IsArray() + @IsString({ each: true }) @ApiProperty({ description: 'The list of ids of tasks which should be exported. If empty no tasks are exported.', type: [String], }) public readonly tasks!: string[]; - // AI next 6 lines @IsArray() + @IsString({ each: true }) @ApiProperty({ description: 'The list of ids of column boards which should be exported. If empty no column boards are exported.', type: [String], diff --git a/apps/server/src/modules/lesson/controller/dto/lesson-content.response.ts b/apps/server/src/modules/lesson/controller/dto/lesson-content.response.ts index 0af0d4006ba..3a52a17b47b 100644 --- a/apps/server/src/modules/lesson/controller/dto/lesson-content.response.ts +++ b/apps/server/src/modules/lesson/controller/dto/lesson-content.response.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger'; import { EntityId } from '@shared/domain/types'; import { ComponentEtherpadProperties, @@ -11,6 +11,65 @@ import { ComponentType, } from '@shared/domain/entity/lesson.entity'; +// eslint problem will be solved in EW-1090 +class ComponentTextPropsImpl implements ComponentTextProperties { + @ApiProperty({ nullable: false }) + text!: string; +} + +class ComponentEtherpadPropsImpl implements ComponentEtherpadProperties { + @ApiProperty({ nullable: false, description: 'description of a Etherpad component' }) + description!: string; + + @ApiProperty({ nullable: false, description: 'title of a Etherpad component' }) + title!: string; + + @ApiProperty({ nullable: false, description: 'url of a Etherpad component' }) + url!: string; +} + +class ComponentNexboardPropsImpl implements ComponentNexboardProperties { + @ApiProperty({ nullable: false, description: 'board of a Nexboard component' }) + board!: string; + + @ApiProperty({ nullable: false, description: 'description of a Nexboard component' }) + description!: string; + + @ApiProperty({ nullable: false, description: 'title of a Nexboard component' }) + title!: string; + + @ApiProperty({ nullable: false, description: 'url of a Nexboard component' }) + url!: string; +} + +class ComponentGeogebraPropsImpl implements ComponentGeogebraProperties { + @ApiProperty({ nullable: false, description: 'materialId of a Geogebra component' }) + materialId!: string; +} + +class ComponentInternalPropsImpl implements ComponentInternalProperties { + @ApiProperty({ nullable: false, description: 'url of a Internal component' }) + url!: string; +} + +class ComponentLernstorePropsImpl implements ComponentLernstoreProperties { + @ApiProperty({ nullable: false, description: 'resources of a Lernstore component' }) + resources!: { + client: string; + description: string; + merlinReference?: string; + title: string; + url: string; + }[]; +} +@ApiExtraModels( + ComponentTextPropsImpl, + ComponentEtherpadPropsImpl, + ComponentGeogebraPropsImpl, + ComponentInternalPropsImpl, + ComponentLernstorePropsImpl, + ComponentNexboardPropsImpl +) export class LessonContentResponse { constructor(lessonContent: ComponentProperties) { this.id = lessonContent._id; @@ -22,14 +81,23 @@ export class LessonContentResponse { this.content = lessonContent.content; } - @ApiProperty() + @ApiProperty({ + oneOf: [ + { $ref: getSchemaPath(ComponentTextPropsImpl) }, + { $ref: getSchemaPath(ComponentEtherpadPropsImpl) }, + { $ref: getSchemaPath(ComponentGeogebraPropsImpl) }, + { $ref: getSchemaPath(ComponentInternalPropsImpl) }, + { $ref: getSchemaPath(ComponentLernstorePropsImpl) }, + { $ref: getSchemaPath(ComponentNexboardPropsImpl) }, + ], + }) content?: - | ComponentTextProperties - | ComponentEtherpadProperties - | ComponentGeogebraProperties - | ComponentInternalProperties - | ComponentLernstoreProperties - | ComponentNexboardProperties; + | ComponentTextPropsImpl + | ComponentEtherpadPropsImpl + | ComponentGeogebraPropsImpl + | ComponentInternalPropsImpl + | ComponentLernstorePropsImpl + | ComponentNexboardPropsImpl; @ApiProperty({ description: 'The id of the Material entity', diff --git a/apps/server/src/modules/oauth/service/oauth.service.spec.ts b/apps/server/src/modules/oauth/service/oauth.service.spec.ts index c850532d7ea..1a1101c2dca 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.spec.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.spec.ts @@ -18,6 +18,7 @@ import { LegacyLogger } from '@src/core/logger'; import { OauthDataDto } from '@src/modules/provisioning/dto'; import { System } from '@src/modules/system'; import jwt, { JwtPayload } from 'jsonwebtoken'; +import { externalUserDtoFactory } from '../../provisioning/testing'; import { OAuthTokenDto } from '../interface'; import { OauthConfigMissingLoggableException, @@ -378,9 +379,9 @@ describe('OAuthService', () => { provisioningStrategy: SystemProvisioningStrategy.SANIS, provisioningUrl: 'https://mock.person-info.de/', }, - externalUser: { + externalUser: externalUserDtoFactory.build({ externalId: externalUserId, - }, + }), externalSchool: { externalId: 'externalSchoolId', name: 'External School', @@ -429,9 +430,9 @@ describe('OAuthService', () => { provisioningStrategy: SystemProvisioningStrategy.SANIS, provisioningUrl: 'https://mock.person-info.de/', }, - externalUser: { + externalUser: externalUserDtoFactory.build({ externalId: externalUserId, - }, + }), externalSchool: { externalId: 'externalSchoolId', name: 'External School', @@ -476,9 +477,9 @@ describe('OAuthService', () => { provisioningStrategy: SystemProvisioningStrategy.SANIS, provisioningUrl: 'https://mock.person-info.de/', }, - externalUser: { + externalUser: externalUserDtoFactory.build({ externalId: externalUserId, - }, + }), externalSchool: { externalId: 'externalSchoolId', name: 'External School', @@ -544,9 +545,9 @@ describe('OAuthService', () => { provisioningStrategy: SystemProvisioningStrategy.SANIS, provisioningUrl: 'https://mock.person-info.de/', }, - externalUser: { + externalUser: externalUserDtoFactory.build({ externalId: externalUserId, - }, + }), externalSchool: { externalId: externalSchoolId, name: school.name, @@ -612,9 +613,9 @@ describe('OAuthService', () => { provisioningStrategy: SystemProvisioningStrategy.SANIS, provisioningUrl: 'https://mock.person-info.de/', }, - externalUser: { + externalUser: externalUserDtoFactory.build({ externalId: externalUserId, - }, + }), externalSchool: { externalId: externalSchoolId, name: school.name, @@ -675,9 +676,9 @@ describe('OAuthService', () => { provisioningStrategy: SystemProvisioningStrategy.SANIS, provisioningUrl: 'https://mock.person-info.de/', }, - externalUser: { + externalUser: externalUserDtoFactory.build({ externalId: externalUserId, - }, + }), externalSchool: { externalId: externalSchoolId, name: school.name, @@ -737,9 +738,9 @@ describe('OAuthService', () => { provisioningStrategy: SystemProvisioningStrategy.SANIS, provisioningUrl: 'https://mock.person-info.de/', }, - externalUser: { + externalUser: externalUserDtoFactory.build({ externalId: externalUserId, - }, + }), externalSchool: { externalId: externalSchoolId, name: school.name, @@ -804,9 +805,9 @@ describe('OAuthService', () => { provisioningStrategy: SystemProvisioningStrategy.SANIS, provisioningUrl: 'https://mock.person-info.de/', }, - externalUser: { + externalUser: externalUserDtoFactory.build({ externalId: externalUserId, - }, + }), externalSchool: { externalId: externalSchoolId, name: school.name, diff --git a/apps/server/src/modules/provisioning/dto/external-user.dto.ts b/apps/server/src/modules/provisioning/dto/external-user.dto.ts index bc5bcc9c80b..013e8e370fa 100644 --- a/apps/server/src/modules/provisioning/dto/external-user.dto.ts +++ b/apps/server/src/modules/provisioning/dto/external-user.dto.ts @@ -1,17 +1,17 @@ import { RoleName } from '@shared/domain/interface'; export class ExternalUserDto { - externalId: string; + public externalId: string; - firstName?: string; + public firstName?: string; - lastName?: string; + public lastName?: string; - email?: string; + public email?: string; - roles?: RoleName[]; + public roles: RoleName[]; - birthday?: Date; + public birthday?: Date; constructor(props: ExternalUserDto) { this.externalId = props.externalId; diff --git a/apps/server/src/modules/provisioning/loggable/fetching-policies-info-failed.loggable.spec.ts b/apps/server/src/modules/provisioning/loggable/fetching-policies-info-failed.loggable.spec.ts index 60818f61582..d3b188c975c 100644 --- a/apps/server/src/modules/provisioning/loggable/fetching-policies-info-failed.loggable.spec.ts +++ b/apps/server/src/modules/provisioning/loggable/fetching-policies-info-failed.loggable.spec.ts @@ -1,12 +1,10 @@ -import { ExternalUserDto } from '../dto'; +import { externalUserDtoFactory } from '../testing'; import { FetchingPoliciesInfoFailedLoggable } from './fetching-policies-info-failed.loggable'; describe(FetchingPoliciesInfoFailedLoggable.name, () => { describe('getLogMessage', () => { const setup = () => { - const externalUserDto: ExternalUserDto = { - externalId: 'someId', - }; + const externalUserDto = externalUserDtoFactory.build(); const policiesInfoEndpoint = 'someEndpoint'; const loggable = new FetchingPoliciesInfoFailedLoggable(externalUserDto, policiesInfoEndpoint); diff --git a/apps/server/src/modules/provisioning/loggable/group-provisioning-info.loggable.spec.ts b/apps/server/src/modules/provisioning/loggable/group-provisioning-info.loggable.spec.ts new file mode 100644 index 00000000000..3e880155d47 --- /dev/null +++ b/apps/server/src/modules/provisioning/loggable/group-provisioning-info.loggable.spec.ts @@ -0,0 +1,39 @@ +import { externalGroupDtoFactory, externalGroupUserDtoFactory } from '../testing'; +import { GroupProvisioningInfoLoggable } from './group-provisioning-info.loggable'; + +describe(GroupProvisioningInfoLoggable.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const groupCount = 2; + const otherUserCount = 5; + const totalUserCount = groupCount * otherUserCount + groupCount; + const externalGroups = externalGroupDtoFactory.buildList(groupCount, { + otherUsers: externalGroupUserDtoFactory.buildList(otherUserCount), + }); + + const loggable = new GroupProvisioningInfoLoggable(externalGroups, 100, 'igorHatGesagt'); + + return { + loggable, + totalUserCount, + groupCount, + }; + }; + + it('should return a loggable message', () => { + const { loggable, totalUserCount, groupCount } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + message: 'Group provisioning has finished.', + data: { + groupCount, + userCount: totalUserCount, + durationMs: 100, + externalUserId: 'igorHatGesagt', + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/provisioning/loggable/group-provisioning-info.loggable.ts b/apps/server/src/modules/provisioning/loggable/group-provisioning-info.loggable.ts new file mode 100644 index 00000000000..57819c45388 --- /dev/null +++ b/apps/server/src/modules/provisioning/loggable/group-provisioning-info.loggable.ts @@ -0,0 +1,27 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { ExternalGroupDto } from '../dto'; + +export class GroupProvisioningInfoLoggable implements Loggable { + constructor( + private readonly groups: ExternalGroupDto[], + private readonly durationMs: number, + private readonly externalUserId: string + ) {} + + public getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + const userCount = this.groups.reduce( + (count: number, group: ExternalGroupDto) => count + (group.otherUsers?.length ?? 0), + this.groups.length + ); + + return { + message: 'Group provisioning has finished.', + data: { + groupCount: this.groups.length, + userCount, + durationMs: this.durationMs, + externalUserId: this.externalUserId, + }, + }; + } +} diff --git a/apps/server/src/modules/provisioning/loggable/index.ts b/apps/server/src/modules/provisioning/loggable/index.ts index 00068126737..93010e22353 100644 --- a/apps/server/src/modules/provisioning/loggable/index.ts +++ b/apps/server/src/modules/provisioning/loggable/index.ts @@ -6,3 +6,6 @@ export * from './group-role-unknown.loggable'; export { SchoolExternalToolCreatedLoggable } from './school-external-tool-created.loggable'; export { FetchingPoliciesInfoFailedLoggable } from './fetching-policies-info-failed.loggable'; export { PoliciesInfoErrorResponseLoggable } from './policies-info-error-response-loggable'; +export { UserRoleUnknownLoggableException } from './user-role-unknown.loggable-exception'; +export { SchoolMissingLoggableException } from './school-missing.loggable-exception'; +export { GroupProvisioningInfoLoggable } from './group-provisioning-info.loggable'; diff --git a/apps/server/src/modules/provisioning/loggable/school-missing.loggable-exception.spec.ts b/apps/server/src/modules/provisioning/loggable/school-missing.loggable-exception.spec.ts new file mode 100644 index 00000000000..45d19533798 --- /dev/null +++ b/apps/server/src/modules/provisioning/loggable/school-missing.loggable-exception.spec.ts @@ -0,0 +1,32 @@ +import { externalUserDtoFactory } from '../testing'; +import { SchoolMissingLoggableException } from './school-missing.loggable-exception'; + +describe(SchoolMissingLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const externalUser = externalUserDtoFactory.build(); + + const loggable = new SchoolMissingLoggableException(externalUser); + + return { + loggable, + externalUser, + }; + }; + + it('should return a loggable message', () => { + const { loggable, externalUser } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + type: 'SCHOOL_MISSING', + stack: expect.any(String), + message: 'Unable to create new external user without a school', + data: { + externalUserId: externalUser.externalId, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/provisioning/loggable/school-missing.loggable-exception.ts b/apps/server/src/modules/provisioning/loggable/school-missing.loggable-exception.ts new file mode 100644 index 00000000000..54727ba8f34 --- /dev/null +++ b/apps/server/src/modules/provisioning/loggable/school-missing.loggable-exception.ts @@ -0,0 +1,28 @@ +import { HttpStatus } from '@nestjs/common'; +import { BusinessError } from '@shared/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { ExternalUserDto } from '../dto'; + +export class SchoolMissingLoggableException extends BusinessError implements Loggable { + constructor(private readonly externalUser: ExternalUserDto) { + super( + { + type: 'SCHOOL_MISSING', + title: 'Invalid school data', + defaultMessage: 'Unable to create new external user without a school', + }, + HttpStatus.UNPROCESSABLE_ENTITY + ); + } + + public getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: this.type, + message: this.message, + stack: this.stack, + data: { + externalUserId: this.externalUser.externalId, + }, + }; + } +} diff --git a/apps/server/src/modules/provisioning/loggable/user-role-unknown.loggable-exception.spec.ts b/apps/server/src/modules/provisioning/loggable/user-role-unknown.loggable-exception.spec.ts new file mode 100644 index 00000000000..c63fb42930b --- /dev/null +++ b/apps/server/src/modules/provisioning/loggable/user-role-unknown.loggable-exception.spec.ts @@ -0,0 +1,32 @@ +import { externalUserDtoFactory } from '../testing'; +import { UserRoleUnknownLoggableException } from './user-role-unknown.loggable-exception'; + +describe(UserRoleUnknownLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const externalUser = externalUserDtoFactory.build(); + + const loggable = new UserRoleUnknownLoggableException(externalUser); + + return { + loggable, + externalUser, + }; + }; + + it('should return a loggable message', () => { + const { loggable, externalUser } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + type: 'EXTERNAL_USER_ROLE_UNKNOWN', + stack: expect.any(String), + message: 'External user has no or no known role assigned to them', + data: { + externalUserId: externalUser.externalId, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/provisioning/loggable/user-role-unknown.loggable-exception.ts b/apps/server/src/modules/provisioning/loggable/user-role-unknown.loggable-exception.ts new file mode 100644 index 00000000000..a17ab899708 --- /dev/null +++ b/apps/server/src/modules/provisioning/loggable/user-role-unknown.loggable-exception.ts @@ -0,0 +1,28 @@ +import { HttpStatus } from '@nestjs/common'; +import { BusinessError } from '@shared/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { ExternalUserDto } from '../dto'; + +export class UserRoleUnknownLoggableException extends BusinessError implements Loggable { + constructor(private readonly externalUser: ExternalUserDto) { + super( + { + type: 'EXTERNAL_USER_ROLE_UNKNOWN', + title: 'Invalid user role', + defaultMessage: 'External user has no or no known role assigned to them', + }, + HttpStatus.UNPROCESSABLE_ENTITY + ); + } + + public getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: this.type, + message: this.message, + stack: this.stack, + data: { + externalUserId: this.externalUser.externalId, + }, + }; + } +} diff --git a/apps/server/src/modules/provisioning/provisioning.config.ts b/apps/server/src/modules/provisioning/provisioning.config.ts index 0314bf8b277..9ba480fbcea 100644 --- a/apps/server/src/modules/provisioning/provisioning.config.ts +++ b/apps/server/src/modules/provisioning/provisioning.config.ts @@ -2,6 +2,7 @@ export interface ProvisioningConfig { FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED: boolean; FEATURE_SCHULCONNEX_MEDIA_LICENSE_ENABLED: boolean; PROVISIONING_SCHULCONNEX_POLICIES_INFO_URL: string; + PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT?: number; FEATURE_SANIS_GROUP_PROVISIONING_ENABLED: boolean; FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED: boolean; } diff --git a/apps/server/src/modules/provisioning/provisioning.module.ts b/apps/server/src/modules/provisioning/provisioning.module.ts index 6474efc61ce..b6e468180d4 100644 --- a/apps/server/src/modules/provisioning/provisioning.module.ts +++ b/apps/server/src/modules/provisioning/provisioning.module.ts @@ -1,4 +1,5 @@ import { AccountModule } from '@modules/account'; +import { ClassModule } from '@modules/class'; import { GroupModule } from '@modules/group'; import { LearnroomModule } from '@modules/learnroom'; import { LegacySchoolModule } from '@modules/legacy-school'; @@ -8,11 +9,10 @@ import { SystemModule } from '@modules/system/system.module'; import { ExternalToolModule } from '@modules/tool'; import { SchoolExternalToolModule } from '@modules/tool/school-external-tool'; import { UserModule } from '@modules/user'; +import { UserLicenseModule } from '@modules/user-license'; import { Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; import { SchulconnexClientModule } from '@src/infra/schulconnex-client/schulconnex-client.module'; -import { ClassModule } from '../class'; -import { UserLicenseModule } from '../user-license'; import { ProvisioningService } from './service/provisioning.service'; import { TspProvisioningService } from './service/tsp-provisioning.service'; import { @@ -28,8 +28,8 @@ import { SchulconnexSchoolProvisioningService, SchulconnexToolProvisioningService, SchulconnexUserProvisioningService, -} from './strategy/oidc/service'; -import { TspProvisioningStrategy } from './strategy/tsp/tsp.strategy'; +} from './strategy/schulconnex/service'; +import { TspProvisioningStrategy } from './strategy/tsp'; @Module({ imports: [ diff --git a/apps/server/src/modules/provisioning/service/provisioning.service.spec.ts b/apps/server/src/modules/provisioning/service/provisioning.service.spec.ts index 37790f6cadd..eae04147cd7 100644 --- a/apps/server/src/modules/provisioning/service/provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/service/provisioning.service.spec.ts @@ -4,16 +4,11 @@ import { systemFactory } from '@modules/system/testing'; import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { - ExternalUserDto, - OauthDataDto, - OauthDataStrategyInputDto, - ProvisioningDto, - ProvisioningSystemDto, -} from '../dto'; +import { OauthDataDto, OauthDataStrategyInputDto, ProvisioningDto, ProvisioningSystemDto } from '../dto'; import { IservProvisioningStrategy, OidcMockProvisioningStrategy, SanisProvisioningStrategy } from '../strategy'; -import { ProvisioningService } from './provisioning.service'; import { TspProvisioningStrategy } from '../strategy/tsp/tsp.strategy'; +import { externalUserDtoFactory } from '../testing'; +import { ProvisioningService } from './provisioning.service'; describe('ProvisioningService', () => { let module: TestingModule; @@ -88,14 +83,13 @@ describe('ProvisioningService', () => { provisioningUrl: 'https://api.moin.schule/', provisioningStrategy: SystemProvisioningStrategy.SANIS, }); + const externalUser = externalUserDtoFactory.build(); const oauthDataDto: OauthDataDto = new OauthDataDto({ system: provisioningSystemDto, - externalUser: new ExternalUserDto({ - externalId: 'externalUserId', - }), + externalUser, }); const provisioningDto: ProvisioningDto = new ProvisioningDto({ - externalUserId: 'externalUserId', + externalUserId: externalUser.externalId, }); return { diff --git a/apps/server/src/modules/provisioning/service/tsp-provisioning.service.spec.ts b/apps/server/src/modules/provisioning/service/tsp-provisioning.service.spec.ts index da637580400..b42ff993b92 100644 --- a/apps/server/src/modules/provisioning/service/tsp-provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/service/tsp-provisioning.service.spec.ts @@ -41,7 +41,7 @@ describe('TspProvisioningService', () => { return new ExternalClassDto({ ...baseProps, ...props }); }; const setupExternalUser = (props?: Partial) => { - const baseProps = { externalId: faker.string.uuid(), username: faker.internet.userName() }; + const baseProps = { externalId: faker.string.uuid(), username: faker.internet.userName(), roles: [] }; return new ExternalUserDto({ ...baseProps, ...props }); }; diff --git a/apps/server/src/modules/provisioning/strategy/index.ts b/apps/server/src/modules/provisioning/strategy/index.ts index 632463a04fa..8406f9de7ac 100644 --- a/apps/server/src/modules/provisioning/strategy/index.ts +++ b/apps/server/src/modules/provisioning/strategy/index.ts @@ -1,5 +1,4 @@ export * from './base.strategy'; export * from './iserv/iserv.strategy'; -export * from './oidc'; +export * from './schulconnex'; export * from './oidc-mock/oidc-mock.strategy'; -export * from './sanis'; diff --git a/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.spec.ts index 3e2d78b75a2..181a5b6971f 100644 --- a/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.spec.ts @@ -183,7 +183,7 @@ describe('IservProvisioningStrategy', () => { systemId: 'systemId', provisioningStrategy: SystemProvisioningStrategy.ISERV, }), - externalUser: new ExternalUserDto({ externalId: userUUID }), + externalUser: new ExternalUserDto({ externalId: userUUID, roles: [] }), }); const result: ProvisioningDto = await strategy.apply(data); diff --git a/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.spec.ts index 5c0c8901077..24d0c6b494d 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import jwt from 'jsonwebtoken'; import { IdTokenExtractionFailureLoggableException } from '@src/modules/oauth/loggable'; +import jwt from 'jsonwebtoken'; import { ExternalUserDto, OauthDataDto, @@ -73,7 +73,7 @@ describe('OidcMockProvisioningStrategy', () => { expect(result).toEqual({ system: input.system, - externalUser: new ExternalUserDto({ externalId: userName }), + externalUser: new ExternalUserDto({ externalId: userName, roles: [] }), }); }); @@ -106,7 +106,7 @@ describe('OidcMockProvisioningStrategy', () => { systemId: 'systemId', provisioningStrategy: SystemProvisioningStrategy.OIDC, }), - externalUser: new ExternalUserDto({ externalId: userName }), + externalUser: new ExternalUserDto({ externalId: userName, roles: [] }), }); const result: ProvisioningDto = await strategy.apply(data); diff --git a/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.ts b/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.ts index dd15672c3c9..0402e4628a4 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.ts @@ -1,7 +1,7 @@ +import { IdTokenExtractionFailureLoggableException } from '@modules/oauth/loggable'; import { Injectable } from '@nestjs/common'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import jwt, { JwtPayload } from 'jsonwebtoken'; -import { IdTokenExtractionFailureLoggableException } from '@modules/oauth/loggable'; import { ExternalUserDto, OauthDataDto, OauthDataStrategyInputDto, ProvisioningDto } from '../../dto'; import { ProvisioningStrategy } from '../base.strategy'; @@ -19,6 +19,7 @@ export class OidcMockProvisioningStrategy extends ProvisioningStrategy { const externalUser: ExternalUserDto = new ExternalUserDto({ externalId: idToken.external_sub, + roles: [], }); const oauthData: OauthDataDto = new OauthDataDto({ diff --git a/apps/server/src/modules/provisioning/strategy/oidc/index.ts b/apps/server/src/modules/provisioning/strategy/oidc/index.ts deleted file mode 100644 index a35eb285666..00000000000 --- a/apps/server/src/modules/provisioning/strategy/oidc/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SchulconnexProvisioningStrategy } from './schulconnex.strategy'; diff --git a/apps/server/src/modules/provisioning/strategy/sanis/index.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/index.ts similarity index 64% rename from apps/server/src/modules/provisioning/strategy/sanis/index.ts rename to apps/server/src/modules/provisioning/strategy/schulconnex/index.ts index 03132b1fcd6..f8fc2ce3f82 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/index.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/index.ts @@ -1,2 +1,3 @@ +export { SchulconnexProvisioningStrategy } from './schulconnex.strategy'; export { SanisProvisioningStrategy } from './sanis.strategy'; export { SchulconnexResponseMapper } from './schulconnex-response-mapper'; diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/sanis.strategy.spec.ts similarity index 97% rename from apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.spec.ts rename to apps/server/src/modules/provisioning/strategy/schulconnex/sanis.strategy.spec.ts index cdbcf05532d..bbd5d7ee0d5 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/sanis.strategy.spec.ts @@ -34,6 +34,9 @@ import { } from '../../dto'; import { PoliciesInfoErrorResponseLoggable } from '../../loggable'; import { ProvisioningConfig } from '../../provisioning.config'; +import { externalUserDtoFactory } from '../../testing'; +import { SanisProvisioningStrategy } from './sanis.strategy'; +import { SchulconnexResponseMapper } from './schulconnex-response-mapper'; import { SchulconnexCourseSyncService, SchulconnexGroupProvisioningService, @@ -41,9 +44,7 @@ import { SchulconnexSchoolProvisioningService, SchulconnexToolProvisioningService, SchulconnexUserProvisioningService, -} from '../oidc/service'; -import { SanisProvisioningStrategy } from './sanis.strategy'; -import { SchulconnexResponseMapper } from './schulconnex-response-mapper'; +} from './service'; import ArgsType = jest.ArgsType; import SpyInstance = jest.SpyInstance; @@ -156,9 +157,7 @@ describe(SanisProvisioningStrategy.name, () => { accessToken: 'sanisAccessToken', }); const schulconnexResponse: SchulconnexResponse = setupSchulconnexResponse(); - const user: ExternalUserDto = new ExternalUserDto({ - externalId: 'externalUserId', - }); + const user: ExternalUserDto = externalUserDtoFactory.build(); const school: ExternalSchoolDto = new ExternalSchoolDto({ externalId: 'externalSchoolId', name: 'schoolName', @@ -280,9 +279,7 @@ describe(SanisProvisioningStrategy.name, () => { accessToken: 'sanisAccessToken', }); const schulconnexResponse: SchulconnexResponse = setupSchulconnexResponse(); - const user: ExternalUserDto = new ExternalUserDto({ - externalId: 'externalUserId', - }); + const user: ExternalUserDto = externalUserDtoFactory.build(); const school: ExternalSchoolDto = new ExternalSchoolDto({ externalId: 'externalSchoolId', name: 'schoolName', @@ -334,9 +331,7 @@ describe(SanisProvisioningStrategy.name, () => { accessToken: 'sanisAccessToken', }); const schulconnexResponse: SchulconnexResponse = setupSchulconnexResponse(); - const user: ExternalUserDto = new ExternalUserDto({ - externalId: 'externalUserId', - }); + const user: ExternalUserDto = externalUserDtoFactory.build(); const school: ExternalSchoolDto = new ExternalSchoolDto({ externalId: 'externalSchoolId', name: 'schoolName', @@ -385,9 +380,7 @@ describe(SanisProvisioningStrategy.name, () => { accessToken: 'sanisAccessToken', }); const schulconnexResponse: SchulconnexResponse = setupSchulconnexResponse(); - const user: ExternalUserDto = new ExternalUserDto({ - externalId: 'externalUserId', - }); + const user: ExternalUserDto = externalUserDtoFactory.build(); const school: ExternalSchoolDto = new ExternalSchoolDto({ externalId: 'externalSchoolId', name: 'schoolName', @@ -428,9 +421,7 @@ describe(SanisProvisioningStrategy.name, () => { accessToken: 'sanisAccessToken', }); const schulconnexResponse: SchulconnexResponse = setupSchulconnexResponse(); - const user: ExternalUserDto = new ExternalUserDto({ - externalId: 'externalUserId', - }); + const user: ExternalUserDto = externalUserDtoFactory.build(); const school: ExternalSchoolDto = new ExternalSchoolDto({ externalId: 'externalSchoolId', name: 'schoolName', @@ -535,9 +526,7 @@ describe(SanisProvisioningStrategy.name, () => { accessToken: 'sanisAccessToken', }); const schulconnexResponse: SchulconnexResponse = setupSchulconnexResponse(); - const user: ExternalUserDto = new ExternalUserDto({ - externalId: 'externalUserId', - }); + const user: ExternalUserDto = externalUserDtoFactory.build(); const school: ExternalSchoolDto = new ExternalSchoolDto({ externalId: 'externalSchoolId', name: 'schoolName', diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/sanis.strategy.ts similarity index 94% rename from apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.ts rename to apps/server/src/modules/provisioning/strategy/schulconnex/sanis.strategy.ts index 590dd214240..6a441c35909 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/sanis.strategy.ts @@ -24,7 +24,8 @@ import { } from '../../dto'; import { FetchingPoliciesInfoFailedLoggable, PoliciesInfoErrorResponseLoggable } from '../../loggable'; import { ProvisioningConfig } from '../../provisioning.config'; -import { SchulconnexProvisioningStrategy } from '../oidc'; +import { SchulconnexResponseMapper } from './schulconnex-response-mapper'; +import { SchulconnexProvisioningStrategy } from './schulconnex.strategy'; import { SchulconnexCourseSyncService, SchulconnexGroupProvisioningService, @@ -32,8 +33,7 @@ import { SchulconnexSchoolProvisioningService, SchulconnexToolProvisioningService, SchulconnexUserProvisioningService, -} from '../oidc/service'; -import { SchulconnexResponseMapper } from './schulconnex-response-mapper'; +} from './service'; @Injectable() export class SanisProvisioningStrategy extends SchulconnexProvisioningStrategy { @@ -46,9 +46,9 @@ export class SanisProvisioningStrategy extends SchulconnexProvisioningStrategy { protected readonly schulconnexLicenseProvisioningService: SchulconnexLicenseProvisioningService, protected readonly schulconnexToolProvisioningService: SchulconnexToolProvisioningService, protected readonly configService: ConfigService, + protected readonly logger: Logger, private readonly responseMapper: SchulconnexResponseMapper, - private readonly schulconnexRestClient: SchulconnexRestClient, - private readonly logger: Logger + private readonly schulconnexRestClient: SchulconnexRestClient ) { super( schulconnexSchoolProvisioningService, @@ -58,15 +58,16 @@ export class SanisProvisioningStrategy extends SchulconnexProvisioningStrategy { schulconnexLicenseProvisioningService, schulconnexToolProvisioningService, groupService, - configService + configService, + logger ); } - getType(): SystemProvisioningStrategy { + public getType(): SystemProvisioningStrategy { return SystemProvisioningStrategy.SANIS; } - override async getData(input: OauthDataStrategyInputDto): Promise { + public override async getData(input: OauthDataStrategyInputDto): Promise { if (!input.system.provisioningUrl) { throw new InternalServerErrorException( `Sanis system with id: ${input.system.systemId} is missing a provisioning url` diff --git a/apps/server/src/modules/provisioning/strategy/sanis/schulconnex-response-mapper.spec.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.spec.ts similarity index 92% rename from apps/server/src/modules/provisioning/strategy/sanis/schulconnex-response-mapper.spec.ts rename to apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.spec.ts index 36ad4321943..1d413fd4aad 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/schulconnex-response-mapper.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.spec.ts @@ -47,6 +47,11 @@ describe(SchulconnexResponseMapper.name, () => { mapper = module.get(SchulconnexResponseMapper); }); + beforeEach(() => { + config.FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED = false; + config.PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT = undefined; + }); + describe('mapToExternalSchoolDto', () => { describe('when a schulconnex response is provided', () => { const setup = () => { @@ -316,6 +321,8 @@ describe(SchulconnexResponseMapper.name, () => { describe('when other participants have unknown roles', () => { const setup = () => { + config.FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED = true; + const schulconnexResponse: SchulconnexResponse = schulconnexResponseFactory.build(); schulconnexResponse.personenkontexte[0].gruppen![0]!.sonstige_gruppenzugehoerige = [ { @@ -514,6 +521,56 @@ describe(SchulconnexResponseMapper.name, () => { ); }); }); + + describe('when there are too many users in groups', () => { + const setup = () => { + config.FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED = true; + config.PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT = 1; + + const schulconnexResponse: SchulconnexResponse = schulconnexResponseFactory.build(); + + return { + schulconnexResponse, + }; + }; + + it('should not map other group users', () => { + const { schulconnexResponse } = setup(); + + const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(schulconnexResponse); + + expect(result).toEqual([ + expect.objectContaining>({ + otherUsers: undefined, + }), + ]); + }); + }); + + describe('when there are not too many users in groups', () => { + const setup = () => { + config.FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED = true; + config.PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT = 10; + + const schulconnexResponse: SchulconnexResponse = schulconnexResponseFactory.build(); + + return { + schulconnexResponse, + }; + }; + + it('should not map other group users', () => { + const { schulconnexResponse } = setup(); + + const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(schulconnexResponse); + + expect(result).not.toEqual([ + expect.objectContaining({ + otherUsers: undefined, + }), + ]); + }); + }); }); describe('mapLernperiode', () => { diff --git a/apps/server/src/modules/provisioning/strategy/sanis/schulconnex-response-mapper.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.ts similarity index 86% rename from apps/server/src/modules/provisioning/strategy/sanis/schulconnex-response-mapper.ts rename to apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.ts index cd15c272342..07ce885a1b9 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/schulconnex-response-mapper.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.ts @@ -27,7 +27,7 @@ import { import { GroupRoleUnknownLoggable } from '../../loggable'; import { ProvisioningConfig } from '../../provisioning.config'; -const RoleMapping: Record = { +const RoleMapping: Partial> = { [SchulconnexRole.LEHR]: RoleName.TEACHER, [SchulconnexRole.LERN]: RoleName.STUDENT, [SchulconnexRole.LEIT]: RoleName.ADMINISTRATOR, @@ -39,7 +39,7 @@ const GroupRoleMapping: Partial> [SchulconnexGroupRole.STUDENT]: RoleName.STUDENT, }; -const GroupTypeMapping: Partial> = { +const GroupTypeMapping: Partial> = { [SchulconnexGroupType.CLASS]: GroupTypes.CLASS, [SchulconnexGroupType.COURSE]: GroupTypes.COURSE, [SchulconnexGroupType.OTHER]: GroupTypes.OTHER, @@ -85,10 +85,12 @@ export class SchulconnexResponseMapper { email = emailContact?.kennung; } + const role: RoleName | undefined = SchulconnexResponseMapper.mapSanisRoleToRoleName(source); + const mapped = new ExternalUserDto({ firstName: source.person.name.vorname, lastName: source.person.name.familienname, - roles: [SchulconnexResponseMapper.mapSanisRoleToRoleName(source)], + roles: role ? [role] : [], externalId: source.pid, birthday: source.person.geburt?.datum ? new Date(source.person.geburt?.datum) : undefined, email, @@ -97,7 +99,7 @@ export class SchulconnexResponseMapper { return mapped; } - public static mapSanisRoleToRoleName(source: SchulconnexResponse): RoleName { + public static mapSanisRoleToRoleName(source: SchulconnexResponse): RoleName | undefined { return RoleMapping[source.personenkontexte[0].rolle]; } @@ -118,14 +120,25 @@ export class SchulconnexResponseMapper { return undefined; } + const usersInGroupsCount: number = groups.reduce( + (count: number, group: SchulconnexGruppenResponse) => count + (group.sonstige_gruppenzugehoerige?.length ?? 0), + groups.length + ); + const limit: number | undefined = this.configService.get('PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT'); + const shouldProvisionOtherUsers: boolean = limit === undefined || usersInGroupsCount < limit; + const mapped: ExternalGroupDto[] = groups - .map((group) => this.mapExternalGroup(source, group)) - .filter((group): group is ExternalGroupDto => group !== null); + .map((group: SchulconnexGruppenResponse) => this.mapExternalGroup(source, group, shouldProvisionOtherUsers)) + .filter((group: ExternalGroupDto | null): group is ExternalGroupDto => group !== null); return mapped; } - private mapExternalGroup(source: SchulconnexResponse, group: SchulconnexGruppenResponse): ExternalGroupDto | null { + private mapExternalGroup( + source: SchulconnexResponse, + group: SchulconnexGruppenResponse, + shouldProvisionOtherUsers: boolean + ): ExternalGroupDto | null { const groupType: GroupTypes | undefined = GroupTypeMapping[group.gruppe.typ]; if (!groupType) { @@ -142,7 +155,7 @@ export class SchulconnexResponseMapper { } let otherUsers: ExternalGroupUserDto[] | undefined; - if (this.configService.get('FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED')) { + if (this.configService.get('FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED') && shouldProvisionOtherUsers) { otherUsers = group.sonstige_gruppenzugehoerige ? group.sonstige_gruppenzugehoerige .map((relation): ExternalGroupUserDto | null => this.mapToExternalGroupUser(relation)) @@ -173,7 +186,7 @@ export class SchulconnexResponseMapper { const userRole: RoleName | undefined = GroupRoleMapping[relation.rollen[0]]; if (!userRole) { - this.logger.info(new GroupRoleUnknownLoggable(relation)); + this.logger.warning(new GroupRoleUnknownLoggable(relation)); return null; } diff --git a/apps/server/src/modules/provisioning/strategy/oidc/schulconnex.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.spec.ts similarity index 95% rename from apps/server/src/modules/provisioning/strategy/oidc/schulconnex.strategy.spec.ts rename to apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.spec.ts index 30408619f57..f86346d37eb 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/schulconnex.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.spec.ts @@ -14,16 +14,17 @@ import { legacySchoolDoFactory, userDoFactory, } from '@shared/testing'; +import { Logger } from '@src/core/logger'; import { ExternalGroupDto, ExternalSchoolDto, - ExternalUserDto, OauthDataDto, OauthDataStrategyInputDto, ProvisioningDto, ProvisioningSystemDto, } from '../../dto'; import { ProvisioningConfig } from '../../provisioning.config'; +import { externalUserDtoFactory } from '../../testing'; import { SchulconnexProvisioningStrategy } from './schulconnex.strategy'; import { SchulconnexCourseSyncService, @@ -98,6 +99,10 @@ describe(SchulconnexProvisioningStrategy.name, () => { get: jest.fn().mockImplementation((key: keyof ProvisioningConfig) => config[key]), }, }, + { + provide: Logger, + useValue: createMock(), + }, ], }).compile(); @@ -141,9 +146,7 @@ describe(SchulconnexProvisioningStrategy.name, () => { externalId: externalSchoolId, name: 'schoolName', }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), + externalUser: externalUserDtoFactory.build(), }); const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).build({ firstName: 'firstName', @@ -193,9 +196,7 @@ describe(SchulconnexProvisioningStrategy.name, () => { externalId: externalSchoolId, name: 'schoolName', }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), + externalUser: externalUserDtoFactory.build({ externalId: externalUserId }), }); const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).build({ firstName: 'firstName', @@ -252,9 +253,7 @@ describe(SchulconnexProvisioningStrategy.name, () => { provisioningStrategy: SystemProvisioningStrategy.OIDC, }), externalSchool: externalSchoolDtoFactory.build(), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), + externalUser: externalUserDtoFactory.build({ externalId: externalUserId }), externalGroups, }); @@ -310,9 +309,7 @@ describe(SchulconnexProvisioningStrategy.name, () => { systemId: 'systemId', provisioningStrategy: SystemProvisioningStrategy.OIDC, }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), + externalUser: externalUserDtoFactory.build({ externalId: externalUserId }), externalGroups: externalGroupDtoFactory.buildList(2), }); @@ -354,9 +351,7 @@ describe(SchulconnexProvisioningStrategy.name, () => { systemId: 'systemId', provisioningStrategy: SystemProvisioningStrategy.OIDC, }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), + externalUser: externalUserDtoFactory.build({ externalId: externalUserId }), externalGroups: undefined, }); @@ -398,9 +393,7 @@ describe(SchulconnexProvisioningStrategy.name, () => { provisioningStrategy: SystemProvisioningStrategy.OIDC, }), externalSchool: externalSchoolDtoFactory.build(), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), + externalUser: externalUserDtoFactory.build({ externalId: externalUserId }), externalGroups, }); @@ -448,9 +441,7 @@ describe(SchulconnexProvisioningStrategy.name, () => { provisioningStrategy: SystemProvisioningStrategy.OIDC, }), externalSchool: externalSchoolDtoFactory.build(), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), + externalUser: externalUserDtoFactory.build({ externalId: externalUserId }), externalGroups, }); @@ -492,9 +483,7 @@ describe(SchulconnexProvisioningStrategy.name, () => { provisioningStrategy: SystemProvisioningStrategy.OIDC, }), externalSchool: externalSchoolDtoFactory.build(), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), + externalUser: externalUserDtoFactory.build({ externalId: externalUserId }), externalGroups: [], }); @@ -532,9 +521,7 @@ describe(SchulconnexProvisioningStrategy.name, () => { systemId: new ObjectId().toHexString(), provisioningStrategy: SystemProvisioningStrategy.OIDC, }), - externalUser: new ExternalUserDto({ - externalId: 'externalUserId', - }), + externalUser: externalUserDtoFactory.build(), externalLicenses: [], }); const user: UserDO = userDoFactory.build({ @@ -581,9 +568,7 @@ describe(SchulconnexProvisioningStrategy.name, () => { systemId: new ObjectId().toHexString(), provisioningStrategy: SystemProvisioningStrategy.OIDC, }), - externalUser: new ExternalUserDto({ - externalId: 'externalUserId', - }), + externalUser: externalUserDtoFactory.build(), externalLicenses: [], }); const user: UserDO = userDoFactory.build({ diff --git a/apps/server/src/modules/provisioning/strategy/oidc/schulconnex.strategy.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.ts similarity index 90% rename from apps/server/src/modules/provisioning/strategy/oidc/schulconnex.strategy.ts rename to apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.ts index 007b70319bc..24f53bbf125 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/schulconnex.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.ts @@ -2,7 +2,9 @@ import { Group, GroupService } from '@modules/group'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { LegacySchoolDo, UserDO } from '@shared/domain/domainobject'; +import { Logger } from '@src/core/logger'; import { ExternalGroupDto, OauthDataDto, ProvisioningDto } from '../../dto'; +import { GroupProvisioningInfoLoggable } from '../../loggable'; import { ProvisioningConfig } from '../../provisioning.config'; import { ProvisioningStrategy } from '../base.strategy'; import { @@ -24,12 +26,13 @@ export abstract class SchulconnexProvisioningStrategy extends ProvisioningStrate protected readonly schulconnexLicenseProvisioningService: SchulconnexLicenseProvisioningService, protected readonly schulconnexToolProvisioningService: SchulconnexToolProvisioningService, protected readonly groupService: GroupService, - protected readonly configService: ConfigService + protected readonly configService: ConfigService, + protected readonly logger: Logger ) { super(); } - override async apply(data: OauthDataDto): Promise { + public override async apply(data: OauthDataDto): Promise { let school: LegacySchoolDo | undefined; if (data.externalSchool) { school = await this.schulconnexSchoolProvisioningService.provisionExternalSchool( @@ -61,6 +64,8 @@ export abstract class SchulconnexProvisioningStrategy extends ProvisioningStrate } private async provisionGroups(data: OauthDataDto, school?: LegacySchoolDo): Promise { + const startTime = performance.now(); + await this.removeUserFromGroups(data); if (data.externalGroups) { @@ -96,6 +101,11 @@ export abstract class SchulconnexProvisioningStrategy extends ProvisioningStrate await Promise.all(groupProvisioningPromises); } + + const endTime = performance.now(); + this.logger.warning( + new GroupProvisioningInfoLoggable(data.externalGroups ?? [], endTime - startTime, data.externalUser.externalId) + ); } private async removeUserFromGroups(data: OauthDataDto): Promise { diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/index.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/service/index.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/oidc/service/index.ts rename to apps/server/src/modules/provisioning/strategy/schulconnex/service/index.ts diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.spec.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-course-sync.service.spec.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.spec.ts rename to apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-course-sync.service.spec.ts diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-course-sync.service.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.ts rename to apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-course-sync.service.ts diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.spec.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-group-provisioning.service.spec.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.spec.ts rename to apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-group-provisioning.service.spec.ts diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-group-provisioning.service.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.ts rename to apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-group-provisioning.service.ts diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-license-provisioning.service.spec.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-license-provisioning.service.spec.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-license-provisioning.service.spec.ts rename to apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-license-provisioning.service.spec.ts diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-license-provisioning.service.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-license-provisioning.service.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-license-provisioning.service.ts rename to apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-license-provisioning.service.ts diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-school-provisioning.service.spec.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-school-provisioning.service.spec.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-school-provisioning.service.spec.ts rename to apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-school-provisioning.service.spec.ts index 42b2e72bb74..ba5995ff324 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-school-provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-school-provisioning.service.spec.ts @@ -6,8 +6,8 @@ import { LegacySchoolDo } from '@shared/domain/domainobject'; import { SchoolFeature } from '@shared/domain/types'; import { federalStateFactory, legacySchoolDoFactory, schoolYearFactory } from '@shared/testing'; import { ExternalSchoolDto } from '../../../dto'; -import { SchulconnexSchoolProvisioningService } from './schulconnex-school-provisioning.service'; import { SchoolNameRequiredLoggableException } from '../../../loggable'; +import { SchulconnexSchoolProvisioningService } from './schulconnex-school-provisioning.service'; describe(SchulconnexSchoolProvisioningService.name, () => { let module: TestingModule; diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-school-provisioning.service.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-school-provisioning.service.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-school-provisioning.service.ts rename to apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-school-provisioning.service.ts diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-tool-provisioning.service.spec.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-tool-provisioning.service.spec.ts similarity index 98% rename from apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-tool-provisioning.service.spec.ts rename to apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-tool-provisioning.service.spec.ts index ed3a05aa0c1..9492425822a 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-tool-provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-tool-provisioning.service.spec.ts @@ -1,7 +1,6 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { SchoolSystemOptionsService, SchulConneXProvisioningOptions } from '@modules/legacy-school'; -import { SchulconnexToolProvisioningService } from '@modules/provisioning/strategy/oidc/service/schulconnex-tool-provisioning.service'; import { ExternalToolService } from '@modules/tool'; import { ExternalTool } from '@modules/tool/external-tool/domain'; import { customParameterFactory, externalToolFactory } from '@modules/tool/external-tool/testing'; @@ -12,6 +11,7 @@ import { MediaUserLicense, mediaUserLicenseFactory, MediaUserLicenseService } fr import { Test, TestingModule } from '@nestjs/testing'; import { schoolSystemOptionsFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; +import { SchulconnexToolProvisioningService } from './schulconnex-tool-provisioning.service'; describe(SchulconnexToolProvisioningService.name, () => { let module: TestingModule; diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-tool-provisioning.service.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-tool-provisioning.service.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-tool-provisioning.service.ts rename to apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-tool-provisioning.service.ts diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-user-provisioning.service.spec.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-user-provisioning.service.spec.ts similarity index 84% rename from apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-user-provisioning.service.spec.ts rename to apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-user-provisioning.service.spec.ts index 1f78350f6ec..4915119a983 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-user-provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-user-provisioning.service.spec.ts @@ -4,7 +4,6 @@ import { AccountSave, AccountService } from '@modules/account'; import { RoleService } from '@modules/role'; import { RoleDto } from '@modules/role/service/dto/role.dto'; import { UserService } from '@modules/user'; -import { UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { UserDO } from '@shared/domain/domainobject'; import { RoleName } from '@shared/domain/interface'; @@ -12,6 +11,8 @@ import { userDoFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; import CryptoJS from 'crypto-js'; import { ExternalUserDto } from '../../../dto'; +import { SchoolMissingLoggableException, UserRoleUnknownLoggableException } from '../../../loggable'; +import { externalUserDtoFactory } from '../../../testing'; import { SchulconnexUserProvisioningService } from './schulconnex-user-provisioning.service'; jest.mock('crypto-js'); @@ -88,7 +89,7 @@ describe(SchulconnexUserProvisioningService.name, () => { }, 'userId' ); - const externalUser: ExternalUserDto = new ExternalUserDto({ + const externalUser: ExternalUserDto = externalUserDtoFactory.build({ externalId: 'externalUserId', firstName: 'firstName', lastName: 'lastName', @@ -96,7 +97,10 @@ describe(SchulconnexUserProvisioningService.name, () => { roles: [RoleName.USER], birthday, }); - const minimalViableExternalUser: ExternalUserDto = new ExternalUserDto({ externalId: 'externalUserId' }); + const minimalViableExternalUser: ExternalUserDto = new ExternalUserDto({ + externalId: 'externalUserId', + roles: [RoleName.USER], + }); const userRole: RoleDto = new RoleDto({ id: new ObjectId().toHexString(), name: RoleName.USER, @@ -126,8 +130,32 @@ describe(SchulconnexUserProvisioningService.name, () => { }; }; + describe('when the user has no role', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const schoolId = new ObjectId().toHexString(); + const externalUser = externalUserDtoFactory.build({ + roles: [], + }); + + return { + systemId, + schoolId, + externalUser, + }; + }; + + it('should throw UserRoleUnknownLoggableException', async () => { + const { externalUser, schoolId, systemId } = setup(); + + await expect(service.provisionExternalUser(externalUser, systemId, schoolId)).rejects.toThrow( + UserRoleUnknownLoggableException + ); + }); + }); + describe('when the user does not exist yet', () => { - describe('when the external user has no email or roles', () => { + describe('when the external user has no email', () => { it('should return the saved user', async () => { const { minimalViableExternalUser, schoolId, savedUser, systemId } = setupUser(); @@ -166,14 +194,14 @@ describe(SchulconnexUserProvisioningService.name, () => { }); describe('when no schoolId is provided', () => { - it('should throw UnprocessableEntityException', async () => { + it('should throw SchoolMissingLoggableException', async () => { const { externalUser } = setupUser(); userService.findByExternalId.mockResolvedValue(null); const promise: Promise = service.provisionExternalUser(externalUser, 'systemId', undefined); - await expect(promise).rejects.toThrow(UnprocessableEntityException); + await expect(promise).rejects.toThrow(SchoolMissingLoggableException); }); }); }); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-user-provisioning.service.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-user-provisioning.service.ts similarity index 85% rename from apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-user-provisioning.service.ts rename to apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-user-provisioning.service.ts index d7b764389bc..3558cdad4f9 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-user-provisioning.service.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/service/schulconnex-user-provisioning.service.ts @@ -1,12 +1,14 @@ import { AccountSave, AccountService } from '@modules/account'; import { RoleDto, RoleService } from '@modules/role'; import { UserService } from '@modules/user'; -import { Injectable, UnprocessableEntityException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { RoleReference, UserDO } from '@shared/domain/domainobject'; import { RoleName } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import CryptoJS from 'crypto-js'; import { ExternalUserDto } from '../../../dto'; +import { UserRoleUnknownLoggableException } from '../../../loggable'; +import { SchoolMissingLoggableException } from '../../../loggable/school-missing.loggable-exception'; @Injectable() export class SchulconnexUserProvisioningService { @@ -24,6 +26,9 @@ export class SchulconnexUserProvisioningService { const foundUser: UserDO | null = await this.userService.findByExternalId(externalUser.externalId, systemId); const roleRefs: RoleReference[] | undefined = await this.createRoleReferences(externalUser.roles); + if (!roleRefs?.length) { + throw new UserRoleUnknownLoggableException(externalUser); + } let createNewAccount = false; let user: UserDO; @@ -31,9 +36,7 @@ export class SchulconnexUserProvisioningService { user = this.updateUser(externalUser, foundUser, roleRefs, schoolId); } else { if (!schoolId) { - throw new UnprocessableEntityException( - `Unable to create new external user ${externalUser.externalId} without a school` - ); + throw new SchoolMissingLoggableException(externalUser); } createNewAccount = true; @@ -55,10 +58,10 @@ export class SchulconnexUserProvisioningService { } private async createRoleReferences(roles?: RoleName[]): Promise { - if (roles) { + if (roles?.length) { const foundRoles: RoleDto[] = await this.roleService.findByNames(roles); - const roleRefs = foundRoles.map( - (role: RoleDto): RoleReference => new RoleReference({ id: role.id || '', name: role.name }) + const roleRefs: RoleReference[] = foundRoles.map( + (role: RoleDto): RoleReference => new RoleReference({ id: role.id, name: role.name }) ); return roleRefs; diff --git a/apps/server/src/modules/provisioning/strategy/tsp/index.ts b/apps/server/src/modules/provisioning/strategy/tsp/index.ts new file mode 100644 index 00000000000..0d472196907 --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/tsp/index.ts @@ -0,0 +1 @@ +export { TspProvisioningStrategy } from './tsp.strategy'; diff --git a/apps/server/src/modules/provisioning/testing/external-group-dto.factory.ts b/apps/server/src/modules/provisioning/testing/external-group-dto.factory.ts new file mode 100644 index 00000000000..d33808d811a --- /dev/null +++ b/apps/server/src/modules/provisioning/testing/external-group-dto.factory.ts @@ -0,0 +1,18 @@ +import { UUID } from 'bson'; +import { Factory } from 'fishery'; +import { GroupTypes } from '../../group'; +import { ExternalGroupDto } from '../dto'; +import { externalGroupUserDtoFactory } from './external-group-user-dto.factory'; + +export const externalGroupDtoFactory = Factory.define( + ({ sequence }) => + new ExternalGroupDto({ + type: GroupTypes.CLASS, + name: `External Group ${sequence}`, + externalId: new UUID().toString(), + user: externalGroupUserDtoFactory.build(), + otherUsers: externalGroupUserDtoFactory.buildList(2), + from: new Date(), + until: new Date(), + }) +); diff --git a/apps/server/src/modules/provisioning/testing/external-group-user-dto.factory.ts b/apps/server/src/modules/provisioning/testing/external-group-user-dto.factory.ts new file mode 100644 index 00000000000..938eff3e073 --- /dev/null +++ b/apps/server/src/modules/provisioning/testing/external-group-user-dto.factory.ts @@ -0,0 +1,12 @@ +import { RoleName } from '@shared/domain/interface'; +import { UUID } from 'bson'; +import { Factory } from 'fishery'; +import { ExternalGroupUserDto } from '../dto'; + +export const externalGroupUserDtoFactory = Factory.define( + () => + new ExternalGroupUserDto({ + externalUserId: new UUID().toString(), + roleName: RoleName.TEACHER, + }) +); diff --git a/apps/server/src/modules/provisioning/testing/external-user-dto.factory.ts b/apps/server/src/modules/provisioning/testing/external-user-dto.factory.ts new file mode 100644 index 00000000000..8a257d9f9ff --- /dev/null +++ b/apps/server/src/modules/provisioning/testing/external-user-dto.factory.ts @@ -0,0 +1,16 @@ +import { RoleName } from '@shared/domain/interface'; +import { UUID } from 'bson'; +import { Factory } from 'fishery'; +import { ExternalUserDto } from '../dto'; + +export const externalUserDtoFactory = Factory.define( + () => + new ExternalUserDto({ + externalId: new UUID().toString(), + email: 'external@schul-cloud.org', + birthday: new Date(1998, 11, 18), + firstName: 'ex', + lastName: 'ternal', + roles: [RoleName.TEACHER], + }) +); diff --git a/apps/server/src/modules/provisioning/testing/index.ts b/apps/server/src/modules/provisioning/testing/index.ts new file mode 100644 index 00000000000..32854894142 --- /dev/null +++ b/apps/server/src/modules/provisioning/testing/index.ts @@ -0,0 +1,3 @@ +export { externalUserDtoFactory } from './external-user-dto.factory'; +export { externalGroupDtoFactory } from './external-group-dto.factory'; +export { externalGroupUserDtoFactory } from './external-group-user-dto.factory'; diff --git a/apps/server/src/modules/room-membership/authorization/room-membership.rule.spec.ts b/apps/server/src/modules/room-membership/authorization/room-membership.rule.spec.ts index 6946c83aa8d..24384cd6b2b 100644 --- a/apps/server/src/modules/room-membership/authorization/room-membership.rule.spec.ts +++ b/apps/server/src/modules/room-membership/authorization/room-membership.rule.spec.ts @@ -1,7 +1,8 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { Permission } from '@shared/domain/interface'; -import { roleDtoFactory, setupEntities, userFactory } from '@shared/testing'; import { Action, AuthorizationHelper, AuthorizationInjectionService } from '@modules/authorization'; +import { roomFactory } from '@modules/room/testing'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Permission, RoleName } from '@shared/domain/interface'; +import { roleDtoFactory, roleFactory, schoolEntityFactory, setupEntities, userFactory } from '@shared/testing'; import { RoomMembershipAuthorizable } from '../do/room-membership-authorizable.do'; import { RoomMembershipRule } from './room-membership.rule'; @@ -30,7 +31,7 @@ describe(RoomMembershipRule.name, () => { describe('when entity is applicable', () => { const setup = () => { const user = userFactory.buildWithId(); - const roomMembershipAuthorizable = new RoomMembershipAuthorizable('', []); + const roomMembershipAuthorizable = new RoomMembershipAuthorizable('', [], user.school.id); return { user, roomMembershipAuthorizable }; }; @@ -60,66 +61,146 @@ describe(RoomMembershipRule.name, () => { }); describe('hasPermission', () => { - describe('when user is viewer member of room', () => { - const setup = () => { - const user = userFactory.buildWithId(); - const roleDto = roleDtoFactory.build({ permissions: [Permission.ROOM_VIEW] }); - const roomMembershipAuthorizable = new RoomMembershipAuthorizable('', [{ roles: [roleDto], userId: user.id }]); + describe("when user's primary school is room's school", () => { + describe('when user is not member of the room', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const roomMembershipAuthorizable = new RoomMembershipAuthorizable('', [], user.school.id); - return { user, roomMembershipAuthorizable }; - }; + return { user, roomMembershipAuthorizable }; + }; - it('should return "true" for read action', () => { - const { user, roomMembershipAuthorizable } = setup(); + it('should return "false" for read action', () => { + const { user, roomMembershipAuthorizable } = setup(); - const res = service.hasPermission(user, roomMembershipAuthorizable, { - action: Action.read, - requiredPermissions: [], - }); + const res = service.hasPermission(user, roomMembershipAuthorizable, { + action: Action.read, + requiredPermissions: [], + }); - expect(res).toBe(true); + expect(res).toBe(false); + }); }); - it('should return "false" for write action', () => { - const { user, roomMembershipAuthorizable } = setup(); + describe('when user has view permission for room', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const roleDto = roleDtoFactory.build({ permissions: [Permission.ROOM_VIEW] }); + const roomMembershipAuthorizable = new RoomMembershipAuthorizable( + '', + [{ roles: [roleDto], userId: user.id }], + user.school.id + ); + + return { user, roomMembershipAuthorizable }; + }; + + it('should return "true" for read action', () => { + const { user, roomMembershipAuthorizable } = setup(); - const res = service.hasPermission(user, roomMembershipAuthorizable, { - action: Action.write, - requiredPermissions: [], + const res = service.hasPermission(user, roomMembershipAuthorizable, { + action: Action.read, + requiredPermissions: [], + }); + + expect(res).toBe(true); }); - expect(res).toBe(false); + it('should return "false" for write action', () => { + const { user, roomMembershipAuthorizable } = setup(); + + const res = service.hasPermission(user, roomMembershipAuthorizable, { + action: Action.write, + requiredPermissions: [], + }); + + expect(res).toBe(false); + }); + + it('should return false for change owner action', () => { + const { user, roomMembershipAuthorizable } = setup(); + + const res = service.hasPermission(user, roomMembershipAuthorizable, { + action: Action.read, + requiredPermissions: [Permission.ROOM_CHANGE_OWNER], + }); + + expect(res).toBe(false); + }); }); - }); - describe('when user is not member of room', () => { - const setup = () => { - const user = userFactory.buildWithId(); - const roomMembershipAuthorizable = new RoomMembershipAuthorizable('', []); + describe('when user is not member of room', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const roomMembershipAuthorizable = new RoomMembershipAuthorizable('', [], user.school.id); - return { user, roomMembershipAuthorizable }; - }; + return { user, roomMembershipAuthorizable }; + }; - it('should return "false" for read action', () => { - const { user, roomMembershipAuthorizable } = setup(); + it('should return "false" for read action', () => { + const { user, roomMembershipAuthorizable } = setup(); + + const res = service.hasPermission(user, roomMembershipAuthorizable, { + action: Action.read, + requiredPermissions: [], + }); - const res = service.hasPermission(user, roomMembershipAuthorizable, { - action: Action.read, - requiredPermissions: [], + expect(res).toBe(false); }); - expect(res).toBe(false); - }); + it('should return "false" for write action', () => { + const { user, roomMembershipAuthorizable } = setup(); - it('should return "false" for write action', () => { - const { user, roomMembershipAuthorizable } = setup(); + const res = service.hasPermission(user, roomMembershipAuthorizable, { + action: Action.write, + requiredPermissions: [], + }); - const res = service.hasPermission(user, roomMembershipAuthorizable, { - action: Action.write, - requiredPermissions: [], + expect(res).toBe(false); }); + }); + }); - expect(res).toBe(false); + describe("when user is guest at room's school", () => { + describe('when user has view permission for room', () => { + const setup = () => { + const otherSchool = schoolEntityFactory.buildWithId(); + const guestTeacherRole = roleFactory.buildWithId({ name: RoleName.GUESTTEACHER }); + const user = userFactory.buildWithId({ + secondarySchools: [{ school: otherSchool, role: guestTeacherRole }], + }); + const room = roomFactory.build({ schoolId: otherSchool.id }); + const roleDto = roleDtoFactory.build({ permissions: [Permission.ROOM_VIEW] }); + const roomMembershipAuthorizable = new RoomMembershipAuthorizable( + room.id, + [{ roles: [roleDto], userId: user.id }], + otherSchool.id + ); + + return { user, roomMembershipAuthorizable }; + }; + + it('should return "true" for read action', () => { + const { user, roomMembershipAuthorizable } = setup(); + + const res = service.hasPermission(user, roomMembershipAuthorizable, { + action: Action.read, + requiredPermissions: [], + }); + + expect(res).toBe(true); + }); + + it('should return "false" for write action', () => { + const { user, roomMembershipAuthorizable } = setup(); + + const res = service.hasPermission(user, roomMembershipAuthorizable, { + action: Action.write, + requiredPermissions: [], + }); + + expect(res).toBe(false); + }); }); }); }); diff --git a/apps/server/src/modules/room-membership/authorization/room-membership.rule.ts b/apps/server/src/modules/room-membership/authorization/room-membership.rule.ts index cfcd11c33af..544a8bdfacf 100644 --- a/apps/server/src/modules/room-membership/authorization/room-membership.rule.ts +++ b/apps/server/src/modules/room-membership/authorization/room-membership.rule.ts @@ -1,7 +1,7 @@ +import { Action, AuthorizationContext, AuthorizationInjectionService, Rule } from '@modules/authorization'; import { Injectable } from '@nestjs/common'; import { User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; -import { AuthorizationInjectionService, Action, AuthorizationContext, Rule } from '@modules/authorization'; import { RoomMembershipAuthorizable } from '../do/room-membership-authorizable.do'; @Injectable() @@ -10,13 +10,21 @@ export class RoomMembershipRule implements Rule { this.authorisationInjectionService.injectAuthorizationRule(this); } - public isApplicable(user: User, object: unknown): boolean { + public isApplicable(_: User, object: unknown): boolean { const isMatched = object instanceof RoomMembershipAuthorizable; return isMatched; } public hasPermission(user: User, object: RoomMembershipAuthorizable, context: AuthorizationContext): boolean { + if (!this.hasAccessToSchool(user, object.schoolId)) { + return false; + } + + if (!this.hasRequiredRoomPermissions(user, object, context.requiredPermissions)) { + return false; + } + const { action } = context; const permissionsThisUserHas = object.members .filter((member) => member.userId === user.id) @@ -28,4 +36,30 @@ export class RoomMembershipRule implements Rule { } return permissionsThisUserHas.includes(Permission.ROOM_EDIT); } + + private hasAccessToSchool(user: User, schoolId: string): boolean { + const primarySchoolId = user.school.id; + const secondarySchools = user.secondarySchools ?? []; + const secondarySchoolIds = secondarySchools.map(({ school }) => school.id); + + return [primarySchoolId, ...secondarySchoolIds].includes(schoolId); + } + + private hasRequiredRoomPermissions( + user: User, + object: RoomMembershipAuthorizable, + requiredPermissions: string[] + ): boolean { + const roomPermissionsOfUser = this.resolveRoomPermissions(user, object); + const missingPermissions = requiredPermissions.filter((permission) => !roomPermissionsOfUser.includes(permission)); + return missingPermissions.length === 0; + } + + private resolveRoomPermissions(user: User, object: RoomMembershipAuthorizable): string[] { + const member = object.members.find((m) => m.userId === user.id); + if (!member) { + return []; + } + return member.roles.flatMap((role) => role.permissions ?? []); + } } diff --git a/apps/server/src/modules/room-membership/do/room-membership-authorizable.do.ts b/apps/server/src/modules/room-membership/do/room-membership-authorizable.do.ts index 61821fa4b82..dbd969a84da 100644 --- a/apps/server/src/modules/room-membership/do/room-membership-authorizable.do.ts +++ b/apps/server/src/modules/room-membership/do/room-membership-authorizable.do.ts @@ -12,10 +12,13 @@ export class RoomMembershipAuthorizable implements AuthorizableObject { public readonly roomId: EntityId; + public readonly schoolId: EntityId; + public readonly members: UserWithRoomRoles[]; - public constructor(roomId: EntityId, members: UserWithRoomRoles[]) { + constructor(roomId: EntityId, members: UserWithRoomRoles[], schoolId: EntityId) { this.members = members; this.roomId = roomId; + this.schoolId = schoolId; } } diff --git a/apps/server/src/modules/room-membership/repo/entity/room-membership.entity.ts b/apps/server/src/modules/room-membership/repo/entity/room-membership.entity.ts index eafbfd3aeab..cec9df1ad1a 100644 --- a/apps/server/src/modules/room-membership/repo/entity/room-membership.entity.ts +++ b/apps/server/src/modules/room-membership/repo/entity/room-membership.entity.ts @@ -1,4 +1,4 @@ -import { Entity, Property, Unique } from '@mikro-orm/core'; +import { Entity, Index, Property, Unique } from '@mikro-orm/core'; import { ObjectIdType } from '@shared/repo/types/object-id.type'; import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; import { EntityId } from '@shared/domain/types'; @@ -14,6 +14,7 @@ export class RoomMembershipEntity extends BaseEntityWithTimestamps implements Ro @Property({ type: ObjectIdType, fieldName: 'userGroup' }) userGroupId!: EntityId; + @Index() @Property({ type: ObjectIdType, fieldName: 'school' }) schoolId!: EntityId; diff --git a/apps/server/src/modules/room-membership/service/room-membership.service.spec.ts b/apps/server/src/modules/room-membership/service/room-membership.service.spec.ts index 6f6b39139b2..7ee7e21aac2 100644 --- a/apps/server/src/modules/room-membership/service/room-membership.service.spec.ts +++ b/apps/server/src/modules/room-membership/service/room-membership.service.spec.ts @@ -87,34 +87,12 @@ describe('RoomMembershipService', () => { }; }; - it('should create new roomMembership when not exists', async () => { - const { user, room } = setup(); - - await service.addMembersToRoom(room.id, [{ userId: user.id, roleName: RoleName.ROOMEDITOR }]); - - expect(roomMembershipRepo.save).toHaveBeenCalled(); - }); - - it('should save the schoolId of the room in the roomMembership', async () => { - const { user, room } = setup(); - - await service.addMembersToRoom(room.id, [{ userId: user.id, roleName: RoleName.ROOMEDITOR }]); + it('should throw an exception', async () => { + const { room, user } = setup(); - expect(roomMembershipRepo.save).toHaveBeenCalledWith( - expect.objectContaining({ - schoolId: room.schoolId, - }) - ); - }); - - describe('when no user is provided', () => { - it('should throw an exception', async () => { - const { room } = setup(); - - roomMembershipRepo.findByRoomId.mockResolvedValue(null); + roomMembershipRepo.findByRoomId.mockResolvedValue(null); - await expect(service.addMembersToRoom(room.id, [])).rejects.toThrow(); - }); + await expect(service.addMembersToRoom(room.id, [user.id])).rejects.toThrow(); }); }); @@ -140,20 +118,21 @@ describe('RoomMembershipService', () => { }; }; - it('should add user to existing roomMembership', async () => { + it('should add user as admin to existing roomMembership', async () => { + // TODO: in the future, once room roles can be changed, this should become ROOMVIEWER const { user, room, group } = setup(); - await service.addMembersToRoom(room.id, [{ userId: user.id, roleName: RoleName.ROOMEDITOR }]); + await service.addMembersToRoom(room.id, [user.id]); expect(groupService.addUsersToGroup).toHaveBeenCalledWith(group.id, [ - { userId: user.id, roleName: RoleName.ROOMEDITOR }, + { userId: user.id, roleName: RoleName.ROOMADMIN }, ]); }); it('should add user to school', async () => { const { user, room } = setup(); - await service.addMembersToRoom(room.id, [{ userId: user.id, roleName: RoleName.ROOMEDITOR }]); + await service.addMembersToRoom(room.id, [user.id]); expect(userService.addSecondarySchoolToUsers).toHaveBeenCalledWith([user.id], room.schoolId); }); @@ -189,118 +168,148 @@ describe('RoomMembershipService', () => { }); describe('when roomMembership exists', () => { - const setup = () => { - const user = userFactory.buildWithId(); + const setupGroupAndRoom = (schoolId: string) => { const group = groupFactory.build({ type: GroupTypes.ROOM }); - const room = roomFactory.build(); - const roomMembership = roomMembershipFactory.build({ roomId: room.id, userGroupId: group.id }); + const room = roomFactory.build({ schoolId }); + const roomMembership = roomMembershipFactory.build({ + roomId: room.id, + userGroupId: group.id, + schoolId, + }); - roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); groupService.findById.mockResolvedValue(group); - groupService.findGroups.mockResolvedValue({ total: 1, data: [group] }); + roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); - return { - user, - room, - roomMembership, - group, - }; + return { group, room, roomMembership }; }; - it('should remove roomMembership', async () => { - const { user, room, group } = setup(); + const mockGroupsAtSchoolAfterRemoval = (groups: Group[]) => { + groupService.findGroups.mockResolvedValue({ total: groups.length, data: groups }); + }; - await service.removeMembersFromRoom(room.id, [user.id]); + const setupRoomRoles = () => { + const roomOwnerRole = roleFactory.buildWithId({ name: RoleName.ROOMOWNER }); + const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR }); + roleService.findByName.mockResolvedValue(roomOwnerRole); - expect(groupService.removeUsersFromGroup).toHaveBeenCalledWith(group.id, [user.id]); - }); - }); + return { roomOwnerRole, roomEditorRole }; + }; - const setupUserWithSecondarySchool = () => { - const secondarySchool = schoolFactory.build(); - const otherSchool = schoolFactory.build(); - const role = roleFactory.buildWithId({ name: RoleName.TEACHER }); - const guestTeacher = roleFactory.buildWithId({ name: RoleName.GUESTTEACHER }); - const externalUser = userDoFactory.buildWithId({ - roles: [role], - secondarySchools: [{ schoolId: secondarySchool.id, role: new RoleDto(guestTeacher) }], - }); + const setupUserWithSecondarySchool = () => { + const secondarySchool = schoolFactory.build(); + const otherSchool = schoolFactory.build(); + const role = roleFactory.buildWithId({ name: RoleName.TEACHER }); + const guestTeacher = roleFactory.buildWithId({ name: RoleName.GUESTTEACHER }); + const externalUser = userDoFactory.buildWithId({ + roles: [role], + secondarySchools: [{ schoolId: secondarySchool.id, role: new RoleDto(guestTeacher) }], + }); + const externalUserId = externalUser.id as string; - return { secondarySchool, externalUser, otherSchool }; - }; + return { secondarySchool, externalUser, externalUserId, otherSchool }; + }; - const setupGroupAndRoom = (schoolId: string) => { - const group = groupFactory.build({ type: GroupTypes.ROOM }); - const room = roomFactory.build({ schoolId }); - const roomMembership = roomMembershipFactory.build({ - roomId: room.id, - userGroupId: group.id, - schoolId, - }); + describe('when removing user from a different school, with no further groups on host school', () => { + const setup = () => { + const { secondarySchool, externalUserId } = setupUserWithSecondarySchool(); + const { roomEditorRole } = setupRoomRoles(); - return { group, room, roomMembership }; - }; + const { room, group } = setupGroupAndRoom(secondarySchool.id); + group.addUser({ userId: externalUserId, roleId: roomEditorRole.id }); - const mockGroupsAtSchoolAfterRemoval = (groups: Group[]) => { - groupService.findGroups.mockResolvedValue({ total: groups.length, data: groups }); - }; + mockGroupsAtSchoolAfterRemoval([]); - it('should pass the schoolId of the room', async () => { - const { secondarySchool, externalUser } = setupUserWithSecondarySchool(); + return { secondarySchool, externalUserId, room, group }; + }; - const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR }); + it('should pass the schoolId of the room', async () => { + const { secondarySchool, externalUserId, room } = setup(); - const { room, group, roomMembership } = setupGroupAndRoom(secondarySchool.id); - group.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); + await service.removeMembersFromRoom(room.id, [externalUserId]); - roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); - groupService.findById.mockResolvedValue(group); - groupService.removeUsersFromGroup.mockResolvedValue(group); - mockGroupsAtSchoolAfterRemoval([]); + expect(groupService.findGroups).toHaveBeenCalledWith( + expect.objectContaining({ schoolId: secondarySchool.id }) + ); + }); - await service.removeMembersFromRoom(room.id, [externalUser.id as string]); + it('should remove user from room', async () => { + const { group, externalUserId, room } = setup(); - expect(groupService.findGroups).toHaveBeenCalledWith(expect.objectContaining({ schoolId: secondarySchool.id })); - }); + await service.removeMembersFromRoom(room.id, [externalUserId]); - describe('when after removal: user is not in any room of that secondary school', () => { - it('should remove user from secondary school', async () => { - const { secondarySchool, externalUser } = setupUserWithSecondarySchool(); + expect(groupService.removeUsersFromGroup).toHaveBeenCalledWith(group.id, [externalUserId]); + }); - const { room, group, roomMembership } = setupGroupAndRoom(secondarySchool.id); - const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR }); - group.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); + it('should remove user from secondary school', async () => { + const { secondarySchool, externalUserId, room } = setup(); - roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); - groupService.findById.mockResolvedValue(group); - groupService.removeUsersFromGroup.mockResolvedValue(group); - mockGroupsAtSchoolAfterRemoval([]); + await service.removeMembersFromRoom(room.id, [externalUserId]); + + expect(userService.removeSecondarySchoolFromUsers).toHaveBeenCalledWith([externalUserId], secondarySchool.id); + }); + }); + + describe('when removing user from a different school, with further groups on host school', () => { + const setup = () => { + const { secondarySchool, externalUser } = setupUserWithSecondarySchool(); + const { roomEditorRole } = setupRoomRoles(); + + const { room, group } = setupGroupAndRoom(secondarySchool.id); + group.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); + const { group: group2 } = setupGroupAndRoom(secondarySchool.id); + group2.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); - await service.removeMembersFromRoom(room.id, [externalUser.id as string]); + mockGroupsAtSchoolAfterRemoval([group2]); - expect(userService.removeSecondarySchoolFromUsers).toHaveBeenCalledWith([externalUser.id], secondarySchool.id); + return { externalUser, room }; + }; + + it('should not remove user from secondary school', async () => { + const { externalUser, room } = setup(); + + await service.removeMembersFromRoom(room.id, [externalUser.id as string]); + + expect(userService.removeSecondarySchoolFromUsers).not.toHaveBeenCalled(); + }); }); - }); - describe('when after removal: user is still in a room of that secondary school', () => { - it('should not remove user from secondary school', async () => { - const { secondarySchool, externalUser } = setupUserWithSecondarySchool(); + describe('when removing user from the same school', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const { roomEditorRole } = setupRoomRoles(); + const { room, group } = setupGroupAndRoom(user.school.id); + group.addUser({ userId: user.id, roleId: roomEditorRole.id }); - const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR }); + mockGroupsAtSchoolAfterRemoval([group]); - const { room, group, roomMembership } = setupGroupAndRoom(secondarySchool.id); - group.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); - const { group: group2 } = setupGroupAndRoom(secondarySchool.id); - group2.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); + return { user, room, group }; + }; - roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); - groupService.findById.mockResolvedValue(group); - groupService.removeUsersFromGroup.mockResolvedValue(group); - mockGroupsAtSchoolAfterRemoval([group2]); + it('should remove user from room', async () => { + const { user, group, room } = setup(); - await service.removeMembersFromRoom(room.id, [externalUser.id as string]); + await service.removeMembersFromRoom(room.id, [user.id]); - expect(userService.removeSecondarySchoolFromUsers).not.toHaveBeenCalled(); + expect(groupService.removeUsersFromGroup).toHaveBeenCalledWith(group.id, [user.id]); + }); + }); + + describe('when removing the owner of the room', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const { room, group } = setupGroupAndRoom(user.school.id); + const { roomOwnerRole } = setupRoomRoles(); + + group.addUser({ userId: user.id, roleId: roomOwnerRole.id }); + + return { user, room }; + }; + + it('should throw a badrequest exception', async () => { + const { user, room } = setup(); + + await expect(service.removeMembersFromRoom(room.id, [user.id])).rejects.toThrowError(BadRequestException); + }); }); }); }); @@ -371,6 +380,7 @@ describe('RoomMembershipService', () => { it('should return empty RoomMembershipAuthorizable when roomMembership not exists', async () => { const roomId = 'nonexistent'; roomMembershipRepo.findByRoomId.mockResolvedValue(null); + roomService.getSingleRoom.mockResolvedValue(roomFactory.build({ id: roomId })); const result = await service.getRoomMembershipAuthorizable(roomId); diff --git a/apps/server/src/modules/room-membership/service/room-membership.service.ts b/apps/server/src/modules/room-membership/service/room-membership.service.ts index 59fa6167c6d..ad6af25fa37 100644 --- a/apps/server/src/modules/room-membership/service/room-membership.service.ts +++ b/apps/server/src/modules/room-membership/service/room-membership.service.ts @@ -20,11 +20,7 @@ export class RoomMembershipService { private readonly userService: UserService ) {} - private async createNewRoomMembership( - roomId: EntityId, - userId: EntityId, - roleName: RoleName.ROOMEDITOR | RoleName.ROOMVIEWER - ): Promise { + public async createNewRoomMembership(roomId: EntityId, ownerUserId: EntityId): Promise { const room = await this.roomService.getSingleRoom(roomId); const group = await this.groupService.createGroup( @@ -32,7 +28,7 @@ export class RoomMembershipService { GroupTypes.ROOM, room.schoolId ); - await this.groupService.addUsersToGroup(group.id, [{ userId, roleName }]); + await this.groupService.addUsersToGroup(group.id, [{ userId: ownerUserId, roleName: RoleName.ROOMOWNER }]); const roomMembership = new RoomMembership({ id: new ObjectId().toHexString(), @@ -51,7 +47,8 @@ export class RoomMembershipService { private buildRoomMembershipAuthorizable( roomId: EntityId, group: Group, - roleSet: RoleDto[] + roleSet: RoleDto[], + schoolId: EntityId ): RoomMembershipAuthorizable { const members = group.users.map((groupUser): UserWithRoomRoles => { const roleDto = roleSet.find((role) => role.id === groupUser.roleId); @@ -62,7 +59,7 @@ export class RoomMembershipService { }; }); - const roomMembershipAuthorizable = new RoomMembershipAuthorizable(roomId, members); + const roomMembershipAuthorizable = new RoomMembershipAuthorizable(roomId, members, schoolId); return roomMembershipAuthorizable; } @@ -76,23 +73,17 @@ export class RoomMembershipService { await this.roomMembershipRepo.delete(roomMembership); } - public async addMembersToRoom( - roomId: EntityId, - userIdsAndRoles: Array<{ userId: EntityId; roleName: RoleName.ROOMEDITOR | RoleName.ROOMVIEWER }> - ): Promise { + public async addMembersToRoom(roomId: EntityId, userIds: Array): Promise { const roomMembership = await this.roomMembershipRepo.findByRoomId(roomId); if (roomMembership === null) { - const firstUser = userIdsAndRoles.shift(); - if (firstUser === undefined) { - throw new BadRequestException('No user provided'); - } - const newRoomMembership = await this.createNewRoomMembership(roomId, firstUser.userId, firstUser.roleName); - return newRoomMembership.id; + throw new Error('Room membership not found'); } + const userIdsAndRoles = userIds.map((userId) => { + return { userId, roleName: RoleName.ROOMADMIN }; + }); await this.groupService.addUsersToGroup(roomMembership.userGroupId, userIdsAndRoles); - const userIds = userIdsAndRoles.map((user) => user.userId); await this.userService.addSecondarySchoolToUsers(userIds, roomMembership.schoolId); return roomMembership.id; @@ -105,6 +96,8 @@ export class RoomMembershipService { } const group = await this.groupService.findById(roomMembership.userGroupId); + + await this.ensureOwnerIsNotRemoved(group, userIds); await this.groupService.removeUsersFromGroup(group.id, userIds); await this.handleGuestRoleRemoval(userIds, roomMembership.schoolId); @@ -120,7 +113,7 @@ export class RoomMembershipService { .map((item) => { const group = groupPage.data.find((g) => g.id === item.userGroupId); if (!group) return null; - return this.buildRoomMembershipAuthorizable(item.roomId, group, roleSet); + return this.buildRoomMembershipAuthorizable(item.roomId, group, roleSet, item.schoolId); }) .filter((item): item is RoomMembershipAuthorizable => item !== null); @@ -130,7 +123,8 @@ export class RoomMembershipService { public async getRoomMembershipAuthorizable(roomId: EntityId): Promise { const roomMembership = await this.roomMembershipRepo.findByRoomId(roomId); if (roomMembership === null) { - return new RoomMembershipAuthorizable(roomId, []); + const room = await this.roomService.getSingleRoom(roomId); + return new RoomMembershipAuthorizable(roomId, [], room.schoolId); } const group = await this.groupService.findById(roomMembership.userGroupId); const roleSet = await this.roleService.findByIds(group.users.map((groupUser) => groupUser.roleId)); @@ -144,11 +138,22 @@ export class RoomMembershipService { }; }); - const roomMembershipAuthorizable = new RoomMembershipAuthorizable(roomId, members); + const roomMembershipAuthorizable = new RoomMembershipAuthorizable(roomId, members, roomMembership.schoolId); return roomMembershipAuthorizable; } + private async ensureOwnerIsNotRemoved(group: Group, userIds: EntityId[]): Promise { + const role = await this.roleService.findByName(RoleName.ROOMOWNER); + const includedOwner = group.users + .filter((groupUser) => userIds.includes(groupUser.userId)) + .find((groupUser) => groupUser.roleId === role.id); + + if (includedOwner) { + throw new BadRequestException('Cannot remove owner from room'); + } + } + private async handleGuestRoleRemoval(userIds: EntityId[], schoolId: EntityId): Promise { const { data: groups } = await this.groupService.findGroups({ userIds, groupTypes: [GroupTypes.ROOM], schoolId }); diff --git a/apps/server/src/modules/room/api/dto/request/add-room-members.body.params.ts b/apps/server/src/modules/room/api/dto/request/add-room-members.body.params.ts index 93cb5556460..f27f142a654 100644 --- a/apps/server/src/modules/room/api/dto/request/add-room-members.body.params.ts +++ b/apps/server/src/modules/room/api/dto/request/add-room-members.body.params.ts @@ -1,32 +1,12 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsMongoId, IsString, ValidateNested } from 'class-validator'; -import { Type } from 'class-transformer'; -import { RoomRole, RoomRoleArray } from '@shared/domain/interface'; - -class UserIdAndRole { - @ApiProperty({ - description: 'The ID of the user', - required: true, - }) - @IsMongoId() - userId!: string; - - @ApiProperty({ - description: 'The role of the user', - required: true, - enum: RoomRoleArray, - }) - @IsString() - roleName!: RoomRole; -} +import { IsArray, IsMongoId } from 'class-validator'; export class AddRoomMembersBodyParams { @ApiProperty({ - description: 'Array of userIds and their roles inside of the room', + description: 'The IDs of the users', required: true, - type: [UserIdAndRole], }) - @ValidateNested({ each: true }) - @Type(() => UserIdAndRole) - userIdsAndRoles!: UserIdAndRole[]; + @IsArray() + @IsMongoId({ each: true }) + public userIds!: string[]; } diff --git a/apps/server/src/modules/room/api/room.controller.ts b/apps/server/src/modules/room/api/room.controller.ts index b9a125787cc..b130516f93c 100644 --- a/apps/server/src/modules/room/api/room.controller.ts +++ b/apps/server/src/modules/room/api/room.controller.ts @@ -47,7 +47,7 @@ export class RoomController { @ApiResponse({ status: HttpStatus.UNAUTHORIZED, type: UnauthorizedException }) @ApiResponse({ status: HttpStatus.FORBIDDEN, type: ForbiddenException }) @ApiResponse({ status: '5XX', type: ErrorResponse }) - async getRooms( + public async getRooms( @CurrentUser() currentUser: ICurrentUser, @Query() pagination: RoomPaginationParams ): Promise { @@ -67,7 +67,7 @@ export class RoomController { @ApiResponse({ status: HttpStatus.UNAUTHORIZED, type: UnauthorizedException }) @ApiResponse({ status: HttpStatus.FORBIDDEN, type: ForbiddenException }) @ApiResponse({ status: '5XX', type: ErrorResponse }) - async createRoom( + public async createRoom( @CurrentUser() currentUser: ICurrentUser, @Body() createRoomParams: CreateRoomBodyParams ): Promise { @@ -86,7 +86,7 @@ export class RoomController { @ApiResponse({ status: HttpStatus.FORBIDDEN, type: ForbiddenException }) @ApiResponse({ status: HttpStatus.NOT_FOUND, type: NotFoundException }) @ApiResponse({ status: '5XX', type: ErrorResponse }) - async getRoomDetails( + public async getRoomDetails( @CurrentUser() currentUser: ICurrentUser, @Param() urlParams: RoomUrlParams ): Promise { @@ -105,7 +105,7 @@ export class RoomController { @ApiResponse({ status: HttpStatus.FORBIDDEN, type: ForbiddenException }) @ApiResponse({ status: HttpStatus.NOT_FOUND, type: NotFoundException }) @ApiResponse({ status: '5XX', type: ErrorResponse }) - async getRoomBoards( + public async getRoomBoards( @CurrentUser() currentUser: ICurrentUser, @Param() urlParams: RoomUrlParams ): Promise { @@ -124,7 +124,7 @@ export class RoomController { @ApiResponse({ status: HttpStatus.FORBIDDEN, type: ForbiddenException }) @ApiResponse({ status: HttpStatus.NOT_FOUND, type: NotFoundException }) @ApiResponse({ status: '5XX', type: ErrorResponse }) - async updateRoom( + public async updateRoom( @CurrentUser() currentUser: ICurrentUser, @Param() urlParams: RoomUrlParams, @Body() updateRoomParams: UpdateRoomBodyParams @@ -145,7 +145,7 @@ export class RoomController { @ApiResponse({ status: HttpStatus.NOT_FOUND, type: NotFoundException }) @ApiResponse({ status: '5XX', type: ErrorResponse }) @HttpCode(204) - async deleteRoom(@CurrentUser() currentUser: ICurrentUser, @Param() urlParams: RoomUrlParams): Promise { + public async deleteRoom(@CurrentUser() currentUser: ICurrentUser, @Param() urlParams: RoomUrlParams): Promise { await this.roomUc.deleteRoom(currentUser.userId, urlParams.roomId); } @@ -156,12 +156,12 @@ export class RoomController { @ApiResponse({ status: HttpStatus.UNAUTHORIZED, type: UnauthorizedException }) @ApiResponse({ status: HttpStatus.FORBIDDEN, type: ForbiddenException }) @ApiResponse({ status: '5XX', type: ErrorResponse }) - async addMembers( + public async addMembers( @CurrentUser() currentUser: ICurrentUser, @Param() urlParams: RoomUrlParams, @Body() bodyParams: AddRoomMembersBodyParams ): Promise { - await this.roomUc.addMembersToRoom(currentUser.userId, urlParams.roomId, bodyParams.userIdsAndRoles); + await this.roomUc.addMembersToRoom(currentUser.userId, urlParams.roomId, bodyParams.userIds); } @Patch(':roomId/members/remove') @@ -171,7 +171,7 @@ export class RoomController { @ApiResponse({ status: HttpStatus.UNAUTHORIZED, type: UnauthorizedException }) @ApiResponse({ status: HttpStatus.FORBIDDEN, type: ForbiddenException }) @ApiResponse({ status: '5XX', type: ErrorResponse }) - async removeMembers( + public async removeMembers( @CurrentUser() currentUser: ICurrentUser, @Param() urlParams: RoomUrlParams, @Body() bodyParams: RemoveRoomMembersBodyParams @@ -190,7 +190,7 @@ export class RoomController { @ApiResponse({ status: HttpStatus.UNAUTHORIZED, type: UnauthorizedException }) @ApiResponse({ status: HttpStatus.FORBIDDEN, type: ForbiddenException }) @ApiResponse({ status: '5XX', type: ErrorResponse }) - async getMembers( + public async getMembers( @CurrentUser() currentUser: ICurrentUser, @Param() urlParams: RoomUrlParams ): Promise { diff --git a/apps/server/src/modules/room/api/room.uc.spec.ts b/apps/server/src/modules/room/api/room.uc.spec.ts index 8910130093e..95cd6f7f6cd 100644 --- a/apps/server/src/modules/room/api/room.uc.spec.ts +++ b/apps/server/src/modules/room/api/room.uc.spec.ts @@ -117,7 +117,7 @@ describe('RoomUc', () => { authorizationService.checkOneOfPermissions.mockReturnValue(undefined); const room = roomFactory.build(); roomService.createRoom.mockResolvedValue(room); - roomMembershipService.addMembersToRoom.mockRejectedValue(new Error('test')); + roomMembershipService.createNewRoomMembership.mockRejectedValue(new Error('test')); return { user, room }; }; diff --git a/apps/server/src/modules/room/api/room.uc.ts b/apps/server/src/modules/room/api/room.uc.ts index a80e2838c66..39ed7379be0 100644 --- a/apps/server/src/modules/room/api/room.uc.ts +++ b/apps/server/src/modules/room/api/room.uc.ts @@ -5,7 +5,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { FeatureDisabledLoggableException } from '@shared/common/loggable-exception'; import { Page, UserDO } from '@shared/domain/domainobject'; -import { IFindOptions, Permission, RoleName, RoomRole } from '@shared/domain/interface'; +import { IFindOptions, Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { BoardExternalReferenceType, ColumnBoard, ColumnBoardService } from '@modules/board'; import { Room, RoomService } from '../domain'; @@ -40,14 +40,13 @@ export class RoomUc { this.authorizationService.checkOneOfPermissions(user, [Permission.ROOM_CREATE]); - await this.roomMembershipService - .addMembersToRoom(room.id, [{ userId: user.id, roleName: RoleName.ROOMEDITOR }]) - .catch(async (err) => { - await this.roomService.deleteRoom(room); - throw err; - }); - - return room; + try { + await this.roomMembershipService.createNewRoomMembership(room.id, userId); + return room; + } catch (err) { + await this.roomService.deleteRoom(room); + throw err; + } } public async getSingleRoom(userId: EntityId, roomId: EntityId): Promise<{ room: Room; permissions: Permission[] }> { @@ -126,17 +125,13 @@ export class RoomUc { return memberResponses; } - public async addMembersToRoom( - currentUserId: EntityId, - roomId: EntityId, - userIdsAndRoles: Array<{ userId: EntityId; roleName: RoomRole }> - ): Promise { + public async addMembersToRoom(currentUserId: EntityId, roomId: EntityId, userIds: Array): Promise { this.checkFeatureEnabled(); - await this.checkRoomAuthorization(currentUserId, roomId, Action.write); - await this.roomMembershipService.addMembersToRoom(roomId, userIdsAndRoles); + await this.checkRoomAuthorization(currentUserId, roomId, Action.write, [Permission.ROOM_MEMBERS_ADD]); + await this.roomMembershipService.addMembersToRoom(roomId, userIds); } - private mapToMember(member: UserWithRoomRoles, user: UserDO) { + private mapToMember(member: UserWithRoomRoles, user: UserDO): RoomMemberResponse { return new RoomMemberResponse({ userId: member.userId, firstName: user.firstName, @@ -148,7 +143,7 @@ export class RoomUc { public async removeMembersFromRoom(currentUserId: EntityId, roomId: EntityId, userIds: EntityId[]): Promise { this.checkFeatureEnabled(); - await this.checkRoomAuthorization(currentUserId, roomId, Action.write); + await this.checkRoomAuthorization(currentUserId, roomId, Action.write, [Permission.ROOM_MEMBERS_REMOVE]); await this.roomMembershipService.removeMembersFromRoom(roomId, userIds); } diff --git a/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts b/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts index a1ed8579853..a4a9a09f6e9 100644 --- a/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts @@ -54,6 +54,15 @@ describe('Room Controller (API)', () => { const teacherGuestRole = roleFactory.buildWithId({ name: RoleName.GUESTTEACHER }); const studentGuestRole = roleFactory.buildWithId({ name: RoleName.GUESTSTUDENT }); const role = roleFactory.buildWithId({ + name: RoleName.ROOMADMIN, + permissions: [ + Permission.ROOM_VIEW, + Permission.ROOM_EDIT, + Permission.ROOM_MEMBERS_ADD, + Permission.ROOM_MEMBERS_REMOVE, + ], + }); + const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_VIEW, Permission.ROOM_EDIT], }); @@ -65,7 +74,11 @@ describe('Room Controller (API)', () => { externalSource: undefined, }); - const roomMemberships = roomMembershipEntityFactory.build({ userGroupId: userGroupEntity.id, roomId: room.id }); + const roomMemberships = roomMembershipEntityFactory.build({ + userGroupId: userGroupEntity.id, + roomId: room.id, + schoolId: school.id, + }); await em.persistAndFlush([ room, roomMemberships, @@ -73,6 +86,7 @@ describe('Room Controller (API)', () => { teacherUser, teacherGuestRole, studentGuestRole, + roomEditorRole, otherTeacherUser, otherTeacherAccount, userGroupEntity, @@ -87,7 +101,9 @@ describe('Room Controller (API)', () => { describe('when the user is not authenticated', () => { it('should return a 401 error', async () => { const { room } = await setupRoomWithMembers(); + const response = await testApiClient.patch(`/${room.id}/members/add`); + expect(response.status).toBe(HttpStatus.UNAUTHORIZED); }); }); @@ -96,15 +112,19 @@ describe('Room Controller (API)', () => { const setupLoggedInUser = async () => { const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); await em.persistAndFlush([teacherAccount, teacherUser]); + const loggedInClient = await testApiClient.login(teacherAccount); + return { loggedInClient }; }; it('should return forbidden error', async () => { - const { room } = await setupRoomWithMembers(); + const { room, otherTeacherUser } = await setupRoomWithMembers(); const { loggedInClient } = await setupLoggedInUser(); - const response = await loggedInClient.patch(`/${room.id}/members/add`); + const response = await loggedInClient.patch(`/${room.id}/members/add`, { + userIds: [otherTeacherUser.id], + }); expect(response.status).toBe(HttpStatus.FORBIDDEN); }); @@ -112,10 +132,12 @@ describe('Room Controller (API)', () => { describe('when the feature is disabled', () => { it('should return a 403 error', async () => { - const { loggedInClient, room } = await setupRoomWithMembers(); + const { loggedInClient, room, otherTeacherUser } = await setupRoomWithMembers(); config.FEATURE_ROOMS_ENABLED = false; - const response = await loggedInClient.patch(`/${room.id}/members/add`); + const response = await loggedInClient.patch(`/${room.id}/members/add`, { + userIds: [otherTeacherUser.id], + }); expect(response.status).toBe(HttpStatus.FORBIDDEN); }); @@ -126,7 +148,7 @@ describe('Room Controller (API)', () => { const { loggedInClient, room, otherTeacherUser } = await setupRoomWithMembers(); const response = await loggedInClient.patch(`/${room.id}/members/add`, { - userIdsAndRoles: [{ userId: otherTeacherUser.id, roleName: RoleName.ROOMEDITOR }], + userIds: [otherTeacherUser.id], }); expect(response.status).toBe(HttpStatus.OK); diff --git a/apps/server/src/modules/room/api/test/room-create.api.spec.ts b/apps/server/src/modules/room/api/test/room-create.api.spec.ts index eeca260725b..47cecf68d32 100644 --- a/apps/server/src/modules/room/api/test/room-create.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-create.api.spec.ts @@ -69,10 +69,20 @@ describe('Room Controller (API)', () => { const setup = async () => { const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); const role = roleFactory.buildWithId({ - name: RoleName.ROOMEDITOR, - permissions: [Permission.ROOM_EDIT, Permission.ROOM_VIEW], + name: RoleName.TEACHER, + permissions: [Permission.ROOM_CREATE, Permission.ROOM_EDIT, Permission.ROOM_VIEW], }); - await em.persistAndFlush([teacherAccount, teacherUser, role]); + const roomOwnerRole = roleFactory.buildWithId({ + name: RoleName.ROOMOWNER, + permissions: [ + Permission.ROOM_CREATE, + Permission.ROOM_EDIT, + Permission.ROOM_VIEW, + Permission.ROOM_MEMBERS_ADD, + Permission.ROOM_MEMBERS_REMOVE, + ], + }); + await em.persistAndFlush([teacherAccount, teacherUser, role, roomOwnerRole]); em.clear(); const loggedInClient = await testApiClient.login(teacherAccount); diff --git a/apps/server/src/modules/room/api/test/room-delete.api.spec.ts b/apps/server/src/modules/room/api/test/room-delete.api.spec.ts index 22f74c7edc8..a088b76b872 100644 --- a/apps/server/src/modules/room/api/test/room-delete.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-delete.api.spec.ts @@ -10,6 +10,7 @@ import { cleanupCollections, groupEntityFactory, roleFactory, + schoolEntityFactory, } from '@shared/testing'; import { RoomMembershipEntity } from '@src/modules/room-membership'; import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing/room-membership-entity.factory'; @@ -95,27 +96,50 @@ describe('Room Controller (API)', () => { describe('when the user has the required permissions', () => { const setup = async () => { const room = roomEntityFactory.build(); - const role = roleFactory.buildWithId({ + const roomOwnerRole = roleFactory.buildWithId({ + name: RoleName.ROOMOWNER, + permissions: [Permission.ROOM_EDIT, Permission.ROOM_DELETE], + }); + const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_EDIT], }); - const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const school = schoolEntityFactory.buildWithId(); + const { teacherAccount: teacherOwnerAccount, teacherUser: teacherOwnerUser } = + UserAndAccountTestFactory.buildTeacher({ school }); + const { teacherAccount: teacherEditorAccount, teacherUser: teacherEditorUser } = + UserAndAccountTestFactory.buildTeacher({ school }); const userGroup = groupEntityFactory.buildWithId({ type: GroupEntityTypes.ROOM, - users: [{ role, user: teacherUser }], + users: [ + { role: roomOwnerRole, user: teacherOwnerUser }, + { role: roomEditorRole, user: teacherEditorUser }, + ], }); - const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); - await em.persistAndFlush([room, roomMembership, teacherAccount, teacherUser, userGroup, role]); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + schoolId: teacherOwnerUser.school.id, + }); + await em.persistAndFlush([ + room, + roomMembership, + teacherOwnerAccount, + teacherOwnerUser, + teacherEditorAccount, + teacherEditorUser, + userGroup, + roomOwnerRole, + ]); em.clear(); - const loggedInClient = await testApiClient.login(teacherAccount); - - return { loggedInClient, room }; + return { teacherOwnerAccount, teacherEditorAccount, room }; }; describe('when the room exists', () => { it('should delete the room', async () => { - const { loggedInClient, room } = await setup(); + const { teacherOwnerAccount, room } = await setup(); + const loggedInClient = await testApiClient.login(teacherOwnerAccount); const response = await loggedInClient.delete(room.id); expect(response.status).toBe(HttpStatus.NO_CONTENT); @@ -123,7 +147,8 @@ describe('Room Controller (API)', () => { }); it('should delete the roomMembership', async () => { - const { loggedInClient, room } = await setup(); + const { teacherOwnerAccount, room } = await setup(); + const loggedInClient = await testApiClient.login(teacherOwnerAccount); await expect(em.findOneOrFail(RoomMembershipEntity, { roomId: room.id })).resolves.not.toThrow(); @@ -131,11 +156,23 @@ describe('Room Controller (API)', () => { expect(response.status).toBe(HttpStatus.NO_CONTENT); await expect(em.findOneOrFail(RoomMembershipEntity, { roomId: room.id })).rejects.toThrow(NotFoundException); }); + + describe('when user is not the roomowner', () => { + it('should fail', async () => { + const { teacherEditorAccount, room } = await setup(); + const loggedInClient = await testApiClient.login(teacherEditorAccount); + + const response = await loggedInClient.delete(room.id); + + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); + }); }); describe('when the room does not exist', () => { it('should return a 404 error', async () => { - const { loggedInClient } = await setup(); + const { teacherOwnerAccount } = await setup(); + const loggedInClient = await testApiClient.login(teacherOwnerAccount); const someId = new ObjectId().toHexString(); const response = await loggedInClient.delete(someId); diff --git a/apps/server/src/modules/room/api/test/room-get-boards.api.spec.ts b/apps/server/src/modules/room/api/test/room-get-boards.api.spec.ts index 4f1646ec708..2f5dc502c70 100644 --- a/apps/server/src/modules/room/api/test/room-get-boards.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-get-boards.api.spec.ts @@ -6,6 +6,7 @@ import { cleanupCollections, groupEntityFactory, roleFactory, + schoolEntityFactory, TestApiClient, UserAndAccountTestFactory, } from '@shared/testing'; @@ -94,11 +95,12 @@ describe('Room Controller (API)', () => { describe('when the user has the required permissions', () => { const setup = async () => { - const room = roomEntityFactory.build(); + const school = schoolEntityFactory.buildWithId(); + const room = roomEntityFactory.build({ schoolId: school.id }); const board = columnBoardEntityFactory.build({ context: { type: BoardExternalReferenceType.Room, id: room.id }, }); - const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent({ school }); const role = roleFactory.buildWithId({ name: RoleName.ROOMVIEWER, permissions: [Permission.ROOM_VIEW], @@ -109,7 +111,11 @@ describe('Room Controller (API)', () => { organization: studentUser.school, externalSource: undefined, }); - const roomMembership = roomMembershipEntityFactory.build({ userGroupId: userGroupEntity.id, roomId: room.id }); + const roomMembership = roomMembershipEntityFactory.build({ + userGroupId: userGroupEntity.id, + roomId: room.id, + schoolId: school.id, + }); await em.persistAndFlush([room, board, studentAccount, studentUser, role, userGroupEntity, roomMembership]); em.clear(); diff --git a/apps/server/src/modules/room/api/test/room-get.api.spec.ts b/apps/server/src/modules/room/api/test/room-get.api.spec.ts index 719889d82ec..b0aa9afcc68 100644 --- a/apps/server/src/modules/room/api/test/room-get.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-get.api.spec.ts @@ -8,6 +8,7 @@ import { cleanupCollections, groupEntityFactory, roleFactory, + schoolEntityFactory, } from '@shared/testing'; import { GroupEntityTypes } from '@modules/group/entity/group.entity'; import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing'; @@ -92,8 +93,9 @@ describe('Room Controller (API)', () => { describe('when the user has the required permissions', () => { const setup = async () => { - const room = roomEntityFactory.build(); - const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const school = schoolEntityFactory.buildWithId(); + const room = roomEntityFactory.build({ schoolId: school.id }); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent({ school }); const role = roleFactory.buildWithId({ name: RoleName.ROOMVIEWER, permissions: [Permission.ROOM_VIEW], @@ -104,7 +106,11 @@ describe('Room Controller (API)', () => { organization: studentUser.school, externalSource: undefined, }); - const roomMembership = roomMembershipEntityFactory.build({ userGroupId: userGroupEntity.id, roomId: room.id }); + const roomMembership = roomMembershipEntityFactory.build({ + userGroupId: userGroupEntity.id, + roomId: room.id, + schoolId: school.id, + }); await em.persistAndFlush([room, studentAccount, studentUser, role, userGroupEntity, roomMembership]); em.clear(); diff --git a/apps/server/src/modules/room/api/test/room-index.api.spec.ts b/apps/server/src/modules/room/api/test/room-index.api.spec.ts index cbb68d0f38c..162fb503a1b 100644 --- a/apps/server/src/modules/room/api/test/room-index.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-index.api.spec.ts @@ -9,6 +9,7 @@ import { cleanupCollections, groupEntityFactory, roleFactory, + schoolEntityFactory, } from '@shared/testing'; import { GroupEntityTypes } from '@modules/group/entity/group.entity'; import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing/room-membership-entity.factory'; @@ -128,8 +129,9 @@ describe('Room Controller (API)', () => { describe('when the user has the required permissions', () => { const setup = async () => { - const rooms = roomEntityFactory.buildListWithId(2); - const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const school = schoolEntityFactory.buildWithId(); + const rooms = roomEntityFactory.buildListWithId(2, { schoolId: school.id }); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent({ school }); const role = roleFactory.buildWithId({ name: RoleName.ROOMVIEWER, permissions: [Permission.ROOM_VIEW], @@ -141,7 +143,7 @@ describe('Room Controller (API)', () => { externalSource: undefined, }); const roomMemberships = rooms.map((room) => - roomMembershipEntityFactory.build({ userGroupId: userGroupEntity.id, roomId: room.id }) + roomMembershipEntityFactory.build({ userGroupId: userGroupEntity.id, roomId: room.id, schoolId: school.id }) ); await em.persistAndFlush([...rooms, ...roomMemberships, studentAccount, studentUser, userGroupEntity]); em.clear(); diff --git a/apps/server/src/modules/room/api/test/room-members.api.spec.ts b/apps/server/src/modules/room/api/test/room-members.api.spec.ts index c509e59c41b..2543d750b9e 100644 --- a/apps/server/src/modules/room/api/test/room-members.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-members.api.spec.ts @@ -9,6 +9,7 @@ import { cleanupCollections, groupEntityFactory, roleFactory, + schoolEntityFactory, userFactory, } from '@shared/testing'; import { GroupEntityTypes } from '@modules/group/entity/group.entity'; @@ -47,8 +48,9 @@ describe('Room Controller (API)', () => { describe('GET /rooms/:roomId/members', () => { const setupRoomWithMembers = async () => { + const school = schoolEntityFactory.buildWithId(); const room = roomEntityFactory.buildWithId(); - const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); const editRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_VIEW, Permission.ROOM_EDIT], @@ -57,8 +59,8 @@ describe('Room Controller (API)', () => { name: RoleName.ROOMVIEWER, permissions: [Permission.ROOM_VIEW], }); - const students = userFactory.buildList(2); - const teachers = userFactory.buildList(2); + const students = userFactory.buildList(2, { school }); + const teachers = userFactory.buildList(2, { school }); const userGroupEntity = groupEntityFactory.buildWithId({ users: [ { role: editRole, user: teacherUser }, @@ -71,7 +73,11 @@ describe('Room Controller (API)', () => { organization: teacherUser.school, externalSource: undefined, }); - const roomMemberships = roomMembershipEntityFactory.build({ userGroupId: userGroupEntity.id, roomId: room.id }); + const roomMemberships = roomMembershipEntityFactory.build({ + userGroupId: userGroupEntity.id, + roomId: room.id, + schoolId: school.id, + }); await em.persistAndFlush([ room, roomMemberships, diff --git a/apps/server/src/modules/room/api/test/room-remove-members.api.spec.ts b/apps/server/src/modules/room/api/test/room-remove-members.api.spec.ts index d87d0e68314..f52dfc0bf2d 100644 --- a/apps/server/src/modules/room/api/test/room-remove-members.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-remove-members.api.spec.ts @@ -9,6 +9,7 @@ import { cleanupCollections, groupEntityFactory, roleFactory, + schoolEntityFactory, } from '@shared/testing'; import { GroupEntityTypes } from '@modules/group/entity/group.entity'; import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing/room-membership-entity.factory'; @@ -45,32 +46,48 @@ describe('Room Controller (API)', () => { describe('PATCH /rooms/:roomId/members/remove', () => { const setupRoomRoles = () => { - const editorRole = roleFactory.buildWithId({ - name: RoleName.ROOMEDITOR, - permissions: [Permission.ROOM_VIEW, Permission.ROOM_EDIT], + const ownerRole = roleFactory.buildWithId({ + name: RoleName.ROOMOWNER, + permissions: [ + Permission.ROOM_VIEW, + Permission.ROOM_EDIT, + Permission.ROOM_DELETE, + Permission.ROOM_MEMBERS_ADD, + Permission.ROOM_MEMBERS_REMOVE, + ], + }); + const adminRole = roleFactory.buildWithId({ + name: RoleName.ROOMADMIN, + permissions: [ + Permission.ROOM_VIEW, + Permission.ROOM_EDIT, + Permission.ROOM_MEMBERS_ADD, + Permission.ROOM_MEMBERS_REMOVE, + ], }); const viewerRole = roleFactory.buildWithId({ name: RoleName.ROOMVIEWER, permissions: [Permission.ROOM_VIEW], }); - return { editorRole, viewerRole }; + return { ownerRole, adminRole, viewerRole }; }; const setupRoomWithMembers = async () => { - const room = roomEntityFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); + const room = roomEntityFactory.buildWithId({ schoolId: school.id }); - const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); - const { teacherUser: inRoomEditor2 } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); - const { teacherUser: inRoomEditor3 } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const { teacherUser: inRoomAdmin2 } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); + const { teacherUser: inRoomAdmin3 } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); const { teacherUser: inRoomViewer } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); const { teacherUser: outTeacher } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); - const users = { teacherUser, inRoomEditor2, inRoomEditor3, inRoomViewer, outTeacher }; + const users = { teacherUser, inRoomAdmin2, inRoomAdmin3, inRoomViewer, outTeacher }; - const { editorRole, viewerRole } = setupRoomRoles(); + const { ownerRole, adminRole, viewerRole } = setupRoomRoles(); - const roomUsers = [teacherUser, inRoomEditor2, inRoomEditor3].map((user) => { - return { role: editorRole, user }; + const roomUsers = [teacherUser, inRoomAdmin2, inRoomAdmin3].map((user) => { + return { role: adminRole, user }; }); roomUsers.push({ role: viewerRole, user: inRoomViewer }); @@ -81,9 +98,20 @@ describe('Room Controller (API)', () => { externalSource: undefined, }); - const roomMemberships = roomMembershipEntityFactory.build({ userGroupId: userGroupEntity.id, roomId: room.id }); + const roomMemberships = roomMembershipEntityFactory.build({ + userGroupId: userGroupEntity.id, + roomId: room.id, + schoolId: school.id, + }); - await em.persistAndFlush([...Object.values(users), room, roomMemberships, teacherAccount, userGroupEntity]); + await em.persistAndFlush([ + ...Object.values(users), + room, + roomMemberships, + teacherAccount, + userGroupEntity, + ownerRole, + ]); em.clear(); const loggedInClient = await testApiClient.login(teacherAccount); @@ -131,9 +159,9 @@ describe('Room Controller (API)', () => { describe('when the user has the required permissions', () => { describe('when removing a user from the room', () => { it('should return OK', async () => { - const { loggedInClient, room, inRoomEditor2 } = await setupRoomWithMembers(); + const { loggedInClient, room, inRoomAdmin2 } = await setupRoomWithMembers(); - const userIds = [inRoomEditor2.id]; + const userIds = [inRoomAdmin2.id]; const response = await loggedInClient.patch(`/${room.id}/members/remove`, { userIds }); expect(response.status).toBe(HttpStatus.OK); @@ -142,9 +170,9 @@ describe('Room Controller (API)', () => { describe('when removing several users from the room', () => { it('should return OK', async () => { - const { loggedInClient, room, inRoomEditor2, inRoomEditor3 } = await setupRoomWithMembers(); + const { loggedInClient, room, inRoomAdmin2, inRoomAdmin3 } = await setupRoomWithMembers(); - const userIds = [inRoomEditor2.id, inRoomEditor3.id]; + const userIds = [inRoomAdmin2.id, inRoomAdmin3.id]; const response = await loggedInClient.patch(`/${room.id}/members/remove`, { userIds }); expect(response.status).toBe(HttpStatus.OK); diff --git a/apps/server/src/modules/room/api/test/room-update.api.spec.ts b/apps/server/src/modules/room/api/test/room-update.api.spec.ts index 782c23961d4..bc505ddc6aa 100644 --- a/apps/server/src/modules/room/api/test/room-update.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-update.api.spec.ts @@ -8,6 +8,7 @@ import { cleanupCollections, groupEntityFactory, roleFactory, + schoolEntityFactory, } from '@shared/testing'; import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing'; import { ServerTestModule, serverConfig, type ServerConfig } from '@modules/server'; @@ -94,19 +95,25 @@ describe('Room Controller (API)', () => { describe('when the user has the required permissions', () => { const setup = async () => { + const school = schoolEntityFactory.buildWithId(); const room = roomEntityFactory.build({ startDate: new Date('2024-10-01'), endDate: new Date('2024-10-20'), + schoolId: school.id, }); const role = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_EDIT], }); - const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); const userGroup = groupEntityFactory.buildWithId({ users: [{ role, user: teacherUser }], }); - const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + schoolId: school.id, + }); await em.persistAndFlush([room, roomMembership, teacherAccount, teacherUser, userGroup, role]); em.clear(); diff --git a/apps/server/src/modules/room/domain/type/room-color.enum.ts b/apps/server/src/modules/room/domain/type/room-color.enum.ts index 5ee92c572f2..f1ceaae9fb8 100644 --- a/apps/server/src/modules/room/domain/type/room-color.enum.ts +++ b/apps/server/src/modules/room/domain/type/room-color.enum.ts @@ -3,6 +3,7 @@ export enum RoomColor { PINK = 'pink', RED = 'red', ORANGE = 'orange', + YELLOW = 'yellow', OLIVE = 'olive', GREEN = 'green', TURQUOISE = 'turquoise', diff --git a/apps/server/src/modules/room/repo/entity/room.entity.ts b/apps/server/src/modules/room/repo/entity/room.entity.ts index 0539f7c469c..ebb0a66d26f 100644 --- a/apps/server/src/modules/room/repo/entity/room.entity.ts +++ b/apps/server/src/modules/room/repo/entity/room.entity.ts @@ -1,4 +1,4 @@ -import { Entity, Property } from '@mikro-orm/core'; +import { Entity, Index, Property } from '@mikro-orm/core'; import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; import { EntityId } from '@shared/domain/types'; import { ObjectIdType } from '@shared/repo/types/object-id.type'; @@ -13,6 +13,7 @@ export class RoomEntity extends BaseEntityWithTimestamps implements RoomProps { @Property({ nullable: false }) color!: RoomColor; + @Index() @Property({ type: ObjectIdType, fieldName: 'school', nullable: false }) schoolId!: EntityId; diff --git a/apps/server/src/modules/server/api/dto/config.response.ts b/apps/server/src/modules/server/api/dto/config.response.ts index 95ff7239dbe..558b50ea0c6 100644 --- a/apps/server/src/modules/server/api/dto/config.response.ts +++ b/apps/server/src/modules/server/api/dto/config.response.ts @@ -50,18 +50,6 @@ export class ConfigResponse { @ApiProperty() FEATURE_TLDRAW_ENABLED: boolean; - @ApiProperty() - TLDRAW__WEBSOCKET_URL: string; - - @ApiProperty() - TLDRAW__ASSETS_ENABLED: boolean; - - @ApiProperty() - TLDRAW__ASSETS_MAX_SIZE_BYTES: number; - - @ApiProperty() - TLDRAW__ASSETS_ALLOWED_MIME_TYPES_LIST: string[]; - @ApiProperty() ADMIN_TABLES_DISPLAY_CONSENT_COLUMN: boolean; @@ -125,6 +113,9 @@ export class ConfigResponse { @ApiProperty() FEATURE_COLUMN_BOARD_SOCKET_ENABLED: boolean; + @ApiProperty() + FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED: boolean; + @ApiProperty() FEATURE_COURSE_SHARE: boolean; @@ -261,6 +252,7 @@ export class ConfigResponse { this.FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED = config.FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED; this.FEATURE_COLUMN_BOARD_SHARE = config.FEATURE_COLUMN_BOARD_SHARE; this.FEATURE_COLUMN_BOARD_SOCKET_ENABLED = config.FEATURE_COLUMN_BOARD_SOCKET_ENABLED; + this.FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED = config.FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED; this.FEATURE_COURSE_SHARE = config.FEATURE_COURSE_SHARE; this.FEATURE_LOGIN_LINK_ENABLED = config.FEATURE_LOGIN_LINK_ENABLED; this.FEATURE_LESSON_SHARE = config.FEATURE_LESSON_SHARE; @@ -301,10 +293,6 @@ export class ConfigResponse { this.FEATURE_SHOW_MIGRATION_WIZARD = config.FEATURE_SHOW_MIGRATION_WIZARD; this.MIGRATION_WIZARD_DOCUMENTATION_LINK = config.MIGRATION_WIZARD_DOCUMENTATION_LINK; this.FEATURE_TLDRAW_ENABLED = config.FEATURE_TLDRAW_ENABLED; - this.TLDRAW__ASSETS_ENABLED = config.TLDRAW__ASSETS_ENABLED; - this.TLDRAW__WEBSOCKET_URL = config.TLDRAW__WEBSOCKET_URL; - this.TLDRAW__ASSETS_MAX_SIZE_BYTES = config.TLDRAW__ASSETS_MAX_SIZE_BYTES; - this.TLDRAW__ASSETS_ALLOWED_MIME_TYPES_LIST = config.TLDRAW__ASSETS_ALLOWED_MIME_TYPES_LIST; this.FEATURE_VIDEOCONFERENCE_ENABLED = config.FEATURE_VIDEOCONFERENCE_ENABLED; this.FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED = config.FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED; this.FEATURE_MEDIA_SHELF_ENABLED = config.FEATURE_MEDIA_SHELF_ENABLED; diff --git a/apps/server/src/modules/server/api/test/server.api.spec.ts b/apps/server/src/modules/server/api/test/server.api.spec.ts index 2fa8cada44a..1fc0a678700 100644 --- a/apps/server/src/modules/server/api/test/server.api.spec.ts +++ b/apps/server/src/modules/server/api/test/server.api.spec.ts @@ -49,6 +49,7 @@ describe('Server Controller (API)', () => { 'FEATURE_COLUMN_BOARD_COLLABORATIVE_TEXT_EDITOR_ENABLED', 'FEATURE_COLUMN_BOARD_SHARE', 'FEATURE_COLUMN_BOARD_SOCKET_ENABLED', + 'FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED', 'FEATURE_BOARD_LAYOUT_ENABLED', 'FEATURE_CONSENT_NECESSARY', 'FEATURE_COPY_SERVICE_ENABLED', @@ -95,10 +96,6 @@ describe('Server Controller (API)', () => { 'TEACHER_STUDENT_VISIBILITY__IS_CONFIGURABLE', 'TEACHER_STUDENT_VISIBILITY__IS_ENABLED_BY_DEFAULT', 'TEACHER_STUDENT_VISIBILITY__IS_VISIBLE', - 'TLDRAW__ASSETS_ALLOWED_MIME_TYPES_LIST', - 'TLDRAW__WEBSOCKET_URL', - 'TLDRAW__ASSETS_ENABLED', - 'TLDRAW__ASSETS_MAX_SIZE_BYTES', 'FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED', 'FEATURE_MEDIA_SHELF_ENABLED', 'BOARD_COLLABORATION_URI', diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index 3c8f9d89708..859e15a551f 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -21,7 +21,6 @@ import { RoomConfig } from '@modules/room'; import type { SchoolConfig } from '@modules/school'; import type { SharingConfig } from '@modules/sharing'; import type { ShdConfig } from '@modules/shd'; -import { getTldrawClientConfig, type TldrawClientConfig } from '@modules/tldraw-client'; import type { ToolConfig } from '@modules/tool'; import type { UserConfig } from '@modules/user'; import type { UserImportConfig } from '@modules/user-import'; @@ -56,7 +55,6 @@ export interface ServerConfig LearnroomConfig, AuthenticationConfig, ToolConfig, - TldrawClientConfig, UserLoginMigrationConfig, LessonConfig, BoardConfig, @@ -98,6 +96,7 @@ export interface ServerConfig FEATURE_COLUMN_BOARD_COLLABORATIVE_TEXT_EDITOR_ENABLED: boolean; FEATURE_COLUMN_BOARD_SHARE: boolean; FEATURE_COLUMN_BOARD_SOCKET_ENABLED: boolean; + FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED: boolean; FEATURE_BOARD_LAYOUT_ENABLED: boolean; FEATURE_CONSENT_NECESSARY: boolean; FEATURE_ALLOW_INSECURE_LDAP_URL_ENABLED: boolean; @@ -115,10 +114,6 @@ export interface ServerConfig FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED: boolean; FEATURE_SHOW_NEW_ROOMS_VIEW_ENABLED: boolean; FEATURE_TLDRAW_ENABLED: boolean; - TLDRAW__WEBSOCKET_URL: string; - TLDRAW__ASSETS_ENABLED: boolean; - TLDRAW__ASSETS_MAX_SIZE_BYTES: number; - TLDRAW__ASSETS_ALLOWED_MIME_TYPES_LIST: string[]; I18N__AVAILABLE_LANGUAGES: LanguageType[]; I18N__DEFAULT_LANGUAGE: LanguageType; I18N__FALLBACK_LANGUAGE: LanguageType; @@ -157,6 +152,9 @@ const config: ServerConfig = { ) as boolean, FEATURE_COLUMN_BOARD_SHARE: Configuration.get('FEATURE_COLUMN_BOARD_SHARE') as boolean, FEATURE_COLUMN_BOARD_SOCKET_ENABLED: Configuration.get('FEATURE_COLUMN_BOARD_SOCKET_ENABLED') as boolean, + FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED: Configuration.get( + 'FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED' + ) as boolean, FEATURE_COURSE_SHARE: Configuration.get('FEATURE_COURSE_SHARE') as boolean, FEATURE_LESSON_SHARE: Configuration.get('FEATURE_LESSON_SHARE') as boolean, FEATURE_TASK_SHARE: Configuration.get('FEATURE_TASK_SHARE') as boolean, @@ -218,12 +216,6 @@ const config: ServerConfig = { BLOCKLIST_OF_EMAIL_DOMAINS: (Configuration.get('BLOCKLIST_OF_EMAIL_DOMAINS') as string) .split(',') .map((domain) => domain.trim()), - TLDRAW__WEBSOCKET_URL: Configuration.get('TLDRAW__WEBSOCKET_URL') as string, - TLDRAW__ASSETS_ENABLED: Configuration.get('TLDRAW__ASSETS_ENABLED') as boolean, - TLDRAW__ASSETS_MAX_SIZE_BYTES: Configuration.get('TLDRAW__ASSETS_MAX_SIZE_BYTES') as number, - TLDRAW__ASSETS_ALLOWED_MIME_TYPES_LIST: (Configuration.get('TLDRAW__ASSETS_ALLOWED_MIME_TYPES_LIST') as string).split( - ',' - ), FEATURE_TLDRAW_ENABLED: Configuration.get('FEATURE_TLDRAW_ENABLED') as boolean, FEATURE_NEW_SCHOOL_ADMINISTRATION_PAGE_AS_DEFAULT_ENABLED: Configuration.get( 'FEATURE_NEW_SCHOOL_ADMINISTRATION_PAGE_AS_DEFAULT_ENABLED' @@ -267,14 +259,19 @@ const config: ServerConfig = { SCHULCONNEX_CLIENT__CLIENT_SECRET: Configuration.has('SCHULCONNEX_CLIENT__CLIENT_SECRET') ? (Configuration.get('SCHULCONNEX_CLIENT__CLIENT_SECRET') as string) : undefined, + SCHULCONNEX_CLIENT__PERSON_INFO_TIMEOUT_IN_MS: Configuration.get( + 'SCHULCONNEX_CLIENT__PERSON_INFO_TIMEOUT_IN_MS' + ) as number, SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS: Configuration.get( 'SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS' ) as number, SCHULCONNEX_CLIENT__POLICIES_INFO_TIMEOUT_IN_MS: Configuration.get( 'SCHULCONNEX_CLIENT__POLICIES_INFO_TIMEOUT_IN_MS' ) as number, + PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT: Configuration.has('PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT') + ? (Configuration.get('PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT') as number) + : undefined, FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED: Configuration.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED') as boolean, - ...getTldrawClientConfig(), FEATURE_MEDIA_SHELF_ENABLED: Configuration.get('FEATURE_MEDIA_SHELF_ENABLED') as boolean, FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED: Configuration.get( 'FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED' diff --git a/apps/server/src/modules/sharing/controller/api-test/sharing-import-room-board-token.api.spec.ts b/apps/server/src/modules/sharing/controller/api-test/sharing-import-room-board-token.api.spec.ts index 799a1bcca32..c63c27bf6d5 100644 --- a/apps/server/src/modules/sharing/controller/api-test/sharing-import-room-board-token.api.spec.ts +++ b/apps/server/src/modules/sharing/controller/api-test/sharing-import-room-board-token.api.spec.ts @@ -7,6 +7,7 @@ import { cleanupCollections, groupEntityFactory, roleFactory, + schoolEntityFactory, TestApiClient, UserAndAccountTestFactory, } from '@shared/testing'; @@ -48,17 +49,22 @@ describe('Sharing Controller (API)', () => { describe('POST /sharetoken/:token/import', () => { const setup = async () => { - const room = roomEntityFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); + const room = roomEntityFactory.buildWithId({ schoolId: school.id }); const role = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_EDIT], }); - const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); const userGroup = groupEntityFactory.buildWithId({ type: GroupEntityTypes.ROOM, users: [{ role, user: teacherUser }], }); - const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + schoolId: school.id, + }); const board = columnBoardEntityFactory.build({ context: { id: room.id, type: BoardExternalReferenceType.Room }, }); diff --git a/apps/server/src/modules/sharing/controller/share-token.controller.ts b/apps/server/src/modules/sharing/controller/share-token.controller.ts index d85937cbb62..04e9ec01b8c 100644 --- a/apps/server/src/modules/sharing/controller/share-token.controller.ts +++ b/apps/server/src/modules/sharing/controller/share-token.controller.ts @@ -35,7 +35,7 @@ export class ShareTokenController { @ApiResponse({ status: 403, type: ForbiddenException }) @ApiResponse({ status: 500, type: InternalServerErrorException }) @Post() - async createShareToken( + public async createShareToken( @CurrentUser() currentUser: ICurrentUser, @Body() body: ShareTokenBodyParams ): Promise { @@ -62,7 +62,7 @@ export class ShareTokenController { @ApiResponse({ status: 404, type: NotFoundException }) @ApiResponse({ status: 500, type: InternalServerErrorException }) @Get(':token') - async lookupShareToken( + public async lookupShareToken( @CurrentUser() currentUser: ICurrentUser, @Param() urlParams: ShareTokenUrlParams ): Promise { @@ -81,7 +81,7 @@ export class ShareTokenController { @ApiResponse({ status: 501, type: NotImplementedException }) @Post(':token/import') @RequestTimeout('INCOMING_REQUEST_TIMEOUT_COPY_API') - async importShareToken( + public async importShareToken( @CurrentUser() currentUser: ICurrentUser, @Param() urlParams: ShareTokenUrlParams, @Body() body: ShareTokenImportBodyParams diff --git a/apps/server/src/modules/sharing/uc/share-token.uc.ts b/apps/server/src/modules/sharing/uc/share-token.uc.ts index 9cd1a9f5c7e..a9498d0b2a1 100644 --- a/apps/server/src/modules/sharing/uc/share-token.uc.ts +++ b/apps/server/src/modules/sharing/uc/share-token.uc.ts @@ -52,7 +52,7 @@ export class ShareTokenUC { this.logger.setContext(ShareTokenUC.name); } - async createShareToken( + public async createShareToken( userId: EntityId, payload: ShareTokenPayload, options?: { schoolExclusive?: boolean; expiresInDays?: number } @@ -80,7 +80,7 @@ export class ShareTokenUC { return shareToken; } - async lookupShareToken(userId: EntityId, token: string): Promise { + public async lookupShareToken(userId: EntityId, token: string): Promise { this.logger.debug({ action: 'lookupShareToken', userId, token }); const { shareToken, parentName } = await this.shareTokenService.lookupTokenWithParentName(token); @@ -102,7 +102,7 @@ export class ShareTokenUC { return shareTokenInfo; } - async importShareToken( + public async importShareToken( userId: EntityId, token: string, newName: string, @@ -282,14 +282,14 @@ export class ShareTokenUC { ); } - private async checkSchoolReadPermission(user: User, schoolId: EntityId) { + private async checkSchoolReadPermission(user: User, schoolId: EntityId): Promise { const school = await this.schoolService.getSchoolById(schoolId); const authorizationContext = AuthorizationContextBuilder.read([]); this.authorizationService.checkPermission(user, school, authorizationContext); } - private async checkContextReadPermission(userId: EntityId, context: ShareTokenContext) { + private async checkContextReadPermission(userId: EntityId, context: ShareTokenContext): Promise { const user = await this.authorizationService.getUserWithPermissions(userId); if (context.contextType === ShareTokenContextType.School) { diff --git a/apps/server/src/modules/tldraw-client/index.ts b/apps/server/src/modules/tldraw-client/index.ts deleted file mode 100644 index 40cf6230c34..00000000000 --- a/apps/server/src/modules/tldraw-client/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { TldrawClientModule } from './tldraw-client.module'; -export { DrawingElementAdapterService } from './service/drawing-element-adapter.service'; -export { TldrawClientConfig } from './interface'; -export { getTldrawClientConfig } from './tldraw-client.config'; diff --git a/apps/server/src/modules/tldraw-client/interface/index.ts b/apps/server/src/modules/tldraw-client/interface/index.ts deleted file mode 100644 index 9dddff4ce51..00000000000 --- a/apps/server/src/modules/tldraw-client/interface/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './tldraw-client-config.interface'; diff --git a/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.spec.ts b/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.spec.ts deleted file mode 100644 index 634d6485bbb..00000000000 --- a/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { HttpService } from '@nestjs/axios'; -import { HttpStatus } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { Test, TestingModule } from '@nestjs/testing'; -import { axiosResponseFactory, setupEntities } from '@shared/testing'; -import { LegacyLogger } from '@src/core/logger'; -import { of } from 'rxjs'; -import { DrawingElementAdapterService } from './drawing-element-adapter.service'; - -describe(DrawingElementAdapterService.name, () => { - let module: TestingModule; - let service: DrawingElementAdapterService; - let httpService: DeepMocked; - let configService: DeepMocked; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - DrawingElementAdapterService, - { - provide: HttpService, - useValue: createMock(), - }, - { - provide: LegacyLogger, - useValue: createMock(), - }, - { - provide: ConfigService, - useValue: createMock(), - }, - ], - }).compile(); - - service = module.get(DrawingElementAdapterService); - httpService = module.get(HttpService); - configService = module.get(ConfigService); - - await setupEntities(); - }); - - afterAll(async () => { - await module.close(); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('deleteDrawingBinData', () => { - describe('when deleteDrawingBinData is called', () => { - const setup = () => { - const apiKey = 'a4a20e6a-8036-4603-aba6-378006fedce2'; - const baseUrl = 'http://localhost:3349'; - - configService.get.mockReturnValueOnce(baseUrl); - configService.get.mockReturnValueOnce(apiKey); - httpService.delete.mockReturnValue( - of( - axiosResponseFactory.build({ - data: '', - status: HttpStatus.OK, - statusText: 'OK', - }) - ) - ); - - return { apiKey, baseUrl }; - }; - - it('should call axios delete method', async () => { - const { apiKey, baseUrl } = setup(); - - await service.deleteDrawingBinData('test'); - - expect(httpService.delete).toHaveBeenCalledWith(`${baseUrl}/api/tldraw-document/test`, { - headers: { 'X-Api-Key': apiKey, Accept: 'Application/json' }, - }); - }); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.ts b/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.ts deleted file mode 100644 index baa16703a94..00000000000 --- a/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { HttpService } from '@nestjs/axios'; -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { LegacyLogger } from '@src/core/logger'; -import { firstValueFrom } from 'rxjs'; -import { TldrawClientConfig } from '../interface'; - -@Injectable() -export class DrawingElementAdapterService { - constructor( - private logger: LegacyLogger, - private readonly httpService: HttpService, - private readonly configService: ConfigService - ) { - this.logger.setContext(DrawingElementAdapterService.name); - } - - async deleteDrawingBinData(parentId: string): Promise { - const baseUrl = this.configService.get('TLDRAW_ADMIN_API_CLIENT_BASE_URL'); - const endpointUrl = '/api/tldraw-document'; - const tldrawDocumentEndpoint = new URL(endpointUrl, baseUrl).toString(); - - await firstValueFrom(this.httpService.delete(`${tldrawDocumentEndpoint}/${parentId}`, this.defaultHeaders())); - } - - private apiKeyHeader() { - const apiKey = this.configService.get('TLDRAW_ADMIN_API_CLIENT_API_KEY'); - - return { 'X-Api-Key': apiKey, Accept: 'Application/json' }; - } - - private defaultHeaders() { - return { - headers: this.apiKeyHeader(), - }; - } -} diff --git a/apps/server/src/modules/tldraw-client/tldraw-client.config.spec.ts b/apps/server/src/modules/tldraw-client/tldraw-client.config.spec.ts deleted file mode 100644 index 240a34ae7fc..00000000000 --- a/apps/server/src/modules/tldraw-client/tldraw-client.config.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { IConfig } from '@hpi-schul-cloud/commons/lib/interfaces/IConfig'; -import { getTldrawClientConfig } from './tldraw-client.config'; - -describe(getTldrawClientConfig.name, () => { - let configBefore: IConfig; - - beforeAll(() => { - configBefore = Configuration.toObject({ plainSecrets: true }); - }); - - afterEach(() => { - Configuration.reset(configBefore); - }); - - describe('when called', () => { - const setup = () => { - const baseUrl = 'http://tldraw-server-svc:3349'; - const apiKey = '7ccd4e11-c6f6-48b0-81eb-cccf7922e7a4'; - - Configuration.set('TLDRAW_ADMIN_API_CLIENT__BASE_URL', baseUrl); - Configuration.set('TLDRAW_ADMIN_API_CLIENT__API_KEY', apiKey); - - const expectedConfig = { - TLDRAW_ADMIN_API_CLIENT_BASE_URL: baseUrl, - TLDRAW_ADMIN_API_CLIENT_API_KEY: apiKey, - }; - - return { expectedConfig }; - }; - - it('should return config with proper values', () => { - const { expectedConfig } = setup(); - - const config = getTldrawClientConfig(); - - expect(config).toEqual(expectedConfig); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw-client/tldraw-client.config.ts b/apps/server/src/modules/tldraw-client/tldraw-client.config.ts deleted file mode 100644 index b778408b0c9..00000000000 --- a/apps/server/src/modules/tldraw-client/tldraw-client.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { TldrawClientConfig } from './interface'; - -export const getTldrawClientConfig = (): TldrawClientConfig => { - return { - TLDRAW_ADMIN_API_CLIENT_BASE_URL: Configuration.get('TLDRAW_ADMIN_API_CLIENT__BASE_URL') as string, - TLDRAW_ADMIN_API_CLIENT_API_KEY: Configuration.get('TLDRAW_ADMIN_API_CLIENT__API_KEY') as string, - }; -}; diff --git a/apps/server/src/modules/tldraw-client/tldraw-client.module.ts b/apps/server/src/modules/tldraw-client/tldraw-client.module.ts deleted file mode 100644 index 58035ea974e..00000000000 --- a/apps/server/src/modules/tldraw-client/tldraw-client.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { HttpModule } from '@nestjs/axios'; -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { LoggerModule } from '@src/core/logger'; -import { DrawingElementAdapterService } from './service/drawing-element-adapter.service'; -import { getTldrawClientConfig } from './tldraw-client.config'; - -@Module({ - imports: [LoggerModule, ConfigModule.forFeature(getTldrawClientConfig), HttpModule], - providers: [DrawingElementAdapterService], - exports: [DrawingElementAdapterService], -}) -export class TldrawClientModule {} diff --git a/apps/server/src/modules/tldraw/config.ts b/apps/server/src/modules/tldraw/config.ts deleted file mode 100644 index 947602d77c1..00000000000 --- a/apps/server/src/modules/tldraw/config.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Configuration } from '@hpi-schul-cloud/commons'; -import { XApiKeyAuthGuardConfig } from '@infra/auth-guard'; -import { env } from 'process'; - -export interface TldrawConfig extends XApiKeyAuthGuardConfig { - TLDRAW_DB_URL: string; - NEST_LOG_LEVEL: string; - INCOMING_REQUEST_TIMEOUT: number; - TLDRAW_DB_COMPRESS_THRESHOLD: number; - CONNECTION_STRING: string; - FEATURE_TLDRAW_ENABLED: boolean; - TLDRAW_PING_TIMEOUT: number; - TLDRAW_GC_ENABLED: boolean; - REDIS_URI: string | null; - TLDRAW_ASSETS_ENABLED: boolean; - TLDRAW_ASSETS_SYNC_ENABLED: boolean; - TLDRAW_ASSETS_MAX_SIZE_BYTES: number; - ASSETS_ALLOWED_MIME_TYPES_LIST: string; - API_HOST: string; - TLDRAW_MAX_DOCUMENT_SIZE: number; - TLDRAW_FINALIZE_DELAY: number; - PERFORMANCE_MEASURE_ENABLED: boolean; -} - -export const TLDRAW_DB_URL: string = Configuration.get('TLDRAW_DB_URL') as string; -export const TLDRAW_SOCKET_PORT = Configuration.get('TLDRAW__SOCKET_PORT') as number; - -export const S3_CONNECTION_NAME = 'tldraw-s3'; -// we need to check if the endpoint is production or not -const s3Endpoint = env.S3_ENDPOINT || ''; -const endpoint = env.NODE_ENV === 'production' ? `https://${s3Endpoint}` : s3Endpoint; -// There are temporary configurations for S3 it should read directly from env -export const tldrawS3Config = { - connectionName: S3_CONNECTION_NAME, - endpoint, - region: env.S3_REGION as string, - bucket: env.S3_BUCKET as string, - accessKeyId: env.S3_ACCESS_KEY as string, - secretAccessKey: env.S3_SECRET_KEY as string, -}; - -const apiKeyAuthGuardConfig: XApiKeyAuthGuardConfig = { - ADMIN_API__ALLOWED_API_KEYS: Configuration.get('ADMIN_API__ALLOWED_API_KEYS') as string[], -}; - -const tldrawConfig: TldrawConfig = { - TLDRAW_DB_URL, - NEST_LOG_LEVEL: Configuration.get('TLDRAW__LOG_LEVEL') as string, - INCOMING_REQUEST_TIMEOUT: Configuration.get('INCOMING_REQUEST_TIMEOUT_API') as number, - TLDRAW_DB_COMPRESS_THRESHOLD: Configuration.get('TLDRAW__DB_COMPRESS_THRESHOLD') as number, - FEATURE_TLDRAW_ENABLED: Configuration.get('FEATURE_TLDRAW_ENABLED') as boolean, - CONNECTION_STRING: Configuration.get('TLDRAW_DB_URL') as string, - TLDRAW_PING_TIMEOUT: Configuration.get('TLDRAW__PING_TIMEOUT') as number, - TLDRAW_GC_ENABLED: Configuration.get('TLDRAW__GC_ENABLED') as boolean, - REDIS_URI: Configuration.has('REDIS_URI') ? (Configuration.get('REDIS_URI') as string) : null, - TLDRAW_ASSETS_ENABLED: Configuration.get('TLDRAW__ASSETS_ENABLED') as boolean, - TLDRAW_ASSETS_SYNC_ENABLED: Configuration.get('TLDRAW__ASSETS_SYNC_ENABLED') as boolean, - TLDRAW_ASSETS_MAX_SIZE_BYTES: Configuration.get('TLDRAW__ASSETS_MAX_SIZE_BYTES') as number, - ASSETS_ALLOWED_MIME_TYPES_LIST: Configuration.get('TLDRAW__ASSETS_ALLOWED_MIME_TYPES_LIST') as string, - API_HOST: Configuration.get('API_HOST') as string, - TLDRAW_MAX_DOCUMENT_SIZE: Configuration.get('TLDRAW__MAX_DOCUMENT_SIZE') as number, - TLDRAW_FINALIZE_DELAY: Configuration.get('TLDRAW__FINALIZE_DELAY') as number, - PERFORMANCE_MEASURE_ENABLED: Configuration.get('TLDRAW__PERFORMANCE_MEASURE_ENABLED') as boolean, - ...apiKeyAuthGuardConfig, -}; - -export const config = () => tldrawConfig; diff --git a/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.401.api.spec.ts b/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.401.api.spec.ts deleted file mode 100644 index 09d5d81cbb4..00000000000 --- a/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.401.api.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { EntityManager } from '@mikro-orm/mongodb'; -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { TestApiClient } from '@shared/testing'; -import { tldrawEntityFactory } from '../../testing'; -import { TldrawApiTestModule } from '../../tldraw-api-test.module'; - -const baseRouteName = '/tldraw-document'; -describe('tldraw controller (api)', () => { - let app: INestApplication; - let em: EntityManager; - let testApiClient: TestApiClient; - const API_KEY = '7ccd4e11-c6f6-48b0-81eb-cccf7922e7a4'; - - beforeAll(async () => { - const module: TestingModule = await Test.createTestingModule({ - imports: [TldrawApiTestModule.forRoot()], - }).compile(); - - app = module.createNestApplication(); - await app.init(); - em = module.get(EntityManager); - testApiClient = new TestApiClient(app, baseRouteName, API_KEY, true); - }); - - afterAll(async () => { - await app.close(); - }); - - describe('when request does not contain token', () => { - const setup = async () => { - const drawingItemData = tldrawEntityFactory.build(); - - await em.persistAndFlush([drawingItemData]); - em.clear(); - - return { drawingItemData }; - }; - - it('should return status 401 for delete', async () => { - const { drawingItemData } = await setup(); - - const response = await testApiClient.delete(`${drawingItemData.docName}`); - - expect(response.status).toEqual(401); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.api.spec.ts b/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.api.spec.ts deleted file mode 100644 index 9e52ae2c970..00000000000 --- a/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.api.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { XApiKeyGuard } from '@infra/auth-guard'; -import { EntityManager } from '@mikro-orm/mongodb'; -import { ExecutionContext, INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { TestApiClient } from '@shared/testing'; -import { Request } from 'express'; -import { tldrawEntityFactory } from '../../testing'; -import { TldrawApiTestModule } from '../../tldraw-api-test.module'; - -const baseRouteName = '/tldraw-document'; -describe('tldraw controller (api)', () => { - let app: INestApplication; - let em: EntityManager; - let testApiClient: TestApiClient; - const API_KEY = '7ccd4e11-c6f6-48b0-81eb-cccf7922e7a4'; - - beforeAll(async () => { - const module: TestingModule = await Test.createTestingModule({ - imports: [TldrawApiTestModule.forRoot()], - }) - .overrideGuard(XApiKeyGuard) - .useValue({ - canActivate(context: ExecutionContext) { - const req: Request = context.switchToHttp().getRequest(); - req.headers['X-API-KEY'] = API_KEY; - return true; - }, - }) - .compile(); - - app = module.createNestApplication(); - await app.init(); - em = module.get(EntityManager); - testApiClient = new TestApiClient(app, baseRouteName, API_KEY, true); - }); - - afterAll(async () => { - await app.close(); - }); - - describe('with valid user', () => { - const setup = async () => { - const drawingItemData = tldrawEntityFactory.build(); - - await em.persistAndFlush([drawingItemData]); - em.clear(); - - return { drawingItemData }; - }; - - it('should return status 200 for delete', async () => { - const { drawingItemData } = await setup(); - - const response = await testApiClient.delete(`${drawingItemData.docName}`); - - expect(response.status).toEqual(204); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/controller/api-test/tldraw.ws.api.spec.ts b/apps/server/src/modules/tldraw/controller/api-test/tldraw.ws.api.spec.ts deleted file mode 100644 index 2b3e57918c6..00000000000 --- a/apps/server/src/modules/tldraw/controller/api-test/tldraw.ws.api.spec.ts +++ /dev/null @@ -1,389 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { MongoMemoryDatabaseModule } from '@infra/database'; -import { HttpService } from '@nestjs/axios'; -import { INestApplication, NotAcceptableException } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { WsAdapter } from '@nestjs/platform-ws'; -import { Test } from '@nestjs/testing'; -import { axiosResponseFactory } from '@shared/testing'; -import { createConfigModuleOptions } from '@src/config'; -import { CoreModule } from '@src/core'; -import { AxiosError, AxiosHeaders, AxiosResponse } from 'axios'; -import { of, throwError } from 'rxjs'; -import { TextEncoder } from 'util'; -import WebSocket from 'ws'; -import { TldrawWs } from '..'; -import { TldrawConfig } from '../../config'; -import { TldrawDrawing } from '../../entities'; -import { MetricsService } from '../../metrics'; -import { TldrawRedisFactory, TldrawRedisService } from '../../redis'; -import { TldrawBoardRepo, TldrawRepo, YMongodb } from '../../repo'; -import { TldrawWsService } from '../../service'; -import { TestConnection, tldrawTestConfig } from '../../testing'; -import { WsCloseCode, WsCloseMessage } from '../../types'; - -// This is a unit test, no api test...need to be refactored -describe('WebSocketController (WsAdapter)', () => { - let app: INestApplication; - let gateway: TldrawWs; - let ws: WebSocket; - let wsService: TldrawWsService; - let httpService: DeepMocked; - let configService: ConfigService; - - const gatewayPort = 3346; - const wsUrl = TestConnection.getWsUrl(gatewayPort); - const clientMessageMock = 'test-message'; - - const getMessage = () => new TextEncoder().encode(clientMessageMock); - - beforeAll(async () => { - const testingModule = await Test.createTestingModule({ - imports: [ - MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] }), - ConfigModule.forRoot(createConfigModuleOptions(tldrawTestConfig)), - CoreModule, - ], - providers: [ - TldrawWs, - TldrawWsService, - TldrawBoardRepo, - YMongodb, - MetricsService, - TldrawRedisFactory, - TldrawRedisService, - { - provide: TldrawRepo, - useValue: createMock(), - }, - { - provide: HttpService, - useValue: createMock(), - }, - ], - }).compile(); - - gateway = testingModule.get(TldrawWs); - wsService = testingModule.get(TldrawWsService); - httpService = testingModule.get(HttpService); - configService = testingModule.get(ConfigService); - app = testingModule.createNestApplication(); - app.useWebSocketAdapter(new WsAdapter(app)); - await app.init(); - }); - - afterAll(async () => { - await app.close(); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - describe('when tldraw connection is established', () => { - const setup = async () => { - const handleConnectionSpy = jest.spyOn(gateway, 'handleConnection'); - jest.spyOn(Uint8Array.prototype, 'reduce').mockReturnValueOnce(1); - - ws = await TestConnection.setupWs(wsUrl, 'TEST'); - const { buffer } = getMessage(); - - return { handleConnectionSpy, buffer }; - }; - - it(`should handle connection`, async () => { - const { handleConnectionSpy, buffer } = await setup(); - - ws.send(buffer, () => {}); - - expect(handleConnectionSpy).toHaveBeenCalledTimes(1); - handleConnectionSpy.mockRestore(); - ws.close(); - }); - - it(`check if client will receive message`, async () => { - const { handleConnectionSpy, buffer } = await setup(); - ws.send(buffer, () => {}); - - gateway.server.on('connection', (client) => { - client.on('message', (payload) => { - expect(payload).toBeInstanceOf(ArrayBuffer); - }); - }); - - handleConnectionSpy.mockRestore(); - ws.close(); - }); - }); - - describe('when tldraw doc has multiple clients', () => { - const setup = async () => { - const handleConnectionSpy = jest.spyOn(gateway, 'handleConnection'); - ws = await TestConnection.setupWs(wsUrl, 'TEST'); - const ws2 = await TestConnection.setupWs(wsUrl, 'TEST'); - - const { buffer } = getMessage(); - - return { - handleConnectionSpy, - ws2, - buffer, - }; - }; - - it(`should handle 2 connections at same doc and data transfer`, async () => { - const { handleConnectionSpy, ws2, buffer } = await setup(); - - ws.send(buffer); - ws2.send(buffer); - - expect(handleConnectionSpy).toHaveBeenCalledTimes(2); - - handleConnectionSpy.mockRestore(); - ws.close(); - ws2.close(); - }); - }); - - describe('when checking cookie', () => { - const setup = () => { - const httpGetCallSpy = jest.spyOn(httpService, 'get'); - const wsCloseSpy = jest.spyOn(WebSocket.prototype, 'close'); - - return { - httpGetCallSpy, - wsCloseSpy, - }; - }; - - it(`should refuse connection if there is no jwt in cookie`, async () => { - const { httpGetCallSpy, wsCloseSpy } = setup(); - - ws = await TestConnection.setupWs(wsUrl, 'TEST', {}); - - expect(wsCloseSpy).toHaveBeenCalledWith(WsCloseCode.UNAUTHORIZED, Buffer.from(WsCloseMessage.UNAUTHORIZED)); - - httpGetCallSpy.mockRestore(); - wsCloseSpy.mockRestore(); - ws.close(); - }); - - it(`should refuse connection if jwt is wrong`, async () => { - const { wsCloseSpy, httpGetCallSpy } = setup(); - const error = new AxiosError('unknown error', '401', undefined, undefined, { - config: { headers: new AxiosHeaders() }, - data: undefined, - headers: {}, - statusText: '401', - status: 401, - }); - - httpGetCallSpy.mockReturnValueOnce(throwError(() => error)); - ws = await TestConnection.setupWs(wsUrl, 'TEST', { cookie: 'jwt=jwt-mocked' }); - - expect(wsCloseSpy).toHaveBeenCalledWith(WsCloseCode.UNAUTHORIZED, Buffer.from(WsCloseMessage.UNAUTHORIZED)); - - httpGetCallSpy.mockRestore(); - wsCloseSpy.mockRestore(); - ws.close(); - }); - }); - - describe('when tldraw feature is disabled', () => { - const setup = () => { - const wsCloseSpy = jest.spyOn(WebSocket.prototype, 'close'); - const configSpy = jest.spyOn(configService, 'get').mockReturnValueOnce(false); - - return { - wsCloseSpy, - configSpy, - }; - }; - - it('should close', async () => { - const { wsCloseSpy } = setup(); - - ws = await TestConnection.setupWs(wsUrl, 'test-doc'); - - expect(wsCloseSpy).toHaveBeenCalledWith(WsCloseCode.BAD_REQUEST, Buffer.from(WsCloseMessage.FEATURE_DISABLED)); - - wsCloseSpy.mockRestore(); - ws.close(); - }); - }); - - describe('when checking docName and cookie', () => { - const setup = () => { - const setupConnectionSpy = jest.spyOn(wsService, 'setupWsConnection'); - const wsCloseSpy = jest.spyOn(WebSocket.prototype, 'close'); - const closeConnSpy = jest.spyOn(wsService, 'closeConnection').mockRejectedValue(new Error('error')); - - return { - setupConnectionSpy, - wsCloseSpy, - closeConnSpy, - }; - }; - - it(`should close for existing cookie and not existing docName`, async () => { - const { setupConnectionSpy, wsCloseSpy } = setup(); - const { buffer } = getMessage(); - - ws = await TestConnection.setupWs(wsUrl, '', { cookie: 'jwt=jwt-mocked' }); - ws.send(buffer); - - expect(wsCloseSpy).toHaveBeenCalledWith(WsCloseCode.BAD_REQUEST, Buffer.from(WsCloseMessage.BAD_REQUEST)); - - wsCloseSpy.mockRestore(); - setupConnectionSpy.mockRestore(); - ws.close(); - }); - - it(`should close for not existing docName resource`, async () => { - const { setupConnectionSpy, wsCloseSpy } = setup(); - - const httpGetCallSpy = jest.spyOn(httpService, 'get'); - const error = new AxiosError('unknown error', '404', undefined, undefined, { - config: { headers: new AxiosHeaders() }, - data: undefined, - headers: {}, - statusText: '404', - status: 404, - }); - httpGetCallSpy.mockReturnValueOnce(throwError(() => error)); - - ws = await TestConnection.setupWs(wsUrl, 'GLOBAL', { cookie: 'jwt=jwt-mocked' }); - - expect(wsCloseSpy).toHaveBeenCalledWith(WsCloseCode.NOT_FOUND, Buffer.from(WsCloseMessage.NOT_FOUND)); - - wsCloseSpy.mockRestore(); - setupConnectionSpy.mockRestore(); - ws.close(); - }); - - it(`should close for not authorized connection`, async () => { - const { setupConnectionSpy, wsCloseSpy } = setup(); - const { buffer } = getMessage(); - - const httpGetCallSpy = jest.spyOn(httpService, 'get'); - const error = new AxiosError('unknown error', '401', undefined, undefined, { - config: { headers: new AxiosHeaders() }, - data: undefined, - headers: {}, - statusText: '401', - status: 401, - }); - httpGetCallSpy.mockReturnValueOnce(throwError(() => error)); - - ws = await TestConnection.setupWs(wsUrl, 'TEST', { cookie: 'jwt=jwt-mocked' }); - ws.send(buffer); - - expect(wsCloseSpy).toHaveBeenCalledWith(WsCloseCode.UNAUTHORIZED, Buffer.from(WsCloseMessage.UNAUTHORIZED)); - - wsCloseSpy.mockRestore(); - setupConnectionSpy.mockRestore(); - httpGetCallSpy.mockRestore(); - ws.close(); - }); - - it(`should close on unexpected error code`, async () => { - const { setupConnectionSpy, wsCloseSpy } = setup(); - const { buffer } = getMessage(); - - const httpGetCallSpy = jest.spyOn(httpService, 'get'); - const error = new AxiosError('unknown error', '418', undefined, undefined, { - config: { headers: new AxiosHeaders() }, - data: undefined, - headers: {}, - statusText: '418', - status: 418, - }); - httpGetCallSpy.mockReturnValueOnce(throwError(() => error)); - - ws = await TestConnection.setupWs(wsUrl, 'TEST', { cookie: 'jwt=jwt-mocked' }); - ws.send(buffer); - - expect(wsCloseSpy).toHaveBeenCalledWith( - WsCloseCode.INTERNAL_SERVER_ERROR, - Buffer.from(WsCloseMessage.INTERNAL_SERVER_ERROR) - ); - - wsCloseSpy.mockRestore(); - setupConnectionSpy.mockRestore(); - httpGetCallSpy.mockRestore(); - ws.close(); - }); - - it(`should setup connection for proper data`, async () => { - const { setupConnectionSpy, wsCloseSpy } = setup(); - const { buffer } = getMessage(); - - const httpGetCallSpy = jest.spyOn(httpService, 'get'); - const axiosResponse: AxiosResponse = axiosResponseFactory.build({ - data: '', - }); - - httpGetCallSpy.mockImplementationOnce(() => of(axiosResponse)); - - ws = await TestConnection.setupWs(wsUrl, 'TEST', { cookie: 'jwt=jwt-mocked' }); - ws.send(buffer); - - expect(setupConnectionSpy).toHaveBeenCalledWith(expect.anything(), 'TEST'); - - wsCloseSpy.mockRestore(); - setupConnectionSpy.mockRestore(); - ws.close(); - }); - - it(`should close after throw at setup connection`, async () => { - const { setupConnectionSpy, wsCloseSpy } = setup(); - const { buffer } = getMessage(); - - const httpGetCallSpy = jest.spyOn(httpService, 'get'); - const axiosResponse: AxiosResponse = axiosResponseFactory.build({ - data: '', - }); - httpGetCallSpy.mockReturnValueOnce(of(axiosResponse)); - setupConnectionSpy.mockImplementationOnce(() => { - throw new Error('unknown error'); - }); - - ws = await TestConnection.setupWs(wsUrl, 'TEST', { cookie: 'jwt=jwt-mocked' }); - ws.send(buffer); - - expect(setupConnectionSpy).toHaveBeenCalledWith(expect.anything(), 'TEST'); - expect(wsCloseSpy).toHaveBeenCalledWith( - WsCloseCode.INTERNAL_SERVER_ERROR, - Buffer.from(WsCloseMessage.INTERNAL_SERVER_ERROR) - ); - - wsCloseSpy.mockRestore(); - setupConnectionSpy.mockRestore(); - ws.close(); - }); - - it('should close after setup connection throws NotAcceptableException', async () => { - const { setupConnectionSpy, wsCloseSpy } = setup(); - const { buffer } = getMessage(); - - const httpGetCallSpy = jest.spyOn(httpService, 'get'); - const axiosResponse: AxiosResponse = axiosResponseFactory.build({ - data: '', - }); - httpGetCallSpy.mockReturnValueOnce(of(axiosResponse)); - setupConnectionSpy.mockImplementationOnce(() => { - throw new NotAcceptableException(); - }); - - ws = await TestConnection.setupWs(wsUrl, 'TEST', { cookie: 'jwt=jwt-mocked' }); - ws.send(buffer); - - expect(setupConnectionSpy).toHaveBeenCalledWith(expect.anything(), 'TEST'); - expect(wsCloseSpy).toHaveBeenCalledWith(WsCloseCode.NOT_ACCEPTABLE, Buffer.from(WsCloseMessage.NOT_ACCEPTABLE)); - - wsCloseSpy.mockRestore(); - setupConnectionSpy.mockRestore(); - ws.close(); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/controller/index.ts b/apps/server/src/modules/tldraw/controller/index.ts deleted file mode 100644 index 38a96a42a75..00000000000 --- a/apps/server/src/modules/tldraw/controller/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './tldraw.ws'; -export * from './tldraw.controller'; diff --git a/apps/server/src/modules/tldraw/controller/tldraw.controller.ts b/apps/server/src/modules/tldraw/controller/tldraw.controller.ts deleted file mode 100644 index a7f5ed2bbc5..00000000000 --- a/apps/server/src/modules/tldraw/controller/tldraw.controller.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { XApiKeyAuthentication } from '@infra/auth-guard'; -import { Controller, Delete, ForbiddenException, HttpCode, NotFoundException, Param } from '@nestjs/common'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { ApiValidationError } from '@shared/common'; -import { TldrawService } from '../service'; -import { TldrawDeleteParams } from './tldraw.params'; - -@ApiTags('Tldraw Document') -@XApiKeyAuthentication() -@Controller('tldraw-document') -export class TldrawController { - constructor(private readonly tldrawService: TldrawService) {} - - @ApiOperation({ summary: 'Delete every element of tldraw drawing by its docName.' }) - @ApiResponse({ status: 204 }) - @ApiResponse({ status: 400, type: ApiValidationError }) - @ApiResponse({ status: 403, type: ForbiddenException }) - @ApiResponse({ status: 404, type: NotFoundException }) - @HttpCode(204) - @Delete(':docName') - async deleteByDocName(@Param() urlParams: TldrawDeleteParams) { - await this.tldrawService.deleteByDocName(urlParams.docName); - } -} diff --git a/apps/server/src/modules/tldraw/controller/tldraw.params.ts b/apps/server/src/modules/tldraw/controller/tldraw.params.ts deleted file mode 100644 index 860b46332bf..00000000000 --- a/apps/server/src/modules/tldraw/controller/tldraw.params.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsString } from 'class-validator'; - -export class TldrawDeleteParams { - @IsString() - @ApiProperty({ - description: 'The name of drawing that should be deleted.', - required: true, - nullable: false, - }) - docName!: string; -} diff --git a/apps/server/src/modules/tldraw/controller/tldraw.ws.ts b/apps/server/src/modules/tldraw/controller/tldraw.ws.ts deleted file mode 100644 index 0c050ee9677..00000000000 --- a/apps/server/src/modules/tldraw/controller/tldraw.ws.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { WebSocketGateway, WebSocketServer, OnGatewayInit, OnGatewayConnection } from '@nestjs/websockets'; -import WebSocket, { Server } from 'ws'; -import { Request } from 'express'; -import { ConfigService } from '@nestjs/config'; -import cookie from 'cookie'; -import { - InternalServerErrorException, - UnauthorizedException, - NotFoundException, - NotAcceptableException, -} from '@nestjs/common'; -import { isAxiosError } from 'axios'; -import { firstValueFrom } from 'rxjs'; -import { HttpService } from '@nestjs/axios'; -import { DomainErrorHandler } from '@src/core'; -import { WebsocketInitErrorLoggable } from '../loggable'; -import { TldrawConfig, TLDRAW_SOCKET_PORT } from '../config'; -import { WsCloseCode, WsCloseMessage } from '../types'; -import { TldrawWsService } from '../service'; - -@WebSocketGateway(TLDRAW_SOCKET_PORT) -export class TldrawWs implements OnGatewayInit, OnGatewayConnection { - @WebSocketServer() - server!: Server; - - constructor( - private readonly configService: ConfigService, - private readonly tldrawWsService: TldrawWsService, - private readonly httpService: HttpService, - private readonly domainErrorHandler: DomainErrorHandler - ) {} - - public async handleConnection(client: WebSocket, request: Request): Promise { - if (!this.configService.get('FEATURE_TLDRAW_ENABLED')) { - client.close(WsCloseCode.BAD_REQUEST, WsCloseMessage.FEATURE_DISABLED); - return; - } - - const docName = this.getDocNameFromRequest(request); - if (!docName) { - client.close(WsCloseCode.BAD_REQUEST, WsCloseMessage.BAD_REQUEST); - return; - } - - try { - const cookies = this.parseCookiesFromHeader(request); - await this.authorizeConnection(docName, cookies?.jwt); - await this.tldrawWsService.setupWsConnection(client, docName); - } catch (err) { - this.handleError(err, client, docName); - } - } - - public async afterInit(): Promise { - await this.tldrawWsService.createDbIndex(); - } - - private getDocNameFromRequest(request: Request): string { - const urlStripped = request.url.replace(/(\/)|(tldraw-server)/g, ''); - return urlStripped; - } - - private parseCookiesFromHeader(request: Request): { [p: string]: string } { - const parsedCookies: { [p: string]: string } = cookie.parse(request.headers.cookie || ''); - return parsedCookies; - } - - private async authorizeConnection(drawingName: string, token: string): Promise { - if (!token) { - throw new UnauthorizedException('Token was not given'); - } - - try { - const apiHostUrl = this.configService.get('API_HOST'); - await firstValueFrom( - this.httpService.get(`${apiHostUrl}/v3/elements/${drawingName}/permission`, { - headers: { - Accept: 'Application/json', - Authorization: `Bearer ${token}`, - }, - }) - ); - } catch (err) { - if (isAxiosError(err)) { - switch (err.response?.status) { - case 400: - case 404: - throw new NotFoundException(); - case 401: - case 403: - throw new UnauthorizedException(); - default: - throw new InternalServerErrorException(); - } - } - - throw new InternalServerErrorException(); - } - } - - private closeClientAndLog( - client: WebSocket, - code: WsCloseCode, - message: WsCloseMessage, - docName: string, - err?: unknown - ): void { - client.close(code, message); - this.domainErrorHandler.exec(new WebsocketInitErrorLoggable(code, message, docName, err)); - } - - private handleError(err: unknown, client: WebSocket, docName: string): void { - if (err instanceof NotFoundException) { - this.closeClientAndLog(client, WsCloseCode.NOT_FOUND, WsCloseMessage.NOT_FOUND, docName); - return; - } - - if (err instanceof UnauthorizedException) { - this.closeClientAndLog(client, WsCloseCode.UNAUTHORIZED, WsCloseMessage.UNAUTHORIZED, docName); - return; - } - - if (err instanceof NotAcceptableException) { - this.closeClientAndLog(client, WsCloseCode.NOT_ACCEPTABLE, WsCloseMessage.NOT_ACCEPTABLE, docName); - return; - } - - this.closeClientAndLog( - client, - WsCloseCode.INTERNAL_SERVER_ERROR, - WsCloseMessage.INTERNAL_SERVER_ERROR, - docName, - err - ); - } -} diff --git a/apps/server/src/modules/tldraw/domain/index.ts b/apps/server/src/modules/tldraw/domain/index.ts deleted file mode 100644 index 6e30b3fa99e..00000000000 --- a/apps/server/src/modules/tldraw/domain/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ws-shared-doc.do'; diff --git a/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.spec.ts b/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.spec.ts deleted file mode 100644 index 791e4108f8e..00000000000 --- a/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { WsSharedDocDo } from './ws-shared-doc.do'; - -describe('WsSharedDocDo', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - - describe('constructor', () => { - describe('when constructor is called', () => { - it('should create a new object with correct properties', () => { - const doc = new WsSharedDocDo('docname'); - - expect(doc).toBeInstanceOf(WsSharedDocDo); - expect(doc.name).toEqual('docname'); - expect(doc.awarenessChannel).toEqual('docname-awareness'); - expect(doc.awareness).toBeDefined(); - }); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.ts b/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.ts deleted file mode 100644 index 2ceec1962c2..00000000000 --- a/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.ts +++ /dev/null @@ -1,24 +0,0 @@ -import WebSocket from 'ws'; -import { Doc } from 'yjs'; -import { Awareness } from 'y-protocols/awareness'; - -export class WsSharedDocDo extends Doc { - public name: string; - - public connections: Map>; - - public awareness: Awareness; - - public awarenessChannel: string; - - public isFinalizing = false; - - constructor(name: string, gcEnabled = true) { - super({ gc: gcEnabled }); - this.name = name; - this.connections = new Map(); - this.awareness = new Awareness(this); - this.awareness.setLocalState(null); - this.awarenessChannel = `${name}-awareness`; - } -} diff --git a/apps/server/src/modules/tldraw/entities/index.ts b/apps/server/src/modules/tldraw/entities/index.ts deleted file mode 100644 index 2e9bb23bb67..00000000000 --- a/apps/server/src/modules/tldraw/entities/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './tldraw-drawing.entity'; diff --git a/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.spec.ts b/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.spec.ts deleted file mode 100644 index 2698056a0ef..00000000000 --- a/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { setupEntities } from '@shared/testing'; -import { tldrawEntityFactory } from '../testing'; -import { TldrawDrawing } from './tldraw-drawing.entity'; - -describe('tldraw entity', () => { - beforeAll(async () => { - await setupEntities(); - }); - - describe('constructor', () => { - describe('when creating a tldraw doc', () => { - it('should create drawing', () => { - const tldraw = tldrawEntityFactory.build(); - - expect(tldraw).toBeInstanceOf(TldrawDrawing); - }); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.ts b/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.ts deleted file mode 100644 index daaa93090e5..00000000000 --- a/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Entity, Index, Property } from '@mikro-orm/core'; -import { BaseEntity } from '@shared/domain/entity/base.entity'; - -export interface TldrawDrawingProps { - id?: string; - docName: string; - version: string; - clock?: number; - action?: string; - value: Buffer; - part?: number; -} - -@Entity({ tableName: 'drawings' }) -@Index({ properties: ['version', 'docName', 'action', 'clock', 'part'] }) -export class TldrawDrawing extends BaseEntity { - @Property({ nullable: false }) - docName: string; - - @Property({ nullable: false }) - version: string; - - @Property({ nullable: false }) - value: Buffer; - - @Property({ nullable: true }) - clock?: number; - - @Property({ nullable: true }) - action?: string; - - @Property({ nullable: true }) - part?: number; - - constructor(props: TldrawDrawingProps) { - super(); - this.docName = props.docName; - this.version = props.version; - this.value = props.value; - this.clock = props.clock; - this.action = props.action; - this.part = props.part; - } -} diff --git a/apps/server/src/modules/tldraw/job/index.ts b/apps/server/src/modules/tldraw/job/index.ts deleted file mode 100644 index 64931538b48..00000000000 --- a/apps/server/src/modules/tldraw/job/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './tldraw-files.console'; -export * from './tldraw-migration.console'; diff --git a/apps/server/src/modules/tldraw/job/tldraw-files.console.spec.ts b/apps/server/src/modules/tldraw/job/tldraw-files.console.spec.ts deleted file mode 100644 index e4379a7e1c2..00000000000 --- a/apps/server/src/modules/tldraw/job/tldraw-files.console.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { createMock } from '@golevelup/ts-jest'; -import { Test, TestingModule } from '@nestjs/testing'; -import { LegacyLogger } from '@src/core/logger'; -import { TldrawDeleteFilesUc } from '../uc'; -import { TldrawFilesConsole } from './tldraw-files.console'; - -describe('TldrawFilesConsole', () => { - let console: TldrawFilesConsole; - let deleteFilesUc: TldrawDeleteFilesUc; - - beforeAll(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - TldrawFilesConsole, - { - provide: TldrawDeleteFilesUc, - useValue: createMock(), - }, - { - provide: LegacyLogger, - useValue: createMock(), - }, - ], - }).compile(); - - console = module.get(TldrawFilesConsole); - deleteFilesUc = module.get(TldrawDeleteFilesUc); - - // Set fake system time. Otherwise, dates constructed in the test and the - // console can differ because of the short time elapsing between the calls. - jest.useFakeTimers(); - jest.setSystemTime(new Date(2022, 1, 22)); - }); - - it('should be defined', () => { - expect(console).toBeDefined(); - }); - - describe('deleteUnusedFiles', () => { - it('should call UC with threshold date', async () => { - const minimumFileAgeInHours = 1; - const thresholdDate = new Date(); - thresholdDate.setHours(thresholdDate.getHours() - minimumFileAgeInHours); - - await console.deleteUnusedFiles(minimumFileAgeInHours); - - expect(deleteFilesUc.deleteUnusedFiles).toHaveBeenCalledWith(thresholdDate); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/job/tldraw-files.console.ts b/apps/server/src/modules/tldraw/job/tldraw-files.console.ts deleted file mode 100644 index e200efe21f3..00000000000 --- a/apps/server/src/modules/tldraw/job/tldraw-files.console.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Command, Console } from 'nestjs-console'; -import { LegacyLogger } from '@src/core/logger'; -import { TldrawDeleteFilesUc } from '../uc'; - -@Console({ command: 'files', description: 'tldraw file deletion console' }) -export class TldrawFilesConsole { - constructor(private deleteFilesUc: TldrawDeleteFilesUc, private logger: LegacyLogger) { - this.logger.setContext(TldrawFilesConsole.name); - } - - @Command({ - command: 'deletion-job ', - description: - 'tldraw file deletion job to delete files no longer used in board - only files older than hours will be marked for deletion', - }) - async deleteUnusedFiles(minimumFileAgeInHours: number): Promise { - this.logger.log( - `Start tldraw file deletion job: marking files for deletion that are no longer used in whiteboard but only older than ${minimumFileAgeInHours} hours to prevent deletion of files that may still be used in an open whiteboard` - ); - const thresholdDate = new Date(); - thresholdDate.setHours(thresholdDate.getHours() - minimumFileAgeInHours); - - await this.deleteFilesUc.deleteUnusedFiles(thresholdDate); - this.logger.log('deletion job finished'); - } -} diff --git a/apps/server/src/modules/tldraw/job/tldraw-migration.console.spec.ts b/apps/server/src/modules/tldraw/job/tldraw-migration.console.spec.ts deleted file mode 100644 index 71e86cf7059..00000000000 --- a/apps/server/src/modules/tldraw/job/tldraw-migration.console.spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { S3ClientAdapter } from '@infra/s3-client'; -import { Test, TestingModule } from '@nestjs/testing'; -import { LegacyLogger } from '@src/core/logger'; -import { S3_CONNECTION_NAME } from '../config'; -import { WsSharedDocDo } from '../domain'; -import { YMongodb } from '../repo'; -import { TldrawMigrationConsole } from './tldraw-migration.console'; - -jest.mock('yjs', () => { - return { - Doc: jest.fn(), - encodeStateAsUpdateV2: jest.fn().mockReturnValue('encodedState'), - }; -}); - -describe(TldrawMigrationConsole.name, () => { - let console: TldrawMigrationConsole; - let yMongodb: DeepMocked; - let s3Adapter: DeepMocked; - - beforeAll(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - TldrawMigrationConsole, - { - provide: S3_CONNECTION_NAME, - useValue: createMock(), - }, - { - provide: YMongodb, - useValue: createMock(), - }, - { - provide: LegacyLogger, - useValue: createMock(), - }, - ], - }).compile(); - - console = module.get(TldrawMigrationConsole); - s3Adapter = module.get(S3_CONNECTION_NAME); - yMongodb = module.get(YMongodb); - }); - - it('should be defined', () => { - expect(console).toBeDefined(); - }); - - describe('migrate', () => { - it('should migrate all documents', async () => { - const docNames = ['doc1', 'doc2']; - const doc1 = { - name: 'doc1', - store: { pendingStructs: 'pendingStructs', pendingDs: 'pendingDs' }, - } as unknown as WsSharedDocDo; - const doc2 = { - name: 'doc2', - store: { pendingStructs: 'pendingStructs', pendingDs: 'pendingDs' }, - } as unknown as WsSharedDocDo; - yMongodb.getAllDocumentNames.mockResolvedValue(docNames); - yMongodb.getDocument.mockImplementation((docName: string) => { - if (docName === 'doc1') { - return Promise.resolve(doc1); - } - if (docName === 'doc2') { - return Promise.resolve(doc2); - } - throw new Error('Invalid docName'); - }); - s3Adapter.create.mockImplementation((key) => Promise.resolve({ Key: key } as any)); - - const result = await console.migrate(1); - - expect(result).toEqual(['doc1/index/doc1', 'doc2/index/doc2']); - expect(yMongodb.getAllDocumentNames).toBeCalledTimes(1); - expect(yMongodb.getDocument).toBeCalledTimes(2); - expect(s3Adapter.create).toBeCalledTimes(2); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/job/tldraw-migration.console.ts b/apps/server/src/modules/tldraw/job/tldraw-migration.console.ts deleted file mode 100644 index 58df75de722..00000000000 --- a/apps/server/src/modules/tldraw/job/tldraw-migration.console.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { S3ClientAdapter } from '@infra/s3-client'; -import { Inject } from '@nestjs/common'; -import { LegacyLogger } from '@src/core/logger'; -import { Command, Console } from 'nestjs-console'; -import { Readable } from 'stream'; -import { Doc, encodeStateAsUpdateV2 } from 'yjs'; -import { S3_CONNECTION_NAME } from '../config'; -import { YMongodb } from '../repo'; - -export const encodeS3ObjectName = (docName: string) => - `${encodeURIComponent(docName)}/index/${encodeURIComponent(docName)}`; - -@Console({ command: 'migration', description: 'tldraw migrate from mongodb to s3' }) -export class TldrawMigrationConsole { - constructor( - private readonly tldrawBoardRepo: YMongodb, - private logger: LegacyLogger, - @Inject(S3_CONNECTION_NAME) private readonly storageClient: S3ClientAdapter - ) { - this.logger.setContext(TldrawMigrationConsole.name); - } - - @Command({ - command: 'run [chunks]', - description: 'execute migrate', - }) - async migrate(chunks = 100): Promise { - const affectedDocs: Array = []; - - this.logger.log(`Start tldraw migration form mongodb to s3`); - const docNames = await this.tldrawBoardRepo.getAllDocumentNames(); - - const docNameChunks = this.chunk(docNames, chunks); - for await (const docNameChunk of docNameChunks) { - const promises = docNameChunk.map(async (docName) => { - const result = await this.tldrawBoardRepo.getDocument(docName); - - const { name, connections, awareness, awarenessChannel, isFinalizing, ...doc } = result; - - if (result.store.pendingStructs) { - this.logger.log(`Remove pendingStructs from ${docName}`); - result.store.pendingStructs = null; - result.store.pendingDs = null; - } - - const file = { - data: Readable.from(Buffer.from(encodeStateAsUpdateV2(doc as Doc))), - mimeType: 'binary/octet-stream', - }; - - const res = await this.storageClient.create(encodeS3ObjectName(docName), file); - if ('Key' in res) { - affectedDocs.push(res.Key as string); - } - this.logger.log(res); - }); - - await Promise.all(promises); - } - - this.logger.log(`Found ${docNames.length} tldraw docs in mongodb`); - this.logger.log(`migration job finished with ${affectedDocs.length} affected docs`); - - return affectedDocs; - } - - private chunk(array: string[], size: number): string[][] { - const r = Array(Math.ceil(array.length / size)).fill(null); - return r.map((e, i) => array.slice(i * size, i * size + size)); - } -} diff --git a/apps/server/src/modules/tldraw/loggable/close-connection.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/close-connection.loggable.spec.ts deleted file mode 100644 index df2fbec507f..00000000000 --- a/apps/server/src/modules/tldraw/loggable/close-connection.loggable.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { CloseConnectionLoggable } from './close-connection.loggable'; - -describe('CloseConnectionLoggable', () => { - describe('getLogMessage', () => { - const setup = () => { - const error = new Error('test'); - const loggable = new CloseConnectionLoggable('functionName', error); - - return { loggable, error }; - }; - - it('should return a loggable message', () => { - const { loggable, error } = setup(); - - const message = loggable.getLogMessage(); - - expect(message).toEqual({ - message: 'Close web socket error in functionName', - type: 'CLOSE_WEB_SOCKET_ERROR', - error, - }); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/loggable/close-connection.loggable.ts b/apps/server/src/modules/tldraw/loggable/close-connection.loggable.ts deleted file mode 100644 index e1d2c90e0bd..00000000000 --- a/apps/server/src/modules/tldraw/loggable/close-connection.loggable.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; - -export class CloseConnectionLoggable implements Loggable { - private error: Error | undefined; - - constructor(private readonly errorLocation: string, private readonly err: unknown) { - if (err instanceof Error) { - this.error = err; - } - } - - getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { - return { - message: `Close web socket error in ${this.errorLocation}`, - type: `CLOSE_WEB_SOCKET_ERROR`, - error: this.error, - }; - } -} diff --git a/apps/server/src/modules/tldraw/loggable/file-storage-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/file-storage-error.loggable.spec.ts deleted file mode 100644 index 817edd10f5e..00000000000 --- a/apps/server/src/modules/tldraw/loggable/file-storage-error.loggable.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { FileStorageErrorLoggable } from './file-storage-error.loggable'; - -describe('FileStorageErrorLoggable', () => { - describe('getLogMessage', () => { - const setup = () => { - const error = new Error('test'); - const loggable = new FileStorageErrorLoggable('doc1', error); - - return { loggable, error }; - }; - - it('should return a loggable message', () => { - const { loggable, error } = setup(); - - const message = loggable.getLogMessage(); - - expect(message).toEqual({ - message: 'Error in document doc1: assets could not be synchronized with file storage.', - type: 'FILE_STORAGE_GENERAL_ERROR', - error, - }); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/loggable/file-storage-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/file-storage-error.loggable.ts deleted file mode 100644 index 3654b608a17..00000000000 --- a/apps/server/src/modules/tldraw/loggable/file-storage-error.loggable.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; - -export class FileStorageErrorLoggable implements Loggable { - private error: Error | undefined; - - constructor(private readonly docName: string, private readonly err: unknown) { - if (err instanceof Error) { - this.error = err; - } - } - - getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { - return { - message: `Error in document ${this.docName}: assets could not be synchronized with file storage.`, - type: `FILE_STORAGE_GENERAL_ERROR`, - error: this.error, - }; - } -} diff --git a/apps/server/src/modules/tldraw/loggable/index.ts b/apps/server/src/modules/tldraw/loggable/index.ts deleted file mode 100644 index 00bfbc2fa7b..00000000000 --- a/apps/server/src/modules/tldraw/loggable/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from './mongo-transaction-error.loggable'; -export * from './redis-error.loggable'; -export * from './redis-publish-error.loggable'; -export * from './websocket-error.loggable'; -export * from './websocket-init-error.loggable'; -export * from './websocket-message-error.loggable'; -export * from './ws-shared-doc-error.loggable'; -export * from './close-connection.loggable'; -export * from './file-storage-error.loggable'; diff --git a/apps/server/src/modules/tldraw/loggable/mongo-transaction-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/mongo-transaction-error.loggable.spec.ts deleted file mode 100644 index e109ece222f..00000000000 --- a/apps/server/src/modules/tldraw/loggable/mongo-transaction-error.loggable.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { MongoTransactionErrorLoggable } from './mongo-transaction-error.loggable'; - -describe('MongoTransactionErrorLoggable', () => { - describe('getLogMessage', () => { - const setup = () => { - const error = new Error('test'); - const loggable = new MongoTransactionErrorLoggable(error); - - return { loggable, error }; - }; - - it('should return a loggable message', () => { - const { loggable, error } = setup(); - - const message = loggable.getLogMessage(); - - expect(message).toEqual({ - message: 'Error while saving transaction', - type: 'MONGO_TRANSACTION_ERROR', - error, - }); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/loggable/mongo-transaction-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/mongo-transaction-error.loggable.ts deleted file mode 100644 index 15153388f3c..00000000000 --- a/apps/server/src/modules/tldraw/loggable/mongo-transaction-error.loggable.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; - -export class MongoTransactionErrorLoggable implements Loggable { - private error: Error | undefined; - - constructor(private readonly err: unknown) { - if (err instanceof Error) { - this.error = err; - } - } - - getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { - return { - message: `Error while saving transaction`, - type: `MONGO_TRANSACTION_ERROR`, - error: this.error, - }; - } -} diff --git a/apps/server/src/modules/tldraw/loggable/redis-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/redis-error.loggable.spec.ts deleted file mode 100644 index 1208015c2a8..00000000000 --- a/apps/server/src/modules/tldraw/loggable/redis-error.loggable.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { RedisErrorLoggable } from './redis-error.loggable'; - -describe('RedisGeneralErrorLoggable', () => { - describe('getLogMessage', () => { - const setup = () => { - const type = 'SUB'; - const error = new Error('test'); - const loggable = new RedisErrorLoggable(type, error); - - return { loggable, error }; - }; - - it('should return a loggable message', () => { - const { loggable, error } = setup(); - - const message = loggable.getLogMessage(); - - expect(message).toEqual({ - message: 'Redis SUB error', - type: 'REDIS_SUB_ERROR', - error, - }); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/loggable/redis-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/redis-error.loggable.ts deleted file mode 100644 index 3ef9e3bbcfe..00000000000 --- a/apps/server/src/modules/tldraw/loggable/redis-error.loggable.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; - -export class RedisErrorLoggable implements Loggable { - private error: Error | undefined; - - constructor(private readonly connectionType: 'PUB' | 'SUB', private readonly err: unknown) { - if (err instanceof Error) { - this.error = err; - } - } - - getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { - return { - message: `Redis ${this.connectionType} error`, - type: `REDIS_${this.connectionType}_ERROR`, - error: this.error, - }; - } -} diff --git a/apps/server/src/modules/tldraw/loggable/redis-publish-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/redis-publish-error.loggable.spec.ts deleted file mode 100644 index 915b1596dd5..00000000000 --- a/apps/server/src/modules/tldraw/loggable/redis-publish-error.loggable.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { RedisPublishErrorLoggable } from './redis-publish-error.loggable'; -import { UpdateType } from '../types'; - -describe('RedisPublishErrorLoggable', () => { - describe('getLogMessage', () => { - const setup = () => { - const type = UpdateType.DOCUMENT; - const error = new Error('test'); - const loggable = new RedisPublishErrorLoggable(type, error); - - return { loggable, error }; - }; - - it('should return a loggable message', () => { - const { loggable, error } = setup(); - - const message = loggable.getLogMessage(); - - expect(message).toEqual({ - message: 'Error while publishing document state to Redis', - type: 'REDIS_PUBLISH_ERROR', - error, - }); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/loggable/redis-publish-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/redis-publish-error.loggable.ts deleted file mode 100644 index 2e3d6b1559e..00000000000 --- a/apps/server/src/modules/tldraw/loggable/redis-publish-error.loggable.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; -import { UpdateType } from '../types'; - -export class RedisPublishErrorLoggable implements Loggable { - private error: Error | undefined; - - constructor(private readonly type: UpdateType, private readonly err: unknown) { - if (err instanceof Error) { - this.error = err; - } - } - - getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { - return { - message: `Error while publishing ${this.type} state to Redis`, - type: `REDIS_PUBLISH_ERROR`, - error: this.error, - }; - } -} diff --git a/apps/server/src/modules/tldraw/loggable/websocket-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/websocket-error.loggable.spec.ts deleted file mode 100644 index 4e129376cc3..00000000000 --- a/apps/server/src/modules/tldraw/loggable/websocket-error.loggable.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { WebsocketErrorLoggable } from './websocket-error.loggable'; - -describe('WebsocketErrorLoggable', () => { - describe('getLogMessage', () => { - const setup = () => { - const error = new Error('test'); - - const loggable = new WebsocketErrorLoggable(error); - return { loggable, error }; - }; - - it('should return a loggable message', () => { - const { loggable, error } = setup(); - - const message = loggable.getLogMessage(); - - expect(message).toEqual({ message: 'Websocket error event', error, type: 'WEBSOCKET_ERROR' }); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/loggable/websocket-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/websocket-error.loggable.ts deleted file mode 100644 index 1da725b3518..00000000000 --- a/apps/server/src/modules/tldraw/loggable/websocket-error.loggable.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; - -export class WebsocketErrorLoggable implements Loggable { - private error: Error | undefined; - - constructor(private readonly err: unknown) { - if (err instanceof Error) { - this.error = err; - } - } - - getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { - return { - message: 'Websocket error event', - type: 'WEBSOCKET_ERROR', - error: this.error, - }; - } -} diff --git a/apps/server/src/modules/tldraw/loggable/websocket-init-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/websocket-init-error.loggable.spec.ts deleted file mode 100644 index faada42a29d..00000000000 --- a/apps/server/src/modules/tldraw/loggable/websocket-init-error.loggable.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { WebsocketInitErrorLoggable } from './websocket-init-error.loggable'; -import { WsCloseCode, WsCloseMessage } from '../types'; - -describe('WebsocketInitErrorLoggable', () => { - describe('getLogMessage', () => { - const setup = () => { - const error = new Error('test'); - const errorCode = WsCloseCode.BAD_REQUEST; - const errorMessage = WsCloseMessage.BAD_REQUEST; - const docName = 'test'; - - const loggable = new WebsocketInitErrorLoggable(errorCode, errorMessage, docName, error); - return { loggable, error, errorCode, errorMessage, docName }; - }; - - it('should return a loggable message', () => { - const { loggable, error, errorMessage, errorCode, docName } = setup(); - - const message = loggable.getLogMessage(); - - expect(message).toEqual({ - message: `[${docName}] [${errorCode}] ${errorMessage}`, - type: 'WEBSOCKET_CONNECTION_INIT_ERROR', - error, - }); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/loggable/websocket-init-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/websocket-init-error.loggable.ts deleted file mode 100644 index d82760290b8..00000000000 --- a/apps/server/src/modules/tldraw/loggable/websocket-init-error.loggable.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; -import { WsCloseCode, WsCloseMessage } from '../types'; - -export class WebsocketInitErrorLoggable implements Loggable { - private readonly error: Error | undefined; - - constructor( - private readonly code: WsCloseCode, - private readonly message: WsCloseMessage, - private readonly docName: string, - private readonly err?: unknown - ) { - if (err instanceof Error) { - this.error = err; - } - } - - getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { - return { - message: `[${this.docName}] [${this.code}] ${this.message}`, - type: 'WEBSOCKET_CONNECTION_INIT_ERROR', - error: this.error, - }; - } -} diff --git a/apps/server/src/modules/tldraw/loggable/websocket-message-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/websocket-message-error.loggable.spec.ts deleted file mode 100644 index 272efcf618b..00000000000 --- a/apps/server/src/modules/tldraw/loggable/websocket-message-error.loggable.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { WebsocketMessageErrorLoggable } from './websocket-message-error.loggable'; - -describe('WebsocketMessageErrorLoggable', () => { - describe('getLogMessage', () => { - const setup = () => { - const error = new Error('test'); - const loggable = new WebsocketMessageErrorLoggable(error); - - return { loggable, error }; - }; - - it('should return a loggable message', () => { - const { loggable, error } = setup(); - - const message = loggable.getLogMessage(); - - expect(message).toEqual({ - message: 'Error while handling websocket message', - type: 'WEBSOCKET_MESSAGE_ERROR', - error, - }); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/loggable/websocket-message-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/websocket-message-error.loggable.ts deleted file mode 100644 index 0309c5aa21b..00000000000 --- a/apps/server/src/modules/tldraw/loggable/websocket-message-error.loggable.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; - -export class WebsocketMessageErrorLoggable implements Loggable { - private error: Error | undefined; - - constructor(private readonly err: unknown) { - if (err instanceof Error) { - this.error = err; - } - } - - getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { - return { - message: `Error while handling websocket message`, - type: `WEBSOCKET_MESSAGE_ERROR`, - error: this.error, - }; - } -} diff --git a/apps/server/src/modules/tldraw/loggable/ws-shared-doc-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/ws-shared-doc-error.loggable.spec.ts deleted file mode 100644 index d18fcff8e9a..00000000000 --- a/apps/server/src/modules/tldraw/loggable/ws-shared-doc-error.loggable.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { WsSharedDocErrorLoggable } from './ws-shared-doc-error.loggable'; - -describe('WsSharedDocErrorLoggable', () => { - describe('getLogMessage', () => { - const setup = () => { - const docName = 'docname'; - const message = 'error message'; - const error = new Error('test'); - const loggable = new WsSharedDocErrorLoggable(docName, message, error); - - return { loggable, error }; - }; - - it('should return a loggable message', () => { - const { loggable, error } = setup(); - - const message = loggable.getLogMessage(); - - expect(message).toEqual({ - message: 'Error in document docname: error message', - type: 'WSSHAREDDOC_ERROR', - error, - }); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/loggable/ws-shared-doc-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/ws-shared-doc-error.loggable.ts deleted file mode 100644 index 4ddd8102ed0..00000000000 --- a/apps/server/src/modules/tldraw/loggable/ws-shared-doc-error.loggable.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; - -export class WsSharedDocErrorLoggable implements Loggable { - private error: Error | undefined; - - constructor(private readonly docName: string, private readonly message: string, private readonly err: unknown) { - if (err instanceof Error) { - this.error = err; - } - } - - getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { - return { - message: `Error in document ${this.docName}: ${this.message}`, - type: `WSSHAREDDOC_ERROR`, - error: this.error, - }; - } -} diff --git a/apps/server/src/modules/tldraw/metrics/index.ts b/apps/server/src/modules/tldraw/metrics/index.ts deleted file mode 100644 index 70337867b90..00000000000 --- a/apps/server/src/modules/tldraw/metrics/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './metrics.service'; diff --git a/apps/server/src/modules/tldraw/metrics/metrics.service.ts b/apps/server/src/modules/tldraw/metrics/metrics.service.ts deleted file mode 100644 index ace2899c36b..00000000000 --- a/apps/server/src/modules/tldraw/metrics/metrics.service.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { Gauge, register } from 'prom-client'; - -@Injectable() -export class MetricsService { - private numberOfUsersOnServerCounter: Gauge; - - private numberOfBoardsOnServerCounter: Gauge; - - constructor() { - this.numberOfUsersOnServerCounter = new Gauge({ - name: 'sc_tldraw_users', - help: 'Number of active users per pod', - }); - - this.numberOfBoardsOnServerCounter = new Gauge({ - name: 'sc_tldraw_boards', - help: 'Number of active boards per pod', - }); - - register.registerMetric(this.numberOfUsersOnServerCounter); - register.registerMetric(this.numberOfBoardsOnServerCounter); - } - - public incrementNumberOfUsersOnServerCounter(): void { - this.numberOfUsersOnServerCounter.inc(); - } - - public decrementNumberOfUsersOnServerCounter(): void { - this.numberOfUsersOnServerCounter.dec(); - } - - public incrementNumberOfBoardsOnServerCounter(): void { - this.numberOfBoardsOnServerCounter.inc(); - } - - public decrementNumberOfBoardsOnServerCounter(): void { - this.numberOfBoardsOnServerCounter.dec(); - } -} diff --git a/apps/server/src/modules/tldraw/redis/index.ts b/apps/server/src/modules/tldraw/redis/index.ts deleted file mode 100644 index 8b4354dcc39..00000000000 --- a/apps/server/src/modules/tldraw/redis/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './tldraw-redis.factory'; -export * from './tldraw-redis.service'; diff --git a/apps/server/src/modules/tldraw/redis/tldraw-redis.factory.spec.ts b/apps/server/src/modules/tldraw/redis/tldraw-redis.factory.spec.ts deleted file mode 100644 index 7353e44b11a..00000000000 --- a/apps/server/src/modules/tldraw/redis/tldraw-redis.factory.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { Test } from '@nestjs/testing'; -import { createConfigModuleOptions } from '@src/config'; -import { createMock } from '@golevelup/ts-jest'; -import { DomainErrorHandler } from '@src/core'; -import { RedisConnectionTypeEnum } from '../types'; -import { TldrawConfig } from '../config'; -import { tldrawTestConfig } from '../testing'; -import { TldrawRedisFactory } from './tldraw-redis.factory'; - -describe('TldrawRedisFactory', () => { - let configService: ConfigService; - let redisFactory: TldrawRedisFactory; - - beforeAll(async () => { - const testingModule = await Test.createTestingModule({ - imports: [ConfigModule.forRoot(createConfigModuleOptions(tldrawTestConfig))], - providers: [ - TldrawRedisFactory, - { - provide: DomainErrorHandler, - useValue: createMock(), - }, - ], - }).compile(); - - configService = testingModule.get(ConfigService); - redisFactory = testingModule.get(TldrawRedisFactory); - }); - - it('should check if factory was created', () => { - expect(redisFactory).toBeDefined(); - }); - - describe('build', () => { - it('should throw if REDIS_URI is not set', () => { - const configSpy = jest.spyOn(configService, 'get').mockReturnValueOnce(null); - - expect(() => redisFactory.build(RedisConnectionTypeEnum.PUBLISH)).toThrow('REDIS_URI is not set'); - configSpy.mockRestore(); - }); - - it('should return redis connection', () => { - const configSpy = jest.spyOn(configService, 'get').mockReturnValueOnce('redis://localhost:6379'); - const redis = redisFactory.build(RedisConnectionTypeEnum.PUBLISH); - - expect(redis).toBeDefined(); - configSpy.mockRestore(); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/redis/tldraw-redis.factory.ts b/apps/server/src/modules/tldraw/redis/tldraw-redis.factory.ts deleted file mode 100644 index e84f9e040b1..00000000000 --- a/apps/server/src/modules/tldraw/redis/tldraw-redis.factory.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Redis } from 'ioredis'; -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { DomainErrorHandler } from '@src/core'; -import { TldrawConfig } from '../config'; -import { RedisErrorLoggable } from '../loggable'; -import { RedisConnectionTypeEnum } from '../types'; - -@Injectable() -export class TldrawRedisFactory { - constructor( - private readonly configService: ConfigService, - private readonly domainErrorHandler: DomainErrorHandler - ) {} - - public build(connectionType: RedisConnectionTypeEnum) { - const redisUri = this.configService.get('REDIS_URI'); - if (!redisUri) { - throw new Error('REDIS_URI is not set'); - } - - const redis = new Redis(redisUri, { - maxRetriesPerRequest: null, - }); - - redis.on('error', (err) => this.domainErrorHandler.exec(new RedisErrorLoggable(connectionType, err))); - - return redis; - } -} diff --git a/apps/server/src/modules/tldraw/redis/tldraw-redis.service.spec.ts b/apps/server/src/modules/tldraw/redis/tldraw-redis.service.spec.ts deleted file mode 100644 index 11473385f3c..00000000000 --- a/apps/server/src/modules/tldraw/redis/tldraw-redis.service.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { createMock } from '@golevelup/ts-jest'; -import { Test } from '@nestjs/testing'; -import { ConfigModule } from '@nestjs/config'; -import { createConfigModuleOptions } from '@src/config'; -import * as Yjs from 'yjs'; -import * as AwarenessProtocol from 'y-protocols/awareness'; -import { DomainErrorHandler } from '@src/core'; -import { WsSharedDocDo } from '../domain'; -import { TldrawRedisFactory, TldrawRedisService } from '.'; -import { tldrawTestConfig } from '../testing'; - -jest.mock('yjs', () => { - const moduleMock: unknown = { - __esModule: true, - ...jest.requireActual('yjs'), - }; - return moduleMock; -}); -jest.mock('y-protocols/awareness', () => { - const moduleMock: unknown = { - __esModule: true, - ...jest.requireActual('y-protocols/awareness'), - }; - return moduleMock; -}); - -describe('TldrawRedisService', () => { - let service: TldrawRedisService; - - beforeAll(async () => { - const testingModule = await Test.createTestingModule({ - imports: [ConfigModule.forRoot(createConfigModuleOptions(tldrawTestConfig))], - providers: [ - TldrawRedisFactory, - TldrawRedisService, - { - provide: DomainErrorHandler, - useValue: createMock(), - }, - ], - }).compile(); - - service = testingModule.get(TldrawRedisService); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('redisMessageHandler', () => { - const setup = () => { - const applyUpdateSpy = jest.spyOn(Yjs, 'applyUpdate').mockReturnValueOnce(); - const applyAwarenessUpdateSpy = jest.spyOn(AwarenessProtocol, 'applyAwarenessUpdate').mockReturnValueOnce(); - - const doc = new WsSharedDocDo('TEST'); - doc.awarenessChannel = 'TEST-awareness'; - - return { - doc, - applyUpdateSpy, - applyAwarenessUpdateSpy, - }; - }; - - describe('when channel name is the same as docName', () => { - it('should call applyUpdate', () => { - const { doc, applyUpdateSpy } = setup(); - service.handleMessage('TEST', Buffer.from('message'), doc); - - expect(applyUpdateSpy).toHaveBeenCalled(); - }); - }); - - describe('when channel name is the same as docAwarenessChannel name', () => { - it('should call applyAwarenessUpdate', () => { - const { doc, applyAwarenessUpdateSpy } = setup(); - service.handleMessage('TEST-awareness', Buffer.from('message'), doc); - - expect(applyAwarenessUpdateSpy).toHaveBeenCalled(); - }); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/redis/tldraw-redis.service.ts b/apps/server/src/modules/tldraw/redis/tldraw-redis.service.ts deleted file mode 100644 index 77a14243524..00000000000 --- a/apps/server/src/modules/tldraw/redis/tldraw-redis.service.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { Redis } from 'ioredis'; -import { Buffer } from 'node:buffer'; -import { applyAwarenessUpdate } from 'y-protocols/awareness'; -import { applyUpdate } from 'yjs'; -import { DomainErrorHandler } from '@src/core'; -import { WsSharedDocDo } from '../domain'; -import { RedisConnectionTypeEnum, UpdateOrigin, UpdateType } from '../types'; -import { RedisPublishErrorLoggable, WsSharedDocErrorLoggable } from '../loggable'; -import { TldrawRedisFactory } from './tldraw-redis.factory'; - -@Injectable() -export class TldrawRedisService { - public readonly sub: Redis; - - private readonly pub: Redis; - - constructor( - private readonly domainErrorHandler: DomainErrorHandler, - private readonly tldrawRedisFactory: TldrawRedisFactory - ) { - this.sub = this.tldrawRedisFactory.build(RedisConnectionTypeEnum.SUBSCRIBE); - this.pub = this.tldrawRedisFactory.build(RedisConnectionTypeEnum.PUBLISH); - } - - public handleMessage = (channelId: string, update: Buffer, doc: WsSharedDocDo): void => { - if (channelId.includes(UpdateType.AWARENESS)) { - applyAwarenessUpdate(doc.awareness, update, UpdateOrigin.REDIS); - } else { - applyUpdate(doc, update, UpdateOrigin.REDIS); - } - }; - - public subscribeToRedisChannels(doc: WsSharedDocDo) { - this.sub.subscribe(doc.name, doc.awarenessChannel).catch((err) => { - this.domainErrorHandler.exec( - new WsSharedDocErrorLoggable(doc.name, 'Error while subscribing to Redis channels', err) - ); - }); - } - - public unsubscribeFromRedisChannels(doc: WsSharedDocDo) { - this.sub.unsubscribe(doc.name, doc.awarenessChannel).catch((err) => { - this.domainErrorHandler.exec( - new WsSharedDocErrorLoggable(doc.name, 'Error while unsubscribing from Redis channels', err) - ); - }); - } - - public publishUpdateToRedis(doc: WsSharedDocDo, update: Uint8Array, type: UpdateType) { - const channel = type === UpdateType.AWARENESS ? doc.awarenessChannel : doc.name; - this.pub.publish(channel, Buffer.from(update)).catch((err) => { - this.domainErrorHandler.exec(new RedisPublishErrorLoggable(type, err)); - }); - } -} diff --git a/apps/server/src/modules/tldraw/repo/index.ts b/apps/server/src/modules/tldraw/repo/index.ts deleted file mode 100644 index 0552c6c0191..00000000000 --- a/apps/server/src/modules/tldraw/repo/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './tldraw-board.repo'; -export * from './tldraw.repo'; -export * from './y-mongodb'; diff --git a/apps/server/src/modules/tldraw/repo/key.factory.spec.ts b/apps/server/src/modules/tldraw/repo/key.factory.spec.ts deleted file mode 100644 index f33fac4a3f8..00000000000 --- a/apps/server/src/modules/tldraw/repo/key.factory.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { ObjectId } from '@mikro-orm/mongodb'; -import { KeyFactory } from './key.factory'; - -describe('KeyFactory', () => { - describe('createForUpdate', () => { - describe('when clock is not passed', () => { - const setup = () => { - const params = { docName: new ObjectId().toHexString() }; - - return { params }; - }; - - it('should return a object that support the interface UniqueKey and clock is not defined', () => { - const { params } = setup(); - - const result = KeyFactory.createForUpdate(params.docName); - - expect(result).toEqual({ - docName: params.docName, - version: 'v1', - action: 'update', - clock: undefined, - }); - }); - }); - - describe('when positive clock number is passed', () => { - const setup = () => { - const params = { docName: new ObjectId().toHexString(), clock: 2 }; - - return { params }; - }; - - it('should return a object that support the interface UniqueKey and pass the clock number', () => { - const { params } = setup(); - - const result = KeyFactory.createForUpdate(params.docName, params.clock); - - expect(result).toEqual({ - docName: params.docName, - version: 'v1', - action: 'update', - clock: params.clock, - }); - }); - }); - - describe('when clock number -1 is passed', () => { - const setup = () => { - const params = { docName: new ObjectId().toHexString(), clock: -1 }; - - return { params }; - }; - - it('should return a object that support the interface UniqueKey and pass the clock number', () => { - const { params } = setup(); - - const result = KeyFactory.createForUpdate(params.docName, params.clock); - - expect(result).toEqual({ - docName: params.docName, - version: 'v1', - action: 'update', - clock: params.clock, - }); - }); - }); - - describe('when clock lower then -1 is passed', () => { - const setup = () => { - const params = { docName: new ObjectId().toHexString(), clock: -2 }; - - return { params }; - }; - - it('should throw an invalid clock number error', () => { - const { params } = setup(); - - expect(() => KeyFactory.createForUpdate(params.docName, params.clock)).toThrowError(); - }); - }); - }); - - describe('createForInsert', () => { - describe('when docName passed', () => { - const setup = () => { - const params = { docName: new ObjectId().toHexString() }; - - return { params }; - }; - - it('should return a object that support the interface UniqueKey', () => { - const { params } = setup(); - - const result = KeyFactory.createForInsert(params.docName); - - expect(result).toEqual({ - docName: params.docName, - version: 'v1_sv', - action: undefined, - clock: undefined, - }); - }); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/repo/key.factory.ts b/apps/server/src/modules/tldraw/repo/key.factory.ts deleted file mode 100644 index 83f1ef84233..00000000000 --- a/apps/server/src/modules/tldraw/repo/key.factory.ts +++ /dev/null @@ -1,54 +0,0 @@ -enum DatabaseAction { - UPDATE = 'update', -} - -export enum Version { - V1_SV = 'v1_sv', - V1 = 'v1', -} - -interface UniqueKey { - version: Version; - action?: DatabaseAction; - docName: string; - clock?: number; -} - -export class KeyFactory { - static checkValidClock(clock?: number): void { - if (clock && clock < -1) { - throw new Error('Invalid clock value is passed to KeyFactory'); - } - } - - static createForUpdate(docName: string, clock?: number): UniqueKey { - KeyFactory.checkValidClock(clock); - let uniqueKey: UniqueKey; - - if (clock !== undefined) { - uniqueKey = { - docName, - version: Version.V1, - action: DatabaseAction.UPDATE, - clock, - }; - } else { - uniqueKey = { - docName, - version: Version.V1, - action: DatabaseAction.UPDATE, - }; - } - - return uniqueKey; - } - - static createForInsert(docName: string): UniqueKey { - const uniqueKey = { - docName, - version: Version.V1_SV, - }; - - return uniqueKey; - } -} diff --git a/apps/server/src/modules/tldraw/repo/tldraw-board.repo.spec.ts b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.spec.ts deleted file mode 100644 index aca97319dc4..00000000000 --- a/apps/server/src/modules/tldraw/repo/tldraw-board.repo.spec.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Test } from '@nestjs/testing'; -import { Doc } from 'yjs'; -import { createMock } from '@golevelup/ts-jest'; -import { Logger } from '@src/core/logger'; -import { ConfigModule } from '@nestjs/config'; -import { MongoMemoryDatabaseModule } from '@infra/database'; -import { createConfigModuleOptions } from '@src/config'; -import { TldrawBoardRepo } from './tldraw-board.repo'; -import { WsSharedDocDo } from '../domain'; -import { tldrawTestConfig } from '../testing'; -import { TldrawDrawing } from '../entities'; -import { YMongodb } from './y-mongodb'; - -describe('TldrawBoardRepo', () => { - let repo: TldrawBoardRepo; - - beforeAll(async () => { - const testingModule = await Test.createTestingModule({ - imports: [ - MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] }), - ConfigModule.forRoot(createConfigModuleOptions(tldrawTestConfig)), - ], - providers: [ - TldrawBoardRepo, - { - provide: YMongodb, - useValue: createMock(), - }, - { - provide: Logger, - useValue: createMock(), - }, - ], - }).compile(); - - repo = testingModule.get(TldrawBoardRepo); - - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('should check if repo and its properties are set correctly', () => { - expect(repo).toBeDefined(); - expect(repo.mdb).toBeDefined(); - }); - - describe('getYDocFromMdb', () => { - describe('when taking doc data from db', () => { - const setup = () => { - const storeGetYDocSpy = jest.spyOn(repo.mdb, 'getDocument').mockResolvedValueOnce(new WsSharedDocDo('TEST')); - - return { - storeGetYDocSpy, - }; - }; - - it('should return ydoc', async () => { - const { storeGetYDocSpy } = setup(); - - const result = await repo.getDocumentFromDb('test'); - - expect(result).toBeInstanceOf(Doc); - storeGetYDocSpy.mockRestore(); - }); - }); - }); - - describe('compressDocument', () => { - const setup = () => { - const flushDocumentSpy = jest.spyOn(repo.mdb, 'compressDocumentTransactional').mockResolvedValueOnce(); - - return { flushDocumentSpy }; - }; - - it('should call compress method on YMongo', async () => { - const { flushDocumentSpy } = setup(); - - await repo.compressDocument('test'); - - expect(flushDocumentSpy).toHaveBeenCalled(); - flushDocumentSpy.mockRestore(); - }); - }); - - describe('storeUpdate', () => { - const setup = () => { - const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdateTransactional').mockResolvedValue(2); - const compressDocumentSpy = jest.spyOn(repo.mdb, 'compressDocumentTransactional').mockResolvedValueOnce(); - - return { - storeUpdateSpy, - compressDocumentSpy, - }; - }; - - it('should call store update method on YMongo', async () => { - const { storeUpdateSpy } = setup(); - - await repo.storeUpdate('test', new Uint8Array()); - - expect(storeUpdateSpy).toHaveBeenCalled(); - storeUpdateSpy.mockRestore(); - }); - - it('should call compressDocument if compress threshold was reached', async () => { - const { storeUpdateSpy, compressDocumentSpy } = setup(); - - await repo.storeUpdate('test', new Uint8Array()); - - expect(storeUpdateSpy).toHaveBeenCalled(); - expect(compressDocumentSpy).toHaveBeenCalled(); - storeUpdateSpy.mockRestore(); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts deleted file mode 100644 index 8ca1b2d02b8..00000000000 --- a/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { Logger } from '@src/core/logger'; -import { ConfigService } from '@nestjs/config'; -import { TldrawConfig } from '../config'; -import { WsSharedDocDo } from '../domain'; -import { YMongodb } from './y-mongodb'; - -@Injectable() -export class TldrawBoardRepo { - constructor( - private readonly configService: ConfigService, - readonly mdb: YMongodb, - private readonly logger: Logger - ) { - this.logger.setContext(TldrawBoardRepo.name); - } - - public async createDbIndex(): Promise { - await this.mdb.createIndex(); - } - - public async getDocumentFromDb(docName: string): Promise { - // can be return null, return type of functions need to be improve - const yDoc = await this.mdb.getDocument(docName); - return yDoc; - } - - public async compressDocument(docName: string): Promise { - await this.mdb.compressDocumentTransactional(docName); - } - - public async storeUpdate(docName: string, update: Uint8Array): Promise { - const compressThreshold = this.configService.get('TLDRAW_DB_COMPRESS_THRESHOLD'); - const currentClock = await this.mdb.storeUpdateTransactional(docName, update); - - if (currentClock % compressThreshold === 0) { - await this.compressDocument(docName); - } - } -} diff --git a/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts b/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts deleted file mode 100644 index 9e12d64d782..00000000000 --- a/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { EntityManager } from '@mikro-orm/mongodb'; -import { Test, TestingModule } from '@nestjs/testing'; -import { cleanupCollections } from '@shared/testing'; -import { MikroORM } from '@mikro-orm/core'; -import { MongoMemoryDatabaseModule } from '@src/infra/database'; -import { tldrawEntityFactory } from '../testing'; -import { TldrawDrawing } from '../entities'; -import { TldrawRepo } from './tldraw.repo'; - -describe('TldrawRepo', () => { - let testingModule: TestingModule; - let repo: TldrawRepo; - let em: EntityManager; - let orm: MikroORM; - - beforeAll(async () => { - testingModule = await Test.createTestingModule({ - imports: [MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] })], - providers: [TldrawRepo], - }).compile(); - - repo = testingModule.get(TldrawRepo); - em = testingModule.get(EntityManager); - orm = testingModule.get(MikroORM); - }); - - afterAll(async () => { - await testingModule.close(); - }); - - afterEach(async () => { - await cleanupCollections(em); - }); - - describe('create', () => { - describe('when called', () => { - const setup = async () => { - const drawing = tldrawEntityFactory.build(); - - await repo.create(drawing); - em.clear(); - - return { - drawing, - }; - }; - - it('should create new drawing node', async () => { - const { drawing } = await setup(); - - const result = await em.find(TldrawDrawing, {}); - - expect(result[0]._id).toEqual(drawing._id); - }); - - it('should flush the changes', async () => { - const drawing = tldrawEntityFactory.build(); - jest.spyOn(em, 'flush'); - - await repo.create(drawing); - - expect(em.flush).toHaveBeenCalled(); - }); - }); - }); - - describe('findByDocName', () => { - describe('when finding by docName', () => { - const setup = async () => { - const drawing = tldrawEntityFactory.build(); - await em.persistAndFlush(drawing); - em.clear(); - - return { drawing }; - }; - - it('should return the object', async () => { - const { drawing } = await setup(); - - const result = await repo.findByDocName(drawing.docName); - - expect(result[0].docName).toEqual(drawing.docName); - expect(result[0]._id).toEqual(drawing._id); - }); - }); - }); - - describe('delete', () => { - describe('when drawings exist', () => { - const setup = async () => { - const drawing = tldrawEntityFactory.build(); - - await repo.create(drawing); - - return { drawing }; - }; - - it('should delete the specified drawing', async () => { - const { drawing } = await setup(); - - await repo.delete([drawing]); - - const results = await repo.findByDocName(drawing.docName); - expect(results.length).toEqual(0); - }); - }); - }); - - describe('ensureIndexes', () => { - it('should call getSchemaGenerator().ensureIndexes()', async () => { - const ormSpy = jest.spyOn(orm, 'getSchemaGenerator'); - - await repo.ensureIndexes(); - - expect(ormSpy).toHaveBeenCalled(); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/repo/tldraw.repo.ts b/apps/server/src/modules/tldraw/repo/tldraw.repo.ts deleted file mode 100644 index c4c934fb540..00000000000 --- a/apps/server/src/modules/tldraw/repo/tldraw.repo.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { MikroORM } from '@mikro-orm/core'; -import { EntityManager } from '@mikro-orm/mongodb'; -import { BulkWriteResult, Collection, Sort } from '@mikro-orm/mongodb/node_modules/mongodb'; -import { Injectable } from '@nestjs/common'; -import { TldrawDrawing } from '../entities'; - -@Injectable() -export class TldrawRepo { - constructor(private readonly em: EntityManager, private readonly orm: MikroORM) {} - - public async create(entity: TldrawDrawing): Promise { - await this.em.persistAndFlush(entity); - } - - public async findByDocName(docName: string): Promise { - const drawings = await this.em.find(TldrawDrawing, { docName }); - return drawings; - } - - public async delete(entity: TldrawDrawing | TldrawDrawing[]): Promise { - await this.em.removeAndFlush(entity); - } - - public get(query: object): Promise { - const collection = this.getCollection(); - return collection.findOne(query, { allowDiskUse: true }); - } - - public async put(query: object, values: object): Promise { - const collection = this.getCollection(); - await collection.updateOne(query, { $set: values }, { upsert: true }); - return this.get(query); - } - - public del(query: object): Promise { - const collection = this.getCollection(); - const bulk = collection.initializeOrderedBulkOp(); - bulk.find(query).delete(); - return bulk.execute(); - } - - public readAsCursor(query: object, opts: { limit?: number; reverse?: boolean } = {}): Promise { - const { limit = 0, reverse = false } = opts; - - const collection = this.getCollection(); - const sortQuery: Sort = reverse ? { clock: -1, part: 1 } : { clock: 1, part: 1 }; - const curs = collection.find(query, { allowDiskUse: true }).sort(sortQuery).limit(limit); - - return curs.toArray(); - } - - public getCollection(): Collection { - return this.em.getCollection(TldrawDrawing); - } - - public async ensureIndexes(): Promise { - await this.orm.getSchemaGenerator().ensureIndexes(); - } -} diff --git a/apps/server/src/modules/tldraw/repo/y-mongodb.spec.ts b/apps/server/src/modules/tldraw/repo/y-mongodb.spec.ts deleted file mode 100644 index dbdd475a32f..00000000000 --- a/apps/server/src/modules/tldraw/repo/y-mongodb.spec.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { createMock } from '@golevelup/ts-jest'; -import { MongoMemoryDatabaseModule } from '@infra/database'; -import { EntityManager } from '@mikro-orm/mongodb'; -import { ConfigModule } from '@nestjs/config'; -import { Test, TestingModule } from '@nestjs/testing'; -import { cleanupCollections } from '@shared/testing'; -import { createConfigModuleOptions } from '@src/config'; -import { DomainErrorHandler } from '@src/core'; -import * as Yjs from 'yjs'; -import { TldrawDrawing } from '../entities'; -import { tldrawEntityFactory, tldrawTestConfig } from '../testing'; -import { Version } from './key.factory'; -import { TldrawRepo } from './tldraw.repo'; -import { YMongodb } from './y-mongodb'; - -jest.mock('yjs', () => { - const moduleMock: unknown = { - __esModule: true, - ...jest.requireActual('yjs'), - }; - return moduleMock; -}); - -describe('YMongoDb', () => { - let testingModule: TestingModule; - let mdb: YMongodb; - let repo: TldrawRepo; - let em: EntityManager; - - beforeAll(async () => { - testingModule = await Test.createTestingModule({ - imports: [ - MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] }), - ConfigModule.forRoot(createConfigModuleOptions(tldrawTestConfig)), - ], - providers: [ - YMongodb, - TldrawRepo, - { - provide: DomainErrorHandler, - useValue: createMock(), - }, - ], - }).compile(); - - mdb = testingModule.get(YMongodb); - repo = testingModule.get(TldrawRepo); - em = testingModule.get(EntityManager); - }); - - afterAll(async () => { - await testingModule.close(); - }); - - afterEach(async () => { - await cleanupCollections(em); - }); - - describe('storeUpdateTransactional', () => { - describe('when clock is defined', () => { - const setup = async () => { - const drawing = tldrawEntityFactory.build({ clock: 1 }); - await em.persistAndFlush(drawing); - em.clear(); - - return { drawing }; - }; - - it('should create new document with updates in the database', async () => { - const { drawing } = await setup(); - - await mdb.storeUpdateTransactional(drawing.docName, new Uint8Array([])); - const docs = await em.findAndCount(TldrawDrawing, { docName: drawing.docName }); - - expect(docs.length).toEqual(2); - }); - }); - - describe('when clock is undefined', () => { - const setup = async () => { - const applyUpdateSpy = jest.spyOn(Yjs, 'applyUpdate').mockReturnValueOnce(); - const drawing = tldrawEntityFactory.build({ clock: undefined }); - - await em.persistAndFlush(drawing); - em.clear(); - - return { - applyUpdateSpy, - drawing, - }; - }; - - it('should call applyUpdate and create new document with updates in the database', async () => { - const { applyUpdateSpy, drawing } = await setup(); - - await mdb.storeUpdateTransactional(drawing.docName, new Uint8Array([2, 2])); - const docs = await em.findAndCount(TldrawDrawing, { docName: drawing.docName }); - - expect(applyUpdateSpy).toHaveBeenCalled(); - expect(docs.length).toEqual(2); - }); - }); - }); - - describe('compressDocumentTransactional', () => { - const setup = async () => { - const applyUpdateSpy = jest.spyOn(Yjs, 'applyUpdate').mockReturnValueOnce(); - const mergeUpdatesSpy = jest.spyOn(Yjs, 'mergeUpdates').mockReturnValueOnce(new Uint8Array([])); - - const drawing1 = tldrawEntityFactory.build({ clock: 1, part: undefined }); - const drawing2 = tldrawEntityFactory.build({ clock: 2, part: undefined }); - const drawing3 = tldrawEntityFactory.build({ clock: 3, part: undefined }); - const drawing4 = tldrawEntityFactory.build({ clock: 4, part: undefined }); - - await em.persistAndFlush([drawing1, drawing2, drawing3, drawing4]); - em.clear(); - - return { - applyUpdateSpy, - mergeUpdatesSpy, - drawing1, - }; - }; - - it('should merge multiple documents with the same name in the database into two (one main document and one with update)', async () => { - const { applyUpdateSpy, drawing1 } = await setup(); - - await mdb.compressDocumentTransactional(drawing1.docName); - const docs = await em.findAndCount(TldrawDrawing, { docName: drawing1.docName }); - - expect(docs.length).toEqual(2); - applyUpdateSpy.mockRestore(); - }); - }); - - describe('createIndex', () => { - const setup = () => { - const ensureIndexesSpy = jest.spyOn(repo, 'ensureIndexes').mockResolvedValueOnce(); - - return { - ensureIndexesSpy, - }; - }; - - it('should create index', async () => { - const { ensureIndexesSpy } = setup(); - - await mdb.createIndex(); - - expect(ensureIndexesSpy).toHaveBeenCalled(); - }); - }); - - describe('getAllDocumentNames', () => { - const setup = async () => { - const drawing1 = tldrawEntityFactory.build({ docName: 'test-name1', version: Version.V1_SV }); - const drawing2 = tldrawEntityFactory.build({ docName: 'test-name2', version: Version.V1_SV }); - const drawing3 = tldrawEntityFactory.build({ docName: 'test-name3', version: Version.V1_SV }); - - await em.persistAndFlush([drawing1, drawing2, drawing3]); - em.clear(); - - return { - expectedDocNames: [drawing1.docName, drawing2.docName, drawing3.docName], - }; - }; - - it('should return all document names', async () => { - const { expectedDocNames } = await setup(); - - const docNames = await mdb.getAllDocumentNames(); - - expect(docNames).toEqual(expectedDocNames); - }); - }); - - describe('getYDoc', () => { - describe('when getting document with well defined parts', () => { - const setup = async () => { - const applyUpdateSpy = jest.spyOn(Yjs, 'applyUpdate').mockReturnValue(); - const mergeUpdatesSpy = jest.spyOn(Yjs, 'mergeUpdates').mockReturnValue(new Uint8Array([])); - - const drawing1 = tldrawEntityFactory.build({ clock: 1, part: 1 }); - const drawing2 = tldrawEntityFactory.build({ clock: 1, part: 2 }); - const drawing3 = tldrawEntityFactory.build({ clock: 2, part: 1 }); - - await em.persistAndFlush([drawing1, drawing2, drawing3]); - em.clear(); - - return { - applyUpdateSpy, - mergeUpdatesSpy, - drawing1, - drawing2, - drawing3, - }; - }; - - it('should return ydoc', async () => { - const { applyUpdateSpy } = await setup(); - - const doc = await mdb.getDocument('test-name'); - - expect(doc).toBeDefined(); - applyUpdateSpy.mockRestore(); - }); - }); - - describe('when getting document with missing parts', () => { - const setup = async () => { - const applyUpdateSpy = jest.spyOn(Yjs, 'applyUpdate').mockReturnValue(); - - const drawing1 = tldrawEntityFactory.build({ clock: 1, part: 1 }); - const drawing4 = tldrawEntityFactory.build({ clock: 1, part: 3 }); - const drawing5 = tldrawEntityFactory.build({ clock: 1, part: 4 }); - - await em.persistAndFlush([drawing1, drawing4, drawing5]); - em.clear(); - - return { - applyUpdateSpy, - }; - }; - - it('should not return ydoc', async () => { - const { applyUpdateSpy } = await setup(); - - const doc = await mdb.getDocument('test-name'); - - expect(doc).toBeNull(); - applyUpdateSpy.mockRestore(); - }); - }); - - describe('when getting document with part undefined', () => { - const setup = async () => { - const applyUpdateSpy = jest.spyOn(Yjs, 'applyUpdate').mockReturnValue(); - const drawing1 = tldrawEntityFactory.build({ part: undefined }); - const drawing2 = tldrawEntityFactory.build({ part: undefined }); - const drawing3 = tldrawEntityFactory.build({ part: undefined }); - - await em.persistAndFlush([drawing1, drawing2, drawing3]); - em.clear(); - - return { - applyUpdateSpy, - }; - }; - - it('should return ydoc from the database', async () => { - const { applyUpdateSpy } = await setup(); - - const doc = await mdb.getDocument('test-name'); - - expect(doc).toBeDefined(); - applyUpdateSpy.mockRestore(); - }); - - describe('when single entity size is greater than MAX_DOCUMENT_SIZE', () => { - it('should return ydoc from the database', async () => { - const { applyUpdateSpy } = await setup(); - - const doc = await mdb.getDocument('test-name'); - - expect(doc).toBeDefined(); - applyUpdateSpy.mockRestore(); - }); - }); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/repo/y-mongodb.ts b/apps/server/src/modules/tldraw/repo/y-mongodb.ts deleted file mode 100644 index edc7fae12fc..00000000000 --- a/apps/server/src/modules/tldraw/repo/y-mongodb.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { BulkWriteResult } from '@mikro-orm/mongodb/node_modules/mongodb'; -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { DomainErrorHandler } from '@src/core'; -import { Buffer } from 'buffer'; -import * as binary from 'lib0/binary'; -import * as encoding from 'lib0/encoding'; -import * as promise from 'lib0/promise'; -import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector, mergeUpdates } from 'yjs'; -import { TldrawConfig } from '../config'; -import { WsSharedDocDo } from '../domain'; -import { TldrawDrawing } from '../entities'; -import { MongoTransactionErrorLoggable } from '../loggable'; -import { YTransaction } from '../types'; -import { KeyFactory, Version } from './key.factory'; -import { TldrawRepo } from './tldraw.repo'; - -@Injectable() -export class YMongodb { - private readonly _transact: >(docName: string, fn: () => T) => T; - - // scope the queue of the transaction to each docName - // this should allow concurrency for different rooms - private tr: { docName?: Promise } = {}; - - constructor( - private readonly configService: ConfigService, - private readonly repo: TldrawRepo, - private readonly domainErrorHandler: DomainErrorHandler - ) { - // execute a transaction on a database - // this will ensure that other processes are currently not writing - this._transact = >(docName: string, fn: () => T): T => { - if (!this.tr[docName]) { - this.tr[docName] = promise.resolve(); - } - - const currTr = this.tr[docName] as T; - let nextTr: Promise = promise.resolve(null); - - nextTr = (async () => { - await currTr; - - let res: YTransaction | null = null; - try { - res = await fn(); - } catch (err) { - this.domainErrorHandler.exec(new MongoTransactionErrorLoggable(err)); - } - - // once the last transaction for a given docName resolves, remove it from the queue - if (this.tr[docName] === nextTr) { - delete this.tr[docName]; - } - - return res; - })(); - - this.tr[docName] = nextTr; - - return this.tr[docName] as T; - }; - } - - public async createIndex(): Promise { - await this.repo.ensureIndexes(); - } - - public async getAllDocumentNames(): Promise { - const docs = await this.repo.readAsCursor({ version: Version.V1_SV }); - const docNames = docs.map((doc) => doc.docName); - - return docNames; - } - - public getDocument(docName: string): Promise { - // return value can be null, need to be defined - return this._transact(docName, async (): Promise => { - const updates = await this.getMongoUpdates(docName); - const mergedUpdates = mergeUpdates(updates); - - const gcEnabled = this.configService.get('TLDRAW_GC_ENABLED'); - const ydoc = new WsSharedDocDo(docName, gcEnabled); - applyUpdate(ydoc, mergedUpdates); - - return ydoc; - }); - } - - public storeUpdateTransactional(docName: string, update: Uint8Array): Promise { - // return value can be null, need to be defined - return this._transact(docName, () => this.storeUpdate(docName, update)); - } - - // return value is not void, need to be changed - public compressDocumentTransactional(docName: string): Promise { - performance.mark('compressDocumentTransactional'); - - return this._transact(docName, async () => { - const updates = await this.getMongoUpdates(docName); - const mergedUpdates = mergeUpdates(updates); - - const ydoc = new Doc(); - applyUpdate(ydoc, mergedUpdates); - - const stateAsUpdate = encodeStateAsUpdate(ydoc); - const sv = encodeStateVector(ydoc); - const clock = await this.storeUpdate(docName, stateAsUpdate); - - await this.writeStateVector(docName, sv, clock); - await this.clearUpdatesRange(docName, 0, clock); - - ydoc.destroy(); - - performance.measure('tldraw:YMongodb:compressDocumentTransactional', { - start: 'compressDocumentTransactional', - detail: { doc_name: docName, clock }, - }); - }); - } - - public async getCurrentUpdateClock(docName: string): Promise { - const updates = await this.getMongoBulkData( - { - ...KeyFactory.createForUpdate(docName, 0), - clock: { - $gte: 0, - $lt: binary.BITS32, - }, - }, - { reverse: true, limit: 1 } - ); - - const clock = this.extractClock(updates); - - return clock; - } - - private async clearUpdatesRange(docName: string, from: number, to: number): Promise { - return this.repo.del({ - docName, - clock: { - $gte: from, - $lt: to, - }, - }); - } - - private getMongoBulkData(query: object, opts: object): Promise { - return this.repo.readAsCursor(query, opts); - } - - private mergeDocsTogether( - tldrawDrawingEntity: TldrawDrawing, - tldrawDrawingEntities: TldrawDrawing[], - docIndex: number - ): Buffer[] { - const parts = [Buffer.from(tldrawDrawingEntity.value.buffer)]; - let currentPartId: number | undefined = tldrawDrawingEntity.part; - for (let i = docIndex + 1; i < tldrawDrawingEntities.length; i += 1) { - const entity = tldrawDrawingEntities[i]; - - if (!this.isSameClock(entity, tldrawDrawingEntity)) { - break; - } - - this.checkIfPartIsNextPartAfterCurrent(entity, currentPartId); - - parts.push(Buffer.from(entity.value.buffer)); - currentPartId = entity.part; - } - - return parts; - } - - /** - * Convert the mongo document array to an array of values (as buffers) - */ - private convertMongoUpdates(tldrawDrawingEntities: TldrawDrawing[]): Buffer[] { - if (!Array.isArray(tldrawDrawingEntities) || !tldrawDrawingEntities.length) return []; - - const updates: Buffer[] = []; - for (let i = 0; i < tldrawDrawingEntities.length; i += 1) { - const tldrawDrawingEntity = tldrawDrawingEntities[i]; - - if (!tldrawDrawingEntity.part) { - updates.push(Buffer.from(tldrawDrawingEntity.value.buffer)); - } - - if (tldrawDrawingEntity.part === 1) { - // merge the docs together that got split because of mongodb size limits - const parts = this.mergeDocsTogether(tldrawDrawingEntity, tldrawDrawingEntities, i); - updates.push(Buffer.concat(parts)); - } - } - return updates; - } - - /** - * Get all document updates for a specific document. - */ - private async getMongoUpdates(docName: string, opts = {}): Promise { - performance.mark('getMongoUpdates'); - - const uniqueKey = KeyFactory.createForUpdate(docName); - const tldrawDrawingEntities = await this.getMongoBulkData(uniqueKey, opts); - - const buffer = this.convertMongoUpdates(tldrawDrawingEntities); - - performance.measure('tldraw:YMongodb:getMongoUpdates', { - start: 'getMongoUpdates', - detail: { doc_name: docName, loaded_tldraw_entities_total: tldrawDrawingEntities.length }, - }); - - return buffer; - } - - private async writeStateVector(docName: string, sv: Uint8Array, clock: number): Promise { - const encoder = encoding.createEncoder(); - encoding.writeVarUint(encoder, clock); - encoding.writeVarUint8Array(encoder, sv); - const uniqueKey = KeyFactory.createForInsert(docName); - - await this.repo.put(uniqueKey, { - value: Buffer.from(encoding.toUint8Array(encoder)), - }); - } - - private async storeUpdate(docName: string, update: Uint8Array): Promise { - const clock: number = await this.getCurrentUpdateClock(docName); - - if (clock === -1) { - // make sure that a state vector is always written, so we can search for available documents - const ydoc = new Doc(); - applyUpdate(ydoc, update); - const sv = encodeStateVector(ydoc); - - await this.writeStateVector(docName, sv, 0); - } - - const maxDocumentSize = this.configService.get('TLDRAW_MAX_DOCUMENT_SIZE'); - const value = Buffer.from(update); - // if our buffer exceeds maxDocumentSize, we store the update in multiple documents - if (value.length <= maxDocumentSize) { - const uniqueKey = KeyFactory.createForUpdate(docName, clock + 1); - - await this.repo.put(uniqueKey, { - value, - }); - } else { - const totalChunks = Math.ceil(value.length / maxDocumentSize); - - const putPromises: Promise[] = []; - for (let i = 0; i < totalChunks; i += 1) { - const start = i * maxDocumentSize; - const end = Math.min(start + maxDocumentSize, value.length); - const chunk = value.subarray(start, end); - - putPromises.push( - this.repo.put({ ...KeyFactory.createForUpdate(docName, clock + 1), part: i + 1 }, { value: chunk }) - ); - } - - await Promise.all(putPromises); - } - - return clock + 1; - } - - private isSameClock(tldrawDrawingEntity1: TldrawDrawing, tldrawDrawingEntity2: TldrawDrawing): boolean { - return tldrawDrawingEntity1.clock === tldrawDrawingEntity2.clock; - } - - private checkIfPartIsNextPartAfterCurrent( - tldrawDrawingEntity: TldrawDrawing, - currentPartId: number | undefined - ): void { - if (tldrawDrawingEntity.part === undefined || currentPartId !== tldrawDrawingEntity.part - 1) { - throw new Error('Could not merge updates together because a part is missing'); - } - } - - private extractClock(tldrawDrawingEntities: TldrawDrawing[]): number { - if (tldrawDrawingEntities.length === 0 || tldrawDrawingEntities[0].clock == null) { - return -1; - } - return tldrawDrawingEntities[0].clock; - } -} diff --git a/apps/server/src/modules/tldraw/service/index.ts b/apps/server/src/modules/tldraw/service/index.ts deleted file mode 100644 index 23b3adf2ee4..00000000000 --- a/apps/server/src/modules/tldraw/service/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './tldraw-files-storage.service'; -export * from './tldraw.service'; -export * from './tldraw.ws.service'; diff --git a/apps/server/src/modules/tldraw/service/tldraw-files-storage.service.spec.ts b/apps/server/src/modules/tldraw/service/tldraw-files-storage.service.spec.ts deleted file mode 100644 index 09352805248..00000000000 --- a/apps/server/src/modules/tldraw/service/tldraw-files-storage.service.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { createMock } from '@golevelup/ts-jest'; -import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; -import { tldrawFileDtoFactory } from '@shared/testing/factory'; -import { TldrawFilesStorageAdapterService } from './tldraw-files-storage.service'; -import { tldrawAssetFactory } from '../testing'; - -describe('TldrawFilesStorageAdapterService', () => { - let module: TestingModule; - let tldrawFilesStorageAdapterService: TldrawFilesStorageAdapterService; - let filesStorageClientAdapterService: FilesStorageClientAdapterService; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - TldrawFilesStorageAdapterService, - { - provide: FilesStorageClientAdapterService, - useValue: createMock(), - }, - ], - }).compile(); - - tldrawFilesStorageAdapterService = module.get(TldrawFilesStorageAdapterService); - filesStorageClientAdapterService = module.get(FilesStorageClientAdapterService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('deleteUnusedFilesForDocument', () => { - describe('when there are files found for this document', () => { - const setup = () => { - const asset = tldrawAssetFactory.build(); - const usedAssets = [asset]; - - const fileDtos = tldrawFileDtoFactory.buildListWithId(2); - const fileWithWrongDate = tldrawFileDtoFactory.build({ createdAt: undefined }); - fileDtos.push(fileWithWrongDate); - - const listFilesOfParentSpy = jest - .spyOn(filesStorageClientAdapterService, 'listFilesOfParent') - .mockResolvedValueOnce(fileDtos); - const deleteFilesSpy = jest.spyOn(filesStorageClientAdapterService, 'deleteFiles'); - - return { - usedAssets, - listFilesOfParentSpy, - deleteFilesSpy, - }; - }; - - it('should call deleteFiles on filesStorageClientAdapterService', async () => { - const { usedAssets, listFilesOfParentSpy, deleteFilesSpy } = setup(); - - await tldrawFilesStorageAdapterService.deleteUnusedFilesForDocument('docname', usedAssets, new Date()); - - expect(listFilesOfParentSpy).toHaveBeenCalled(); - expect(deleteFilesSpy).toHaveBeenCalled(); - }); - - describe('when no files are older than the threshold date', () => { - it('should not call deleteFiles on filesStorageClientAdapterService', async () => { - const { usedAssets, listFilesOfParentSpy, deleteFilesSpy } = setup(); - - await tldrawFilesStorageAdapterService.deleteUnusedFilesForDocument( - 'docname', - usedAssets, - new Date(2019, 1, 1, 0, 0) - ); - - expect(listFilesOfParentSpy).toHaveBeenCalled(); - expect(deleteFilesSpy).not.toHaveBeenCalled(); - }); - }); - }); - - describe('when there are no files found for this document', () => { - const setup = () => { - const listFilesOfParentSpy = jest - .spyOn(filesStorageClientAdapterService, 'listFilesOfParent') - .mockResolvedValueOnce([]); - const deleteFilesSpy = jest.spyOn(filesStorageClientAdapterService, 'deleteFiles'); - - return { - listFilesOfParentSpy, - deleteFilesSpy, - }; - }; - - it('should not call deleteFiles on filesStorageClientAdapterService', async () => { - const { listFilesOfParentSpy, deleteFilesSpy } = setup(); - - await tldrawFilesStorageAdapterService.deleteUnusedFilesForDocument('docname', [], new Date()); - - expect(listFilesOfParentSpy).toHaveBeenCalled(); - expect(deleteFilesSpy).not.toHaveBeenCalled(); - }); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/service/tldraw-files-storage.service.ts b/apps/server/src/modules/tldraw/service/tldraw-files-storage.service.ts deleted file mode 100644 index fef272813fc..00000000000 --- a/apps/server/src/modules/tldraw/service/tldraw-files-storage.service.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { FileDto, FilesStorageClientAdapterService } from '@modules/files-storage-client'; -import { TldrawAsset } from '../types'; - -@Injectable() -export class TldrawFilesStorageAdapterService { - constructor(private readonly filesStorageClientAdapterService: FilesStorageClientAdapterService) {} - - public async deleteUnusedFilesForDocument( - docName: string, - usedAssets: TldrawAsset[], - createdBeforeDate: Date - ): Promise { - const fileRecords = await this.filesStorageClientAdapterService.listFilesOfParent(docName); - const fileRecordIdsForDeletion = this.foundAssetsForDeletion(fileRecords, usedAssets, createdBeforeDate); - - if (fileRecordIdsForDeletion.length === 0) { - return; - } - - await this.filesStorageClientAdapterService.deleteFiles(fileRecordIdsForDeletion); - } - - private foundAssetsForDeletion(fileRecords: FileDto[], usedAssets: TldrawAsset[], createdBeforeDate: Date): string[] { - const fileRecordIdsForDeletion: string[] = []; - - for (const fileRecord of fileRecords) { - if (this.isOlderThanRequiredDate(fileRecord, createdBeforeDate)) { - this.addFileRecordIdToDeletionList(fileRecord, fileRecordIdsForDeletion, usedAssets); - } - } - - return fileRecordIdsForDeletion; - } - - private addFileRecordIdToDeletionList( - fileRecord: FileDto, - fileRecordIdsForDeletion: string[], - usedAssets: TldrawAsset[] - ) { - const foundAsset = usedAssets.some((asset) => this.matchAssetWithFileRecord(asset, fileRecord)); - if (!foundAsset) { - fileRecordIdsForDeletion.push(fileRecord.id); - } - } - - private isOlderThanRequiredDate(fileRecord: FileDto, createdBeforeDate: Date) { - if (!fileRecord.createdAt) { - return false; - } - - const isOlder = new Date(fileRecord.createdAt) < createdBeforeDate; - return isOlder; - } - - private matchAssetWithFileRecord(asset: TldrawAsset, fileRecord: FileDto) { - const srcArr = asset.src.split('/'); - const fileRecordId = srcArr[srcArr.length - 2]; - - return fileRecordId === fileRecord.id; - } -} diff --git a/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts b/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts deleted file mode 100644 index 8febc5f1f88..00000000000 --- a/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { EntityManager } from '@mikro-orm/mongodb'; -import { MongoMemoryDatabaseModule } from '@infra/database'; -import { cleanupCollections } from '@shared/testing'; -import { ConfigModule } from '@nestjs/config'; -import { createConfigModuleOptions } from '@src/config'; -import { TldrawDrawing } from '../entities'; -import { tldrawEntityFactory, tldrawTestConfig } from '../testing'; -import { TldrawRepo } from '../repo/tldraw.repo'; -import { TldrawService } from './tldraw.service'; - -describe('TldrawService', () => { - let module: TestingModule; - let service: TldrawService; - let repo: TldrawRepo; - let em: EntityManager; - - beforeAll(async () => { - module = await Test.createTestingModule({ - imports: [ - MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] }), - ConfigModule.forRoot(createConfigModuleOptions(tldrawTestConfig)), - ], - providers: [TldrawService, TldrawRepo], - }).compile(); - - repo = module.get(TldrawRepo); - service = module.get(TldrawService); - em = module.get(EntityManager); - }); - - afterAll(async () => { - await module.close(); - }); - - afterEach(async () => { - await cleanupCollections(em); - jest.clearAllMocks(); - }); - - describe('delete', () => { - describe('when deleting all collection connected to one drawing', () => { - it('should remove all collections giving drawing name', async () => { - const drawing = tldrawEntityFactory.build(); - await repo.create(drawing); - - await service.deleteByDocName(drawing.docName); - - const result = await repo.findByDocName(drawing.docName); - expect(result.length).toEqual(0); - }); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/service/tldraw.service.ts b/apps/server/src/modules/tldraw/service/tldraw.service.ts deleted file mode 100644 index 8001a72ed0f..00000000000 --- a/apps/server/src/modules/tldraw/service/tldraw.service.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { TldrawRepo } from '../repo'; - -@Injectable() -export class TldrawService { - constructor(private readonly tldrawRepo: TldrawRepo) {} - - async deleteByDocName(docName: string): Promise { - const drawings = await this.tldrawRepo.findByDocName(docName); - await this.tldrawRepo.delete(drawings); - } -} diff --git a/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts b/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts deleted file mode 100644 index 61853806003..00000000000 --- a/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts +++ /dev/null @@ -1,1138 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { MongoMemoryDatabaseModule } from '@infra/database'; -import { HttpService } from '@nestjs/axios'; -import { INestApplication } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { WsAdapter } from '@nestjs/platform-ws'; -import { Test } from '@nestjs/testing'; -import { WebSocketReadyStateEnum } from '@shared/testing'; -import { TldrawWsFactory } from '@shared/testing/factory/tldraw.ws.factory'; -import { createConfigModuleOptions } from '@src/config'; -import { DomainErrorHandler } from '@src/core'; -import * as Ioredis from 'ioredis'; -import { encoding } from 'lib0'; -import { TextEncoder } from 'util'; -import WebSocket from 'ws'; -import * as AwarenessProtocol from 'y-protocols/awareness'; -import * as SyncProtocols from 'y-protocols/sync'; -import * as Yjs from 'yjs'; -import { TldrawWsService } from '.'; -import { TldrawWs } from '../controller'; -import { WsSharedDocDo } from '../domain'; -import { TldrawDrawing } from '../entities'; -import { MetricsService } from '../metrics'; -import { TldrawRedisFactory, TldrawRedisService } from '../redis'; -import { TldrawBoardRepo, TldrawRepo, YMongodb } from '../repo'; -import { TestConnection, tldrawTestConfig } from '../testing'; - -jest.mock('yjs', () => { - const moduleMock: unknown = { - __esModule: true, - ...jest.requireActual('yjs'), - }; - return moduleMock; -}); -jest.mock('y-protocols/awareness', () => { - const moduleMock: unknown = { - __esModule: true, - ...jest.requireActual('y-protocols/awareness'), - }; - return moduleMock; -}); -jest.mock('y-protocols/sync', () => { - const moduleMock: unknown = { - __esModule: true, - ...jest.requireActual('y-protocols/sync'), - }; - return moduleMock; -}); - -const createMessage = (values: number[]) => { - const encoder = encoding.createEncoder(); - values.forEach((val) => { - encoding.writeVarUint(encoder, val); - }); - encoding.writeVarUint(encoder, 0); - encoding.writeVarUint(encoder, 1); - const msg = encoding.toUint8Array(encoder); - - return { - msg, - }; -}; - -describe('TldrawWSService', () => { - let app: INestApplication; - let wsGlobal: WebSocket; - let service: TldrawWsService; - let boardRepo: DeepMocked; - // let domainErrorHandler: DeepMocked; - - const gatewayPort = 3346; - const wsUrl = TestConnection.getWsUrl(gatewayPort); - - const delay = (ms: number) => - new Promise((resolve) => { - setTimeout(resolve, ms); - }); - - beforeAll(async () => { - const testingModule = await Test.createTestingModule({ - imports: [ - MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] }), - ConfigModule.forRoot(createConfigModuleOptions(tldrawTestConfig)), - ], - providers: [ - TldrawWs, - TldrawWsService, - YMongodb, - MetricsService, - TldrawRedisFactory, - TldrawRedisService, - { - provide: TldrawBoardRepo, - useValue: createMock(), - }, - { - provide: TldrawRepo, - useValue: createMock(), - }, - { - provide: DomainErrorHandler, - useValue: createMock(), - }, - { - provide: HttpService, - useValue: createMock(), - }, - ], - }).compile(); - - service = testingModule.get(TldrawWsService); - boardRepo = testingModule.get(TldrawBoardRepo); - // domainErrorHandler = testingModule.get(DomainErrorHandler); - app = testingModule.createNestApplication(); - app.useWebSocketAdapter(new WsAdapter(app)); - await app.init(); - }); - - afterAll(async () => { - await app.close(); - }); - - afterEach(() => { - jest.restoreAllMocks(); - jest.clearAllMocks(); - }); - - describe('send', () => { - describe('when client is not connected to WS', () => { - const setup = async () => { - const ws = await TestConnection.setupWs(wsUrl, 'TEST'); - const closeConMock = jest.spyOn(service, 'closeConnection').mockResolvedValueOnce(); - const doc = TldrawWsFactory.createWsSharedDocDo(); - const byteArray = new TextEncoder().encode('test-message'); - - return { - closeConMock, - doc, - byteArray, - ws, - }; - }; - - it('should throw error for send message', async () => { - const { closeConMock, doc, byteArray, ws } = await setup(); - - service.send(doc, ws, byteArray); - - expect(closeConMock).toHaveBeenCalled(); - ws.close(); - }); - }); - - describe('when client is not connected to WS and close connection throws error', () => { - const setup = () => { - const socketMock = TldrawWsFactory.createWebsocket(WebSocketReadyStateEnum.OPEN); - const clientMessageMock = 'test-message'; - - jest.spyOn(service, 'closeConnection').mockRejectedValueOnce(new Error('error')); - jest.spyOn(socketMock, 'send').mockImplementationOnce((...args: unknown[]) => { - args.forEach((arg) => { - if (typeof arg === 'function') { - arg(new Error('error')); - } - }); - }); - - const doc = TldrawWsFactory.createWsSharedDocDo(); - const byteArray = new TextEncoder().encode(clientMessageMock); - - return { - socketMock, - doc, - byteArray, - }; - }; - - it('should log error', () => { - const { socketMock, doc, byteArray } = setup(); - - const result = service.send(doc, socketMock, byteArray); - - // await delay(100); - - expect(result).toBeUndefined(); - // expect(domainErrorHandler.exec).toHaveBeenCalledTimes(3); - }); - }); - - describe('when web socket has ready state CLOSED and close connection throws error', () => { - const setup = () => { - const socketMock = TldrawWsFactory.createWebsocket(WebSocketReadyStateEnum.CLOSED); - - const closeConMock = jest.spyOn(service, 'closeConnection').mockRejectedValueOnce(new Error('error')); - const doc = TldrawWsFactory.createWsSharedDocDo(); - const byteArray = new TextEncoder().encode('test-message'); - - return { - socketMock, - closeConMock, - doc, - byteArray, - }; - }; - - it('should log error', () => { - const { socketMock, closeConMock, doc, byteArray } = setup(); - - service.send(doc, socketMock, byteArray); - - expect(closeConMock).toHaveBeenCalled(); - }); - }); - - describe('when websocket has ready state different than Open (1) or Connecting (0)', () => { - const setup = () => { - const clientMessageMock = 'test-message'; - const closeConSpy = jest.spyOn(service, 'closeConnection'); - const sendSpy = jest.spyOn(service, 'send'); - const doc = TldrawWsFactory.createWsSharedDocDo(); - const socketMock = TldrawWsFactory.createWebsocket(WebSocketReadyStateEnum.OPEN); - const byteArray = new TextEncoder().encode(clientMessageMock); - - return { - closeConSpy, - sendSpy, - doc, - socketMock, - byteArray, - }; - }; - - it('should close connection', () => { - const { closeConSpy, sendSpy, doc, socketMock, byteArray } = setup(); - - service.send(doc, socketMock, byteArray); - - expect(sendSpy).toHaveBeenCalledWith(doc, socketMock, byteArray); - expect(sendSpy).toHaveBeenCalledTimes(1); - closeConSpy.mockRestore(); - sendSpy.mockRestore(); - }); - }); - - describe('when websocket has ready state Open (0)', () => { - const setup = async () => { - wsGlobal = await TestConnection.setupWs(wsUrl, 'TEST'); - const clientMessageMock = 'test-message'; - - const sendSpy = jest.spyOn(service, 'send'); - jest.spyOn(Ioredis.Redis.prototype, 'publish').mockResolvedValueOnce(1); - const doc = TldrawWsFactory.createWsSharedDocDo(); - const socketMock = TldrawWsFactory.createWebsocket(WebSocketReadyStateEnum.OPEN); - doc.connections.set(socketMock, new Set()); - const encoder = encoding.createEncoder(); - encoding.writeVarUint(encoder, 2); - const updateByteArray = new TextEncoder().encode(clientMessageMock); - encoding.writeVarUint8Array(encoder, updateByteArray); - const msg = encoding.toUint8Array(encoder); - - return { - sendSpy, - doc, - msg, - socketMock, - }; - }; - - it('should call send in updateHandler', async () => { - const { sendSpy, doc, msg, socketMock } = await setup(); - - service.updateHandler(msg, socketMock, doc); - - expect(sendSpy).toHaveBeenCalled(); - wsGlobal.close(); - sendSpy.mockRestore(); - }); - }); - - describe('when received message of type specific type', () => { - const setup = async (messageValues: number[]) => { - wsGlobal = await TestConnection.setupWs(wsUrl, 'TEST'); - - const publishSpy = jest.spyOn(Ioredis.Redis.prototype, 'publish'); - const sendSpy = jest.spyOn(service, 'send'); - const applyAwarenessUpdateSpy = jest.spyOn(AwarenessProtocol, 'applyAwarenessUpdate'); - const syncProtocolUpdateSpy = jest - .spyOn(SyncProtocols, 'readSyncMessage') - .mockImplementationOnce((_dec, enc) => { - enc.bufs = [new Uint8Array(2), new Uint8Array(2)]; - return 1; - }); - const doc = new WsSharedDocDo('TEST'); - const { msg } = createMessage(messageValues); - - return { - sendSpy, - publishSpy, - applyAwarenessUpdateSpy, - syncProtocolUpdateSpy, - doc, - msg, - }; - }; - - it('should call send method when received message of type SYNC', async () => { - const { sendSpy, applyAwarenessUpdateSpy, syncProtocolUpdateSpy, doc, msg } = await setup([0, 1]); - - service.messageHandler(wsGlobal, doc, msg); - - expect(sendSpy).toHaveBeenCalledTimes(1); - wsGlobal.close(); - sendSpy.mockRestore(); - applyAwarenessUpdateSpy.mockRestore(); - syncProtocolUpdateSpy.mockRestore(); - }); - - it('should not call send method when received message of type AWARENESS', async () => { - const { sendSpy, applyAwarenessUpdateSpy, syncProtocolUpdateSpy, doc, msg } = await setup([1, 1, 0]); - - service.messageHandler(wsGlobal, doc, msg); - - expect(sendSpy).not.toHaveBeenCalled(); - wsGlobal.close(); - sendSpy.mockRestore(); - applyAwarenessUpdateSpy.mockRestore(); - syncProtocolUpdateSpy.mockRestore(); - }); - - it('should do nothing when received message unknown type', async () => { - const { sendSpy, applyAwarenessUpdateSpy, syncProtocolUpdateSpy, doc, msg } = await setup([2]); - - service.messageHandler(wsGlobal, doc, msg); - - expect(sendSpy).toHaveBeenCalledTimes(0); - expect(applyAwarenessUpdateSpy).toHaveBeenCalledTimes(0); - wsGlobal.close(); - sendSpy.mockRestore(); - applyAwarenessUpdateSpy.mockRestore(); - syncProtocolUpdateSpy.mockRestore(); - }); - }); - - describe('when publishing AWARENESS has errors', () => { - const setup = async (messageValues: number[]) => { - wsGlobal = await TestConnection.setupWs(wsUrl, 'TEST'); - - const publishSpy = jest - .spyOn(Ioredis.Redis.prototype, 'publish') - .mockImplementationOnce((_channel, _message, cb) => { - if (cb) { - cb(new Error('error')); - } - return Promise.resolve(0); - }); - const sendSpy = jest.spyOn(service, 'send'); - const applyAwarenessUpdateSpy = jest.spyOn(AwarenessProtocol, 'applyAwarenessUpdate'); - const syncProtocolUpdateSpy = jest - .spyOn(SyncProtocols, 'readSyncMessage') - .mockImplementationOnce((_dec, enc) => { - enc.bufs = [new Uint8Array(2), new Uint8Array(2)]; - return 1; - }); - const doc = new WsSharedDocDo('TEST'); - const { msg } = createMessage(messageValues); - - return { - sendSpy, - publishSpy, - applyAwarenessUpdateSpy, - syncProtocolUpdateSpy, - doc, - msg, - }; - }; - - it('should log error', async () => { - const { publishSpy, sendSpy, applyAwarenessUpdateSpy, syncProtocolUpdateSpy, doc, msg } = await setup([ - 1, 1, 0, - ]); - - service.messageHandler(wsGlobal, doc, msg); - - expect(sendSpy).not.toHaveBeenCalled(); - // expect(domainErrorHandler.exec).toHaveBeenCalledTimes(1); - wsGlobal.close(); - sendSpy.mockRestore(); - applyAwarenessUpdateSpy.mockRestore(); - syncProtocolUpdateSpy.mockRestore(); - publishSpy.mockRestore(); - }); - }); - - describe('when error is thrown during receiving message', () => { - const setup = async () => { - wsGlobal = await TestConnection.setupWs(wsUrl); - - const sendSpy = jest.spyOn(service, 'send'); - jest.spyOn(SyncProtocols, 'readSyncMessage').mockImplementationOnce(() => { - throw new Error('error'); - }); - const doc = new WsSharedDocDo('TEST'); - const { msg } = createMessage([0]); - - return { - sendSpy, - doc, - msg, - }; - }; - - it('should not call send method', async () => { - const { sendSpy, doc, msg } = await setup(); - - expect(() => service.messageHandler(wsGlobal, doc, msg)).toThrow('error'); - - expect(sendSpy).toHaveBeenCalledTimes(0); - wsGlobal.close(); - sendSpy.mockRestore(); - }); - }); - - describe('when awareness states (clients) size is greater then one', () => { - const setup = async () => { - wsGlobal = await TestConnection.setupWs(wsUrl, 'TEST'); - - const doc = new WsSharedDocDo('TEST'); - doc.awareness.states = new Map(); - doc.awareness.states.set(1, ['test1']); - doc.awareness.states.set(2, ['test2']); - - const messageHandlerSpy = jest.spyOn(service, 'messageHandler').mockReturnValueOnce(); - const sendSpy = jest.spyOn(service, 'send').mockImplementation(() => {}); - const getYDocSpy = jest.spyOn(service, 'getDocument').mockResolvedValueOnce(doc); - const closeConnSpy = jest.spyOn(service, 'closeConnection').mockResolvedValue(); - const { msg } = createMessage([0]); - jest.spyOn(AwarenessProtocol, 'encodeAwarenessUpdate').mockReturnValueOnce(msg); - - return { - messageHandlerSpy, - sendSpy, - getYDocSpy, - closeConnSpy, - }; - }; - - it('should send to every client', async () => { - const { messageHandlerSpy, sendSpy, getYDocSpy, closeConnSpy } = await setup(); - - await expect(service.setupWsConnection(wsGlobal, 'TEST')).resolves.toBeUndefined(); - wsGlobal.emit('pong'); - - expect(sendSpy).toHaveBeenCalledTimes(3); // unlcear why it is called 3 times - wsGlobal.close(); - messageHandlerSpy.mockRestore(); - sendSpy.mockRestore(); - getYDocSpy.mockRestore(); - closeConnSpy.mockRestore(); - }); - }); - }); - - describe('on websocket error', () => { - const setup = async () => { - boardRepo.getDocumentFromDb.mockResolvedValueOnce(new WsSharedDocDo('TEST')); - wsGlobal = await TestConnection.setupWs(wsUrl, 'TEST'); - }; - - it('should log error', async () => { - await setup(); - await service.setupWsConnection(wsGlobal, 'TEST'); - wsGlobal.emit('error', new Error('error')); - - // expect(domainErrorHandler.exec).toHaveBeenCalledTimes(2); - wsGlobal.close(); - }); - }); - - describe('closeConn', () => { - describe('when there is no error', () => { - const setup = async () => { - boardRepo.getDocumentFromDb.mockResolvedValueOnce(new WsSharedDocDo('TEST')); - wsGlobal = await TestConnection.setupWs(wsUrl); - - const redisUnsubscribeSpy = jest.spyOn(Ioredis.Redis.prototype, 'unsubscribe').mockResolvedValueOnce(1); - const closeConnSpy = jest.spyOn(service, 'closeConnection'); - jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce({}); - - return { - redisUnsubscribeSpy, - closeConnSpy, - }; - }; - - it('should close connection', async () => { - const { redisUnsubscribeSpy, closeConnSpy } = await setup(); - - await service.setupWsConnection(wsGlobal, 'TEST'); - - expect(closeConnSpy).toHaveBeenCalled(); - wsGlobal.close(); - closeConnSpy.mockRestore(); - redisUnsubscribeSpy.mockRestore(); - }); - }); - - describe('when there are active connections', () => { - const setup = async () => { - const doc = new WsSharedDocDo('TEST'); - wsGlobal = await TestConnection.setupWs(wsUrl); - const ws2 = await TestConnection.setupWs(wsUrl); - doc.connections.set(wsGlobal, new Set()); - doc.connections.set(ws2, new Set()); - boardRepo.compressDocument.mockRestore(); - - return { - doc, - }; - }; - - it('should not call compressDocument', async () => { - const { doc } = await setup(); - - await service.closeConnection(doc, wsGlobal); - - expect(boardRepo.compressDocument).not.toHaveBeenCalled(); - wsGlobal.close(); - }); - }); - - describe('when close connection fails', () => { - const setup = async () => { - boardRepo.getDocumentFromDb.mockResolvedValueOnce(new WsSharedDocDo('TEST')); - wsGlobal = await TestConnection.setupWs(wsUrl); - - boardRepo.compressDocument.mockResolvedValueOnce(); - const redisUnsubscribeSpy = jest.spyOn(Ioredis.Redis.prototype, 'unsubscribe').mockResolvedValueOnce(1); - const closeConnSpy = jest.spyOn(service, 'closeConnection').mockRejectedValueOnce(new Error('error')); - const sendSpyError = jest.spyOn(service, 'send').mockReturnValue(); - jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce({}); - - return { - redisUnsubscribeSpy, - closeConnSpy, - sendSpyError, - }; - }; - - it('should log error', async () => { - const { redisUnsubscribeSpy, closeConnSpy, sendSpyError } = await setup(); - - await service.setupWsConnection(wsGlobal, 'TEST'); - - await delay(100); - - expect(closeConnSpy).toHaveBeenCalled(); - - wsGlobal.close(); - await delay(100); - // expect(domainErrorHandler.exec).toHaveBeenCalledTimes(3); - redisUnsubscribeSpy.mockRestore(); - closeConnSpy.mockRestore(); - sendSpyError.mockRestore(); - }); - }); - - describe('when unsubscribing from Redis throw error', () => { - const setup = async () => { - wsGlobal = await TestConnection.setupWs(wsUrl); - const doc = TldrawWsFactory.createWsSharedDocDo(); - doc.connections.set(wsGlobal, new Set()); - - boardRepo.compressDocument.mockResolvedValueOnce(); - const redisUnsubscribeSpy = jest - .spyOn(Ioredis.Redis.prototype, 'unsubscribe') - .mockRejectedValue(new Error('error')); - const closeConnSpy = jest.spyOn(service, 'closeConnection'); - jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce({}); - - return { - doc, - redisUnsubscribeSpy, - closeConnSpy, - }; - }; - - it('should log error', async () => { - const { doc, redisUnsubscribeSpy, closeConnSpy } = await setup(); - - await service.closeConnection(doc, wsGlobal); - await delay(200); - - expect(redisUnsubscribeSpy).toHaveBeenCalled(); - expect(closeConnSpy).toHaveBeenCalled(); - // expect(domainErrorHandler.exec).toHaveBeenCalledTimes(3); - closeConnSpy.mockRestore(); - redisUnsubscribeSpy.mockRestore(); - }); - }); - - describe('when pong not received', () => { - const setup = async () => { - boardRepo.getDocumentFromDb.mockResolvedValueOnce(new WsSharedDocDo('TEST')); - wsGlobal = await TestConnection.setupWs(wsUrl, 'TEST'); - - const messageHandlerSpy = jest.spyOn(service, 'messageHandler').mockReturnValueOnce(); - const closeConnSpy = jest.spyOn(service, 'closeConnection').mockImplementation(() => Promise.resolve()); - const pingSpy = jest.spyOn(wsGlobal, 'ping').mockImplementationOnce(() => {}); - const sendSpy = jest.spyOn(service, 'send').mockImplementation(() => {}); - const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); - jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce({}); - - return { - messageHandlerSpy, - closeConnSpy, - pingSpy, - sendSpy, - clearIntervalSpy, - }; - }; - - it('should close connection', async () => { - const { messageHandlerSpy, closeConnSpy, pingSpy, sendSpy, clearIntervalSpy } = await setup(); - - await service.setupWsConnection(wsGlobal, 'TEST'); - - await delay(200); - - expect(closeConnSpy).toHaveBeenCalled(); - expect(clearIntervalSpy).toHaveBeenCalled(); - wsGlobal.close(); - messageHandlerSpy.mockRestore(); - pingSpy.mockRestore(); - closeConnSpy.mockRestore(); - sendSpy.mockRestore(); - clearIntervalSpy.mockRestore(); - }); - }); - - describe('when pong not received and close connection fails', () => { - const setup = async () => { - boardRepo.getDocumentFromDb.mockResolvedValueOnce(new WsSharedDocDo('TEST')); - wsGlobal = await TestConnection.setupWs(wsUrl, 'TEST'); - - const messageHandlerSpy = jest.spyOn(service, 'messageHandler').mockReturnValueOnce(); - const closeConnSpy = jest.spyOn(service, 'closeConnection').mockRejectedValue(new Error('error')); - const pingSpy = jest.spyOn(wsGlobal, 'ping').mockImplementation(() => {}); - const sendSpy = jest.spyOn(service, 'send').mockImplementation(() => {}); - const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); - // expect(domainErrorHandler.exec).toHaveBeenCalledTimes(1); - jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce({}); - - return { - messageHandlerSpy, - closeConnSpy, - pingSpy, - sendSpy, - clearIntervalSpy, - }; - }; - - it('should log error', async () => { - const { messageHandlerSpy, closeConnSpy, pingSpy, sendSpy, clearIntervalSpy } = await setup(); - - await service.setupWsConnection(wsGlobal, 'TEST'); - - await delay(200); - - expect(closeConnSpy).toHaveBeenCalled(); - expect(clearIntervalSpy).toHaveBeenCalled(); - // expect(domainErrorHandler.exec).toHaveBeenCalledTimes(4); - wsGlobal.close(); - messageHandlerSpy.mockRestore(); - pingSpy.mockRestore(); - closeConnSpy.mockRestore(); - sendSpy.mockRestore(); - clearIntervalSpy.mockRestore(); - }); - }); - - describe('when compressDocument failed', () => { - const setup = async () => { - wsGlobal = await TestConnection.setupWs(wsUrl, 'TEST'); - const doc = TldrawWsFactory.createWsSharedDocDo(); - doc.connections.set(wsGlobal, new Set()); - - boardRepo.compressDocument.mockRejectedValueOnce(new Error('error')); - - return { - doc, - }; - }; - - it('should log error', async () => { - const { doc } = await setup(); - - await service.closeConnection(doc, wsGlobal); - - expect(boardRepo.compressDocument).toHaveBeenCalled(); - // expect(domainErrorHandler.exec).toHaveBeenCalledTimes(2); - wsGlobal.close(); - }); - }); - }); - - describe('updateHandler', () => { - const setup = async () => { - wsGlobal = await TestConnection.setupWs(wsUrl); - - const sendSpy = jest.spyOn(service, 'send').mockReturnValueOnce(); - const publishSpy = jest.spyOn(Ioredis.Redis.prototype, 'publish').mockResolvedValueOnce(1); - - const doc = TldrawWsFactory.createWsSharedDocDo(); - const socketMock = TldrawWsFactory.createWebsocket(WebSocketReadyStateEnum.OPEN); - doc.connections.set(socketMock, new Set()); - const msg = new Uint8Array([0]); - - return { - doc, - sendSpy, - socketMock, - msg, - publishSpy, - }; - }; - - it('should call send method', async () => { - const { sendSpy, doc, socketMock, msg } = await setup(); - - service.updateHandler(msg, socketMock, doc); - - expect(sendSpy).toHaveBeenCalled(); - wsGlobal.close(); - }); - }); - - describe('databaseUpdateHandler', () => { - const setup = async () => { - wsGlobal = await TestConnection.setupWs(wsUrl); - boardRepo.storeUpdate.mockResolvedValueOnce(); - }; - - it('should call storeUpdate method', async () => { - await setup(); - - await service.databaseUpdateHandler('test', new Uint8Array(), 'test'); - - expect(boardRepo.storeUpdate).toHaveBeenCalled(); - wsGlobal.close(); - }); - - it('should not call storeUpdate when origin is redis', async () => { - await setup(); - - await service.databaseUpdateHandler('test', new Uint8Array(), 'redis'); - - expect(boardRepo.storeUpdate).not.toHaveBeenCalled(); - wsGlobal.close(); - }); - }); - - describe('when publish to Redis throws errors', () => { - const setup = async () => { - wsGlobal = await TestConnection.setupWs(wsUrl); - - const sendSpy = jest.spyOn(service, 'send').mockReturnValueOnce(); - const publishSpy = jest.spyOn(Ioredis.Redis.prototype, 'publish').mockRejectedValueOnce(new Error('error')); - - const doc = TldrawWsFactory.createWsSharedDocDo(); - doc.connections.set(wsGlobal, new Set()); - const msg = new Uint8Array([0]); - - return { - doc, - sendSpy, - msg, - publishSpy, - }; - }; - - it('should log error', async () => { - const { doc, msg, publishSpy } = await setup(); - - service.updateHandler(msg, wsGlobal, doc); - - await delay(200); - - // expect(domainErrorHandler.exec).toHaveBeenCalledTimes(3); - wsGlobal.close(); - publishSpy.mockRestore(); - }); - }); - - describe('messageHandler', () => { - describe('when message is received', () => { - const setup = async (messageValues: number[]) => { - boardRepo.getDocumentFromDb.mockResolvedValueOnce(new WsSharedDocDo('TEST')); - wsGlobal = await TestConnection.setupWs(wsUrl, 'TEST'); - - const messageHandlerSpy = jest.spyOn(service, 'messageHandler'); - const readSyncMessageSpy = jest.spyOn(SyncProtocols, 'readSyncMessage').mockImplementationOnce((_dec, enc) => { - enc.bufs = [new Uint8Array(2), new Uint8Array(2)]; - return 1; - }); - const publishSpy = jest.spyOn(Ioredis.Redis.prototype, 'publish'); - jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce({}); - const { msg } = createMessage(messageValues); - - return { - msg, - messageHandlerSpy, - readSyncMessageSpy, - publishSpy, - }; - }; - - it('should handle message', async () => { - const { messageHandlerSpy, msg, readSyncMessageSpy, publishSpy } = await setup([0, 1]); - publishSpy.mockResolvedValueOnce(1); - - await service.setupWsConnection(wsGlobal, 'TEST'); - wsGlobal.emit('message', msg); - - await delay(200); - - expect(messageHandlerSpy).toHaveBeenCalledTimes(1); - wsGlobal.close(); - messageHandlerSpy.mockRestore(); - readSyncMessageSpy.mockRestore(); - publishSpy.mockRestore(); - }); - - it('should log error when messageHandler throws', async () => { - const { messageHandlerSpy, msg } = await setup([0, 1]); - messageHandlerSpy.mockImplementationOnce(() => { - throw new Error('error'); - }); - - await service.setupWsConnection(wsGlobal, 'TEST'); - wsGlobal.emit('message', msg); - - await delay(200); - - // expect(domainErrorHandler.exec).toHaveBeenCalledTimes(4); - wsGlobal.close(); - messageHandlerSpy.mockRestore(); - }); - - it('should log error when publish to Redis throws', async () => { - const { publishSpy } = await setup([1, 1]); - publishSpy.mockRejectedValueOnce(new Error('error')); - - await service.setupWsConnection(wsGlobal, 'TEST'); - - // expect(domainErrorHandler.exec).toHaveBeenCalledTimes(1); - wsGlobal.close(); - }); - }); - }); - - describe('getDocument', () => { - describe('when getting yDoc by name', () => { - it('should assign to service docs map and return instance', async () => { - boardRepo.getDocumentFromDb.mockResolvedValueOnce(new WsSharedDocDo('get-test')); - jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce({}); - const docName = 'get-test'; - const doc = await service.getDocument(docName); - - expect(doc).toBeInstanceOf(WsSharedDocDo); - expect(service.docs.get(docName)).not.toBeUndefined(); - }); - - describe('when subscribing to redis channel', () => { - const setup = () => { - boardRepo.getDocumentFromDb.mockResolvedValueOnce(new WsSharedDocDo('test-redis')); - const doc = new WsSharedDocDo('test-redis'); - - const redisSubscribeSpy = jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce(1); - const redisOnSpy = jest.spyOn(Ioredis.Redis.prototype, 'on'); - boardRepo.getDocumentFromDb.mockResolvedValueOnce(doc); - - return { - redisOnSpy, - redisSubscribeSpy, - }; - }; - - it('should subscribe', async () => { - const { redisOnSpy, redisSubscribeSpy } = setup(); - - const doc = await service.getDocument('test-redis'); - - expect(doc).toBeDefined(); - expect(redisSubscribeSpy).toHaveBeenCalled(); - redisSubscribeSpy.mockRestore(); - redisOnSpy.mockRestore(); - }); - }); - }); - - describe('when subscribing to redis channel throws error', () => { - const setup = () => { - boardRepo.getDocumentFromDb.mockResolvedValueOnce(new WsSharedDocDo('test-redis-fail-2')); - const redisSubscribeSpy = jest - .spyOn(Ioredis.Redis.prototype, 'subscribe') - .mockRejectedValue(new Error('error')); - const redisOnSpy = jest.spyOn(Ioredis.Redis.prototype, 'on'); - - return { - redisOnSpy, - redisSubscribeSpy, - }; - }; - - it('should log error', async () => { - const { redisSubscribeSpy, redisOnSpy } = setup(); - - await service.getDocument('test-redis-fail-2'); - - await delay(500); - - expect(redisSubscribeSpy).toHaveBeenCalled(); - // expect(domainErrorHandler.exec).toHaveBeenCalledTimes(3); - redisSubscribeSpy.mockRestore(); - redisOnSpy.mockRestore(); - }); - }); - - describe('when found document is still finalizing', () => { - const setup = () => { - const doc = new WsSharedDocDo('test-finalizing'); - doc.isFinalizing = true; - service.docs.set('test-finalizing', doc); - boardRepo.getDocumentFromDb.mockResolvedValueOnce(doc); - }; - - it('should throw', async () => { - setup(); - - await expect(service.getDocument('test-finalizing')).rejects.toThrow(); - service.docs.delete('test-finalizing'); - }); - }); - }); - - describe('redisMessageHandler', () => { - const setup = () => { - const applyUpdateSpy = jest.spyOn(Yjs, 'applyUpdate').mockReturnValueOnce(); - const applyAwarenessUpdateSpy = jest.spyOn(AwarenessProtocol, 'applyAwarenessUpdate').mockReturnValueOnce(); - - const doc = new WsSharedDocDo('TEST'); - doc.awarenessChannel = 'TEST-awareness'; - - return { - doc, - applyUpdateSpy, - applyAwarenessUpdateSpy, - }; - }; - - describe('when channel name is the same as docName', () => { - it('should call applyUpdate', () => { - const { doc, applyUpdateSpy } = setup(); - service.docs.set('TEST', doc); - service.redisMessageHandler(Buffer.from('TEST'), Buffer.from('message')); - - expect(applyUpdateSpy).toHaveBeenCalled(); - }); - }); - - describe('when channel name is the same as docAwarenessChannel name', () => { - it('should call applyAwarenessUpdate', () => { - const { doc, applyAwarenessUpdateSpy } = setup(); - service.docs.set('TEST', doc); - service.redisMessageHandler(Buffer.from('TEST-awareness'), Buffer.from('message')); - - expect(applyAwarenessUpdateSpy).toHaveBeenCalled(); - }); - }); - - describe('when channel name is not found as document name', () => { - it('should not call applyUpdate or applyAwarenessUpdate', () => { - const { doc, applyUpdateSpy, applyAwarenessUpdateSpy } = setup(); - service.docs.set('TEST', doc); - service.redisMessageHandler(Buffer.from('NOTFOUND'), Buffer.from('message')); - - expect(applyUpdateSpy).not.toHaveBeenCalled(); - expect(applyAwarenessUpdateSpy).not.toHaveBeenCalled(); - }); - }); - }); - - describe('updateHandler', () => { - describe('when update comes from connected websocket', () => { - const setup = async () => { - wsGlobal = await TestConnection.setupWs(wsUrl, 'TEST'); - - const doc = new WsSharedDocDo('TEST'); - doc.connections.set(wsGlobal, new Set()); - const publishSpy = jest.spyOn(Ioredis.Redis.prototype, 'publish'); - - return { - doc, - publishSpy, - }; - }; - - it('should publish update to redis', async () => { - const { doc, publishSpy } = await setup(); - - service.updateHandler(new Uint8Array([]), wsGlobal, doc); - - expect(publishSpy).toHaveBeenCalled(); - wsGlobal.close(); - }); - - it('should log error on failed publish', async () => { - const { doc, publishSpy } = await setup(); - publishSpy.mockRejectedValueOnce(new Error('error')); - - service.updateHandler(new Uint8Array([]), wsGlobal, doc); - - // expect(domainErrorHandler.exec).toHaveBeenCalledTimes(2); - wsGlobal.close(); - }); - }); - }); - - describe('awarenessUpdateHandler', () => { - const setup = async () => { - wsGlobal = await TestConnection.setupWs(wsUrl); - - class MockAwareness { - on = jest.fn(); - } - - const doc = new WsSharedDocDo('TEST-AUH'); - doc.awareness = new MockAwareness() as unknown as AwarenessProtocol.Awareness; - const awarenessMetaMock = new Map(); - awarenessMetaMock.set(1, { clock: 11, lastUpdated: 21 }); - awarenessMetaMock.set(2, { clock: 12, lastUpdated: 22 }); - awarenessMetaMock.set(3, { clock: 13, lastUpdated: 23 }); - const awarenessStatesMock = new Map(); - awarenessStatesMock.set(1, { updating: '21' }); - awarenessStatesMock.set(2, { updating: '22' }); - awarenessStatesMock.set(3, { updating: '23' }); - doc.awareness.states = awarenessStatesMock; - doc.awareness.meta = awarenessMetaMock; - - const sendSpy = jest.spyOn(service, 'send').mockReturnValue(); - - const mockIDs = new Set(); - const mockConns = new Map>(); - mockConns.set(wsGlobal, mockIDs); - doc.connections = mockConns; - - return { - sendSpy, - doc, - mockIDs, - mockConns, - }; - }; - - describe('when adding two clients states', () => { - it('should have two registered clients states', async () => { - const { sendSpy, doc, mockIDs } = await setup(); - const awarenessUpdate = { - added: [1, 3], - updated: [], - removed: [], - }; - - service.awarenessUpdateHandler(awarenessUpdate, wsGlobal, doc); - - expect(mockIDs.size).toBe(2); - expect(mockIDs.has(1)).toBe(true); - expect(mockIDs.has(3)).toBe(true); - expect(mockIDs.has(2)).toBe(false); - expect(sendSpy).toBeCalled(); - wsGlobal.close(); - sendSpy.mockRestore(); - }); - }); - - describe('when removing one of two existing clients states', () => { - it('should have one registered client state', async () => { - const { sendSpy, doc, mockIDs } = await setup(); - let awarenessUpdate: { added: number[]; updated: number[]; removed: number[] } = { - added: [1, 3], - updated: [], - removed: [], - }; - - service.awarenessUpdateHandler(awarenessUpdate, wsGlobal, doc); - awarenessUpdate = { - added: [], - updated: [], - removed: [1], - }; - service.awarenessUpdateHandler(awarenessUpdate, wsGlobal, doc); - - expect(mockIDs.size).toBe(1); - expect(mockIDs.has(1)).toBe(false); - expect(mockIDs.has(3)).toBe(true); - expect(sendSpy).toBeCalled(); - wsGlobal.close(); - sendSpy.mockRestore(); - }); - }); - - describe('when updating client state', () => { - it('should not change number of states', async () => { - const { sendSpy, doc, mockIDs } = await setup(); - let awarenessUpdate: { added: number[]; updated: number[]; removed: number[] } = { - added: [1], - updated: [], - removed: [], - }; - - service.awarenessUpdateHandler(awarenessUpdate, wsGlobal, doc); - awarenessUpdate = { - added: [], - updated: [1], - removed: [], - }; - service.awarenessUpdateHandler(awarenessUpdate, wsGlobal, doc); - - expect(mockIDs.size).toBe(1); - expect(mockIDs.has(1)).toBe(true); - expect(sendSpy).toBeCalled(); - - wsGlobal.close(); - sendSpy.mockRestore(); - }); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts b/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts deleted file mode 100644 index 1fadacf0bfd..00000000000 --- a/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { Injectable, NotAcceptableException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { DomainErrorHandler } from '@src/core'; -import { decoding, encoding } from 'lib0'; -import { Buffer } from 'node:buffer'; -import WebSocket from 'ws'; -import { encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'; -import { readSyncMessage, writeSyncStep1, writeSyncStep2, writeUpdate } from 'y-protocols/sync'; -import { TldrawConfig } from '../config'; -import { WsSharedDocDo } from '../domain'; -import { - CloseConnectionLoggable, - WebsocketErrorLoggable, - WebsocketMessageErrorLoggable, - WsSharedDocErrorLoggable, -} from '../loggable'; -import { MetricsService } from '../metrics'; -import { TldrawRedisService } from '../redis'; -import { TldrawBoardRepo } from '../repo'; -import { AwarenessConnectionsUpdate, UpdateOrigin, UpdateType, WSMessageType } from '../types'; - -@Injectable() -export class TldrawWsService { - public docs = new Map(); - - constructor( - private readonly configService: ConfigService, - private readonly tldrawBoardRepo: TldrawBoardRepo, - private readonly domainErrorHandler: DomainErrorHandler, - private readonly metricsService: MetricsService, - private readonly tldrawRedisService: TldrawRedisService - ) { - this.tldrawRedisService.sub.on('messageBuffer', (channel, message) => this.redisMessageHandler(channel, message)); - } - - public async closeConnection(doc: WsSharedDocDo, ws: WebSocket): Promise { - performance.mark('closeConnection'); - - if (doc.connections.has(ws)) { - const controlledIds = doc.connections.get(ws); - doc.connections.delete(ws); - removeAwarenessStates(doc.awareness, this.forceToArray(controlledIds), null); - - this.metricsService.decrementNumberOfUsersOnServerCounter(); - } - - ws.close(); - await this.finalizeIfNoConnections(doc); - - performance.measure('tldraw:TldrawWsService:closeConnection', { - start: 'closeConnection', - detail: { doc_name: doc.name, doc_connection_total: doc.connections.size }, - }); - } - - public send(doc: WsSharedDocDo, ws: WebSocket, message: Uint8Array): void { - if (this.isClosedOrClosing(ws)) { - this.closeConnection(doc, ws).catch((err) => { - this.domainErrorHandler.exec(new CloseConnectionLoggable('send | isClosedOrClosing', err)); - }); - } else { - ws.send(message, (err) => { - if (err) { - this.closeConnection(doc, ws).catch((e) => { - this.domainErrorHandler.exec(new CloseConnectionLoggable('send', e)); - }); - } - }); - } - } - - public updateHandler(update: Uint8Array, origin, doc: WsSharedDocDo): void { - if (this.isFromConnectedWebSocket(doc, origin)) { - this.tldrawRedisService.publishUpdateToRedis(doc, update, UpdateType.DOCUMENT); - } - - this.sendUpdateToConnectedClients(update, doc); - } - - public async databaseUpdateHandler(docName: string, update: Uint8Array, origin) { - if (this.isFromRedis(origin)) { - return; - } - await this.tldrawBoardRepo.storeUpdate(docName, update); - } - - public awarenessUpdateHandler = ( - connectionsUpdate: AwarenessConnectionsUpdate, - wsConnection: WebSocket | null, - doc: WsSharedDocDo - ): void => { - const changedClients = this.manageClientsConnections(connectionsUpdate, wsConnection, doc); - const buff = this.prepareAwarenessMessage(changedClients, doc); - this.sendAwarenessMessage(buff, doc); - }; - - // this is a private method, need to be changed - public async getDocument(docName: string) { - const existingDoc = this.docs.get(docName); - - if (this.isFinalizingOrNotYetLoaded(existingDoc)) { - // drop the connection, the client will have to reconnect - // and check again if the finalizing or loading has finished - throw new NotAcceptableException(); - } - - if (existingDoc) { - return existingDoc; - } - - // doc can be null, need to be handled - const doc = await this.tldrawBoardRepo.getDocumentFromDb(docName); - doc.isLoaded = false; - - this.registerAwarenessUpdateHandler(doc); - this.registerUpdateHandler(doc); - this.tldrawRedisService.subscribeToRedisChannels(doc); - this.registerDatabaseUpdateHandler(doc); - - this.docs.set(docName, doc); - this.metricsService.incrementNumberOfBoardsOnServerCounter(); - doc.isLoaded = true; - return doc; - } - - public async createDbIndex(): Promise { - await this.tldrawBoardRepo.createDbIndex(); - } - - public messageHandler(ws: WebSocket, doc: WsSharedDocDo, message: Uint8Array): void { - const encoder = encoding.createEncoder(); - const decoder = decoding.createDecoder(message); - const messageType = decoding.readVarUint(decoder); - switch (messageType) { - case WSMessageType.SYNC: - this.handleSyncMessage(doc, encoder, decoder, ws); - break; - case WSMessageType.AWARENESS: { - this.handleAwarenessMessage(doc, decoder); - break; - } - default: - break; - } - } - - private handleSyncMessage( - doc: WsSharedDocDo, - encoder: encoding.Encoder, - decoder: decoding.Decoder, - ws: WebSocket - ): void { - encoding.writeVarUint(encoder, WSMessageType.SYNC); - readSyncMessage(decoder, encoder, doc, ws); - - // If the `encoder` only contains the type of reply message and no - // message, there is no need to send the message. When `encoder` only - // contains the type of reply, its length is 1. - if (encoding.length(encoder) > 1) { - this.send(doc, ws, encoding.toUint8Array(encoder)); - } - } - - private handleAwarenessMessage(doc: WsSharedDocDo, decoder: decoding.Decoder) { - const update = decoding.readVarUint8Array(decoder); - this.tldrawRedisService.publishUpdateToRedis(doc, update, UpdateType.AWARENESS); - } - - public redisMessageHandler = (channel: Buffer, update: Buffer): void => { - const channelId = channel.toString(); - const docName = channel.toString().split('-')[0]; - const doc = this.docs.get(docName); - if (!doc) { - return; - } - - this.tldrawRedisService.handleMessage(channelId, update, doc); - }; - - public async setupWsConnection(ws: WebSocket, docName: string): Promise { - performance.mark('setupWsConnection'); - - ws.binaryType = 'arraybuffer'; - - // get doc, initialize if it does not exist yet - update this.getDocument(docName) can be return null - const doc = await this.getDocument(docName); - doc.connections.set(ws, new Set()); - - ws.on('error', (err) => { - this.domainErrorHandler.exec(new WebsocketErrorLoggable(err)); - }); - - ws.on('message', (message: ArrayBufferLike) => { - try { - this.messageHandler(ws, doc, new Uint8Array(message)); - } catch (err) { - this.domainErrorHandler.exec(new WebsocketMessageErrorLoggable(err)); - } - }); - - // check if connection is still alive - const pingTimeout = this.configService.get('TLDRAW_PING_TIMEOUT'); - let pongReceived = true; - const pingInterval = setInterval(() => { - if (pongReceived && doc.connections.has(ws)) { - pongReceived = false; - ws.ping(); - return; - } - - this.closeConnection(doc, ws).catch((err) => { - this.domainErrorHandler.exec(new CloseConnectionLoggable('pingInterval', err)); - }); - clearInterval(pingInterval); - }, pingTimeout); - - ws.on('close', () => { - this.closeConnection(doc, ws).catch((err) => { - this.domainErrorHandler.exec(new CloseConnectionLoggable('websocket close', err)); - }); - clearInterval(pingInterval); - }); - - ws.on('pong', () => { - pongReceived = true; - }); - - // send initial doc state to client as update - this.sendInitialState(ws, doc); - - const syncEncoder = encoding.createEncoder(); - encoding.writeVarUint(syncEncoder, WSMessageType.SYNC); - writeSyncStep1(syncEncoder, doc); - this.send(doc, ws, encoding.toUint8Array(syncEncoder)); - - const awarenessStates = doc.awareness.getStates(); - if (awarenessStates.size > 0) { - const awarenessEncoder = encoding.createEncoder(); - encoding.writeVarUint(awarenessEncoder, WSMessageType.AWARENESS); - encoding.writeVarUint8Array( - awarenessEncoder, - encodeAwarenessUpdate(doc.awareness, Array.from(awarenessStates.keys())) - ); - this.send(doc, ws, encoding.toUint8Array(awarenessEncoder)); - } - - this.metricsService.incrementNumberOfUsersOnServerCounter(); - - performance.measure('tldraw:TldrawWsService:setupWsConnection', { - start: 'setupWsConnection', - detail: { - doc_name: doc.name, - doc_awareness_state_total: awarenessStates.size, - doc_connection_total: doc.connections.size, - pod_docs_total: this.docs.size, - }, - }); - } - - private async finalizeIfNoConnections(doc: WsSharedDocDo) { - // wait before doing the check - // the only user on the pod might have lost connection for a moment - // or simply refreshed the page - await this.delay(this.configService.get('TLDRAW_FINALIZE_DELAY')); - - if (doc.connections.size > 0) { - return; - } - - if (doc.isFinalizing) { - return; - } - doc.isFinalizing = true; - - try { - this.tldrawRedisService.unsubscribeFromRedisChannels(doc); - await this.tldrawBoardRepo.compressDocument(doc.name); - } catch (err) { - this.domainErrorHandler.exec(new WsSharedDocErrorLoggable(doc.name, 'Error while finalizing document', err)); - } finally { - doc.destroy(); - this.docs.delete(doc.name); - this.metricsService.decrementNumberOfBoardsOnServerCounter(); - } - } - - private sendUpdateToConnectedClients(update: Uint8Array, doc: WsSharedDocDo): void { - const encoder = encoding.createEncoder(); - encoding.writeVarUint(encoder, WSMessageType.SYNC); - writeUpdate(encoder, update); - const message = encoding.toUint8Array(encoder); - - for (const [conn] of doc.connections) { - this.send(doc, conn, message); - } - } - - private prepareAwarenessMessage(changedClients: number[], doc: WsSharedDocDo): Uint8Array { - const encoder = encoding.createEncoder(); - encoding.writeVarUint(encoder, WSMessageType.AWARENESS); - encoding.writeVarUint8Array(encoder, encodeAwarenessUpdate(doc.awareness, changedClients)); - const message = encoding.toUint8Array(encoder); - return message; - } - - private sendAwarenessMessage(message: Uint8Array, doc: WsSharedDocDo): void { - for (const [conn] of doc.connections) { - this.send(doc, conn, message); - } - } - - private manageClientsConnections( - connectionsUpdate: AwarenessConnectionsUpdate, - ws: WebSocket | null, - doc: WsSharedDocDo - ): number[] { - const changedClients = connectionsUpdate.added.concat(connectionsUpdate.updated, connectionsUpdate.removed); - if (ws !== null) { - const connControlledIDs = doc.connections.get(ws); - if (connControlledIDs !== undefined) { - for (const clientID of connectionsUpdate.added) { - connControlledIDs.add(clientID); - } - - for (const clientID of connectionsUpdate.removed) { - connControlledIDs.delete(clientID); - } - } - } - - return changedClients; - } - - private registerAwarenessUpdateHandler(doc: WsSharedDocDo) { - doc.awareness.on('update', (connectionsUpdate: AwarenessConnectionsUpdate, wsConnection: WebSocket | null) => - this.awarenessUpdateHandler(connectionsUpdate, wsConnection, doc) - ); - } - - private registerUpdateHandler(doc: WsSharedDocDo) { - doc.on('update', (update: Uint8Array, origin) => this.updateHandler(update, origin, doc)); - } - - private registerDatabaseUpdateHandler(doc: WsSharedDocDo) { - doc.on('update', (update: Uint8Array, origin) => this.databaseUpdateHandler(doc.name, update, origin)); - } - - private sendInitialState(ws: WebSocket, doc: WsSharedDocDo): void { - const encoder = encoding.createEncoder(); - encoding.writeVarUint(encoder, WSMessageType.SYNC); - writeSyncStep2(encoder, doc); - this.send(doc, ws, encoding.toUint8Array(encoder)); - } - - private isFinalizingOrNotYetLoaded(doc: WsSharedDocDo | undefined): boolean { - const isFinalizing = doc !== undefined && doc.isFinalizing; - const isNotLoaded = doc !== undefined && !doc.isLoaded; - return isFinalizing || isNotLoaded; - } - - private delay(ms: number) { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); - } - - private isClosedOrClosing(ws: WebSocket): boolean { - return ws.readyState === WebSocket.CLOSING || ws.readyState === WebSocket.CLOSED; - } - - private forceToArray(connections: Set | undefined): number[] { - return connections ? Array.from(connections) : []; - } - - private isFromConnectedWebSocket(doc: WsSharedDocDo, origin: unknown) { - return origin instanceof WebSocket && doc.connections.has(origin); - } - - private isFromRedis(origin: unknown): boolean { - return typeof origin === 'string' && origin === UpdateOrigin.REDIS; - } -} diff --git a/apps/server/src/modules/tldraw/testing/index.ts b/apps/server/src/modules/tldraw/testing/index.ts deleted file mode 100644 index e240b1fb117..00000000000 --- a/apps/server/src/modules/tldraw/testing/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './tldraw.factory'; -export * from './test-connection'; -export * from './testConfig'; -export * from './tldraw-asset.factory'; -export * from './tldraw-shape.factory'; diff --git a/apps/server/src/modules/tldraw/testing/test-connection.ts b/apps/server/src/modules/tldraw/testing/test-connection.ts deleted file mode 100644 index 248d8144b7a..00000000000 --- a/apps/server/src/modules/tldraw/testing/test-connection.ts +++ /dev/null @@ -1,23 +0,0 @@ -import WebSocket from 'ws'; -import { HttpHeaders } from 'aws-sdk/clients/iot'; - -export class TestConnection { - public static getWsUrl = (gatewayPort: number): string => { - const wsUrl = `ws://localhost:${gatewayPort}`; - return wsUrl; - }; - - public static setupWs = async (wsUrl: string, docName?: string, headers?: HttpHeaders): Promise => { - let ws: WebSocket; - if (docName) { - ws = new WebSocket(`${wsUrl}/${docName}`, { headers }); - } else { - ws = new WebSocket(`${wsUrl}`, { headers }); - } - await new Promise((resolve) => { - ws.on('open', resolve); - }); - - return ws; - }; -} diff --git a/apps/server/src/modules/tldraw/testing/testConfig.ts b/apps/server/src/modules/tldraw/testing/testConfig.ts deleted file mode 100644 index 61f244d1a27..00000000000 --- a/apps/server/src/modules/tldraw/testing/testConfig.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { config } from '../config'; - -export const tldrawTestConfig = () => { - const conf = config(); - if (!conf.REDIS_URI) { - conf.REDIS_URI = 'redis://127.0.0.1:6379'; - } - conf.TLDRAW_DB_COMPRESS_THRESHOLD = 2; - conf.TLDRAW_PING_TIMEOUT = 0; - conf.TLDRAW_FINALIZE_DELAY = 0; - conf.TLDRAW_MAX_DOCUMENT_SIZE = 1; - return conf; -}; diff --git a/apps/server/src/modules/tldraw/testing/tldraw-asset.factory.ts b/apps/server/src/modules/tldraw/testing/tldraw-asset.factory.ts deleted file mode 100644 index 9791d5f5155..00000000000 --- a/apps/server/src/modules/tldraw/testing/tldraw-asset.factory.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Factory } from 'fishery'; -import { TldrawAsset, TldrawShapeType } from '../types'; - -export const tldrawAssetFactory = Factory.define(({ sequence }) => { - return { - id: `asset-${sequence}`, - type: TldrawShapeType.Image, - name: 'img.png', - src: `/filerecordid-${sequence}/file1.jpg`, - }; -}); diff --git a/apps/server/src/modules/tldraw/testing/tldraw-shape.factory.ts b/apps/server/src/modules/tldraw/testing/tldraw-shape.factory.ts deleted file mode 100644 index 368a6ca74a2..00000000000 --- a/apps/server/src/modules/tldraw/testing/tldraw-shape.factory.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Factory } from 'fishery'; -import { TldrawShape, TldrawShapeType } from '../types'; - -export const tldrawShapeFactory = Factory.define(({ sequence }) => { - return { - id: `shape-${sequence}`, - type: TldrawShapeType.Image, - assetId: `asset-${sequence}`, - }; -}); diff --git a/apps/server/src/modules/tldraw/testing/tldraw.factory.ts b/apps/server/src/modules/tldraw/testing/tldraw.factory.ts deleted file mode 100644 index 33d869f0017..00000000000 --- a/apps/server/src/modules/tldraw/testing/tldraw.factory.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { BaseFactory } from '@shared/testing/factory/base.factory'; -import { ObjectId } from '@mikro-orm/mongodb'; -import { TldrawDrawing, TldrawDrawingProps } from '../entities'; - -export const tldrawEntityFactory = BaseFactory.define( - TldrawDrawing, - ({ sequence }) => { - return { - id: new ObjectId().toHexString(), - docName: 'test-name', - value: Buffer.from('test'), - version: `v1`, - action: 'update', - clock: sequence, - part: sequence, - }; - } -); diff --git a/apps/server/src/modules/tldraw/tldraw-api-test.module.ts b/apps/server/src/modules/tldraw/tldraw-api-test.module.ts deleted file mode 100644 index 227e209ebbe..00000000000 --- a/apps/server/src/modules/tldraw/tldraw-api-test.module.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { MongoDatabaseModuleOptions, MongoMemoryDatabaseModule } from '@infra/database'; -import { HttpModule } from '@nestjs/axios'; -import { DynamicModule, Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { createConfigModuleOptions } from '@src/config'; -import { CoreModule } from '@src/core'; -import { LoggerModule } from '@src/core/logger'; -import { AuthGuardModule, AuthGuardOptions } from '@src/infra/auth-guard'; -import { config } from './config'; -import { TldrawController } from './controller'; -import { TldrawDrawing } from './entities'; -import { TldrawBoardRepo, TldrawRepo, YMongodb } from './repo'; -import { TldrawService } from './service'; - -const imports = [ - MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] }), - LoggerModule, - ConfigModule.forRoot(createConfigModuleOptions(config)), - HttpModule, - AuthGuardModule.register([AuthGuardOptions.X_API_KEY]), - CoreModule, -]; -const providers = [TldrawService, TldrawBoardRepo, TldrawRepo, YMongodb]; -@Module({ - imports, - providers, -}) -export class TldrawApiTestModule { - static forRoot(options?: MongoDatabaseModuleOptions): DynamicModule { - return { - module: TldrawApiTestModule, - imports: [...imports, MongoMemoryDatabaseModule.forRoot({ ...options })], - controllers: [TldrawController], - providers, - }; - } -} diff --git a/apps/server/src/modules/tldraw/tldraw-api.module.ts b/apps/server/src/modules/tldraw/tldraw-api.module.ts deleted file mode 100644 index 531fea643bb..00000000000 --- a/apps/server/src/modules/tldraw/tldraw-api.module.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { AuthGuardModule, AuthGuardOptions } from '@infra/auth-guard'; -import { MikroOrmModule } from '@mikro-orm/nestjs'; -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { createConfigModuleOptions, DB_PASSWORD, DB_USERNAME } from '@src/config'; -import { CoreModule } from '@src/core'; -import { LoggerModule } from '@src/core/logger'; -import { defaultMikroOrmOptions } from '@shared/common/defaultMikroOrmOptions'; -import { config, TLDRAW_DB_URL } from './config'; -import { TldrawController } from './controller'; -import { TldrawDrawing } from './entities'; -import { TldrawBoardRepo, TldrawRepo, YMongodb } from './repo'; -import { TldrawService } from './service'; - -@Module({ - imports: [ - LoggerModule, - CoreModule, - MikroOrmModule.forRoot({ - ...defaultMikroOrmOptions, - type: 'mongo', - clientUrl: TLDRAW_DB_URL, - password: DB_PASSWORD, - user: DB_USERNAME, - entities: [TldrawDrawing], - }), - ConfigModule.forRoot(createConfigModuleOptions(config)), - AuthGuardModule.register([AuthGuardOptions.X_API_KEY]), - ], - providers: [TldrawService, TldrawBoardRepo, TldrawRepo, YMongodb], - controllers: [TldrawController], -}) -export class TldrawApiModule {} diff --git a/apps/server/src/modules/tldraw/tldraw-console.module.ts b/apps/server/src/modules/tldraw/tldraw-console.module.ts deleted file mode 100644 index 596267bee22..00000000000 --- a/apps/server/src/modules/tldraw/tldraw-console.module.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { ConsoleWriterModule } from '@infra/console'; -import { RabbitMQWrapperModule } from '@infra/rabbitmq'; -import { S3ClientModule } from '@infra/s3-client'; -import { MikroOrmModule } from '@mikro-orm/nestjs'; -import { Module } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { initialisePerformanceObserver } from '@shared/common/measure-utils'; -import { createConfigModuleOptions, DB_PASSWORD, DB_USERNAME } from '@src/config'; -import { CoreModule } from '@src/core'; -import { Logger, LoggerModule } from '@src/core/logger'; -import { ConsoleModule } from 'nestjs-console'; -import { defaultMikroOrmOptions } from '@shared/common/defaultMikroOrmOptions'; -import { FilesStorageClientModule } from '../files-storage-client'; -import { config, TLDRAW_DB_URL, TldrawConfig, tldrawS3Config } from './config'; -import { TldrawDrawing } from './entities'; -import { TldrawFilesConsole, TldrawMigrationConsole } from './job'; -import { TldrawRepo, YMongodb } from './repo'; -import { TldrawFilesStorageAdapterService } from './service'; -import { TldrawDeleteFilesUc } from './uc'; - -@Module({ - imports: [ - S3ClientModule.register([tldrawS3Config]), - CoreModule, - ConsoleModule, - ConsoleWriterModule, - RabbitMQWrapperModule, - FilesStorageClientModule, - LoggerModule, - CoreModule, - MikroOrmModule.forRoot({ - ...defaultMikroOrmOptions, - type: 'mongo', - clientUrl: TLDRAW_DB_URL, - password: DB_PASSWORD, - user: DB_USERNAME, - entities: [TldrawDrawing], - }), - ConfigModule.forRoot(createConfigModuleOptions(config)), - ], - providers: [ - TldrawRepo, - YMongodb, - TldrawFilesConsole, - TldrawFilesStorageAdapterService, - TldrawDeleteFilesUc, - TldrawMigrationConsole, - ], -}) -export class TldrawConsoleModule { - constructor(private readonly logger: Logger, private readonly configService: ConfigService) { - if (this.configService.get('PERFORMANCE_MEASURE_ENABLED') === true) { - this.logger.setContext('PerformanceObserver'); - initialisePerformanceObserver(this.logger); - } - } -} diff --git a/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts b/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts deleted file mode 100644 index e5e4e192e83..00000000000 --- a/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { DynamicModule, Module } from '@nestjs/common'; -import { MongoMemoryDatabaseModule, MongoDatabaseModuleOptions } from '@infra/database'; -import { CoreModule } from '@src/core'; -import { ConfigModule } from '@nestjs/config'; -import { createConfigModuleOptions } from '@src/config'; -import { LoggerModule } from '@src/core/logger'; -import { HttpModule } from '@nestjs/axios'; -import { MetricsService } from './metrics'; -import { TldrawBoardRepo, TldrawRepo, YMongodb } from './repo'; -import { TldrawWsService } from './service'; -import { config } from './config'; -import { TldrawWs } from './controller'; -import { TldrawDrawing } from './entities'; -import { TldrawRedisFactory, TldrawRedisService } from './redis'; - -const imports = [ - HttpModule, - LoggerModule, - CoreModule, - MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] }), - ConfigModule.forRoot(createConfigModuleOptions(config)), -]; -const providers = [ - TldrawWs, - TldrawWsService, - TldrawBoardRepo, - TldrawRepo, - YMongodb, - MetricsService, - TldrawRedisFactory, - TldrawRedisService, -]; -@Module({ - imports, - providers, -}) -export class TldrawWsTestModule { - static forRoot(options?: MongoDatabaseModuleOptions): DynamicModule { - return { - module: TldrawWsTestModule, - imports: [...imports, MongoMemoryDatabaseModule.forRoot({ ...options })], - providers, - }; - } -} diff --git a/apps/server/src/modules/tldraw/tldraw-ws.module.ts b/apps/server/src/modules/tldraw/tldraw-ws.module.ts deleted file mode 100644 index ba2615ca465..00000000000 --- a/apps/server/src/modules/tldraw/tldraw-ws.module.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { MikroOrmModule } from '@mikro-orm/nestjs'; -import { HttpModule } from '@nestjs/axios'; -import { Module } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { initialisePerformanceObserver } from '@shared/common/measure-utils'; -import { createConfigModuleOptions, DB_PASSWORD, DB_USERNAME } from '@src/config'; -import { CoreModule } from '@src/core'; -import { Logger, LoggerModule } from '@src/core/logger'; -import { defaultMikroOrmOptions } from '@shared/common/defaultMikroOrmOptions'; -import { config, TLDRAW_DB_URL, TldrawConfig } from './config'; -import { TldrawWs } from './controller'; -import { TldrawDrawing } from './entities'; -import { MetricsService } from './metrics'; -import { TldrawRedisFactory, TldrawRedisService } from './redis'; -import { TldrawBoardRepo, TldrawRepo, YMongodb } from './repo'; -import { TldrawWsService } from './service'; - -@Module({ - imports: [ - HttpModule, - LoggerModule, - CoreModule, - MikroOrmModule.forRoot({ - ...defaultMikroOrmOptions, - type: 'mongo', - clientUrl: TLDRAW_DB_URL, - password: DB_PASSWORD, - user: DB_USERNAME, - entities: [TldrawDrawing], - }), - ConfigModule.forRoot(createConfigModuleOptions(config)), - ], - providers: [ - TldrawWs, - TldrawWsService, - TldrawBoardRepo, - TldrawRepo, - YMongodb, - MetricsService, - TldrawRedisFactory, - TldrawRedisService, - ], -}) -export class TldrawWsModule { - constructor(private readonly logger: Logger, private readonly configService: ConfigService) { - if (this.configService.get('PERFORMANCE_MEASURE_ENABLED') === true) { - this.logger.setContext('PerformanceObserver'); - initialisePerformanceObserver(this.logger); - } - } -} diff --git a/apps/server/src/modules/tldraw/types/awareness-connections-update-type.ts b/apps/server/src/modules/tldraw/types/awareness-connections-update-type.ts deleted file mode 100644 index 77e5ab1b99e..00000000000 --- a/apps/server/src/modules/tldraw/types/awareness-connections-update-type.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type AwarenessConnectionsUpdate = { - added: Array; - updated: Array; - removed: Array; -}; diff --git a/apps/server/src/modules/tldraw/types/connection-enum.ts b/apps/server/src/modules/tldraw/types/connection-enum.ts deleted file mode 100644 index c8c0cfdd2c3..00000000000 --- a/apps/server/src/modules/tldraw/types/connection-enum.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum WSMessageType { - SYNC = 0, - AWARENESS = 1, -} diff --git a/apps/server/src/modules/tldraw/types/index.ts b/apps/server/src/modules/tldraw/types/index.ts deleted file mode 100644 index ed1bf3d3226..00000000000 --- a/apps/server/src/modules/tldraw/types/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './tldraw-types'; -export * from './connection-enum'; -export * from './y-transaction-type'; -export * from './ws-close-enum'; -export * from './awareness-connections-update-type'; -export * from './redis-connection-type-enum'; -export * from './update-enums'; diff --git a/apps/server/src/modules/tldraw/types/redis-connection-type-enum.ts b/apps/server/src/modules/tldraw/types/redis-connection-type-enum.ts deleted file mode 100644 index a0e34661a98..00000000000 --- a/apps/server/src/modules/tldraw/types/redis-connection-type-enum.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum RedisConnectionTypeEnum { - PUBLISH = 'PUB', - SUBSCRIBE = 'SUB', -} diff --git a/apps/server/src/modules/tldraw/types/tldraw-types.ts b/apps/server/src/modules/tldraw/types/tldraw-types.ts deleted file mode 100644 index be566290ae2..00000000000 --- a/apps/server/src/modules/tldraw/types/tldraw-types.ts +++ /dev/null @@ -1,26 +0,0 @@ -export enum TldrawShapeType { - Sticky = 'sticky', - Ellipse = 'ellipse', - Rectangle = 'rectangle', - Triangle = 'triangle', - Draw = 'draw', - Arrow = 'arrow', - Line = 'line', - Text = 'text', - Group = 'group', - Image = 'image', - Video = 'video', -} - -export type TldrawShape = { - id: string; - type: TldrawShapeType; - assetId?: string; -}; - -export type TldrawAsset = { - id: string; - type: TldrawShapeType; - name: string; - src: string; -}; diff --git a/apps/server/src/modules/tldraw/types/update-enums.ts b/apps/server/src/modules/tldraw/types/update-enums.ts deleted file mode 100644 index 826bfe7039c..00000000000 --- a/apps/server/src/modules/tldraw/types/update-enums.ts +++ /dev/null @@ -1,8 +0,0 @@ -export enum UpdateOrigin { - REDIS = 'redis', -} - -export enum UpdateType { - AWARENESS = 'awareness', - DOCUMENT = 'document', -} diff --git a/apps/server/src/modules/tldraw/types/ws-close-enum.ts b/apps/server/src/modules/tldraw/types/ws-close-enum.ts deleted file mode 100644 index 0e3333c46ab..00000000000 --- a/apps/server/src/modules/tldraw/types/ws-close-enum.ts +++ /dev/null @@ -1,15 +0,0 @@ -export enum WsCloseCode { - BAD_REQUEST = 4400, - UNAUTHORIZED = 4401, - NOT_FOUND = 4404, - NOT_ACCEPTABLE = 4406, - INTERNAL_SERVER_ERROR = 4500, -} -export enum WsCloseMessage { - FEATURE_DISABLED = 'Tldraw feature is disabled.', - BAD_REQUEST = 'Room name param not found in url.', - UNAUTHORIZED = "You don't have permission to this drawing.", - NOT_FOUND = 'Drawing not found.', - NOT_ACCEPTABLE = 'Could not get document, still finalizing or not yet loaded.', - INTERNAL_SERVER_ERROR = 'Unable to establish websocket connection.', -} diff --git a/apps/server/src/modules/tldraw/types/y-transaction-type.ts b/apps/server/src/modules/tldraw/types/y-transaction-type.ts deleted file mode 100644 index cee97047960..00000000000 --- a/apps/server/src/modules/tldraw/types/y-transaction-type.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Doc } from 'yjs'; - -export type YTransaction = Doc | number | void; diff --git a/apps/server/src/modules/tldraw/uc/index.ts b/apps/server/src/modules/tldraw/uc/index.ts deleted file mode 100644 index 0b585097608..00000000000 --- a/apps/server/src/modules/tldraw/uc/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './tldraw-delete-files.uc'; diff --git a/apps/server/src/modules/tldraw/uc/tldraw-delete-files.uc.spec.ts b/apps/server/src/modules/tldraw/uc/tldraw-delete-files.uc.spec.ts deleted file mode 100644 index 4cbed61fdfd..00000000000 --- a/apps/server/src/modules/tldraw/uc/tldraw-delete-files.uc.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { YMap } from 'yjs/dist/src/types/YMap'; -import { TldrawFilesStorageAdapterService } from '../service'; -import { YMongodb } from '../repo'; -import { TldrawDeleteFilesUc } from './tldraw-delete-files.uc'; -import { WsSharedDocDo } from '../domain'; -import { TldrawAsset, TldrawShape, TldrawShapeType } from '../types'; -import { tldrawShapeFactory, tldrawAssetFactory } from '../testing'; - -describe('TldrawDeleteFilesUc', () => { - let uc: TldrawDeleteFilesUc; - let mdb: DeepMocked; - let filesStorageAdapterService: DeepMocked; - - beforeAll(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - TldrawDeleteFilesUc, - { - provide: YMongodb, - useValue: createMock(), - }, - { - provide: TldrawFilesStorageAdapterService, - useValue: createMock(), - }, - ], - }).compile(); - - uc = module.get(TldrawDeleteFilesUc); - mdb = module.get(YMongodb); - filesStorageAdapterService = module.get(TldrawFilesStorageAdapterService); - }); - - it('should be defined', () => { - expect(uc).toBeDefined(); - }); - - describe('deleteUnusedFiles', () => { - const setup = () => { - mdb.getAllDocumentNames.mockResolvedValueOnce(['doc1']); - const doc = new WsSharedDocDo('doc1'); - - const shapes: YMap = doc.getMap('shapes'); - const shape1 = tldrawShapeFactory.build(); - const shape2 = tldrawShapeFactory.build({ type: TldrawShapeType.Draw, assetId: undefined }); - shapes.set('shape1', shape1); - shapes.set('shape2', shape2); - - const assets: YMap = doc.getMap('assets'); - const asset1 = tldrawAssetFactory.build(); - const asset2 = tldrawAssetFactory.build(); - assets.set('asset1', asset1); - assets.set('asset2', asset2); - - mdb.getDocument.mockResolvedValueOnce(doc); - }; - - it('should call deleteUnusedFilesForDocument on TldrawFilesStorageAdapterService correct number of times', async () => { - setup(); - - await uc.deleteUnusedFiles(new Date()); - - expect(filesStorageAdapterService.deleteUnusedFilesForDocument).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/uc/tldraw-delete-files.uc.ts b/apps/server/src/modules/tldraw/uc/tldraw-delete-files.uc.ts deleted file mode 100644 index 2d3f9b91510..00000000000 --- a/apps/server/src/modules/tldraw/uc/tldraw-delete-files.uc.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* eslint-disable no-await-in-loop */ -import { Injectable } from '@nestjs/common'; -import { YMap } from 'yjs/dist/src/types/YMap'; -import { YMongodb } from '../repo'; -import { TldrawFilesStorageAdapterService } from '../service'; -import { WsSharedDocDo } from '../domain'; -import { TldrawAsset, TldrawShape } from '../types'; - -@Injectable() -export class TldrawDeleteFilesUc { - constructor(private mdb: YMongodb, private filesStorageTldrawAdapterService: TldrawFilesStorageAdapterService) {} - - public async deleteUnusedFiles(thresholdDate: Date): Promise { - const docNames = await this.mdb.getAllDocumentNames(); - - for (const docName of docNames) { - // this.mdb.getDocument(docName); can be return null, it is not handled - const doc = await this.mdb.getDocument(docName); - const usedAssets = this.getUsedAssetsFromDocument(doc); - - await this.filesStorageTldrawAdapterService.deleteUnusedFilesForDocument(docName, usedAssets, thresholdDate); - doc.destroy(); - } - } - - private getUsedAssetsFromDocument(doc: WsSharedDocDo): TldrawAsset[] { - const assets: YMap = doc.getMap('assets'); - const shapes: YMap = doc.getMap('shapes'); - const usedShapesAsAssets: TldrawShape[] = []; - const usedAssets: TldrawAsset[] = []; - - for (const [, shape] of shapes) { - if (shape.assetId) { - usedShapesAsAssets.push(shape); - } - } - - for (const [, asset] of assets) { - const foundAsset = usedShapesAsAssets.some((shape) => shape.assetId === asset.id); - if (foundAsset) { - usedAssets.push(asset); - } - } - - return usedAssets; - } -} diff --git a/apps/server/src/modules/user-import/entity/import-user.entity.ts b/apps/server/src/modules/user-import/entity/import-user.entity.ts index 24d8ba33653..5944ae5f4c5 100644 --- a/apps/server/src/modules/user-import/entity/import-user.entity.ts +++ b/apps/server/src/modules/user-import/entity/import-user.entity.ts @@ -117,7 +117,7 @@ export class ImportUser extends BaseEntityWithTimestamps implements EntityWithSc @Property({ nullable: true }) externalRoleNames?: string[]; - setMatch(user: User, matchedBy: MatchCreator) { + public setMatch(user: User, matchedBy: MatchCreator): void { if (this.school.id !== user.school.id) { throw new Error('not same school'); } @@ -125,12 +125,12 @@ export class ImportUser extends BaseEntityWithTimestamps implements EntityWithSc this.matchedBy = matchedBy; } - revokeMatch() { + public revokeMatch(): void { this.user = undefined; this.matchedBy = undefined; } - static isImportUserRole(role: RoleName): role is ImportUserRoleName { + public static isImportUserRole(role: unknown): role is ImportUserRoleName { return role === RoleName.ADMINISTRATOR || role === RoleName.STUDENT || role === RoleName.TEACHER; } } diff --git a/apps/server/src/modules/user-import/loggable/index.ts b/apps/server/src/modules/user-import/loggable/index.ts index 5866aa22e61..3d18659984b 100644 --- a/apps/server/src/modules/user-import/loggable/index.ts +++ b/apps/server/src/modules/user-import/loggable/index.ts @@ -12,3 +12,4 @@ export { UserMigrationIsNotEnabledLoggableException } from './user-migration-not export { UserMigrationCanceledLoggable } from './user-migration-canceled.loggable'; export { UserAlreadyMigratedLoggable } from './user-already-migrated.loggable'; export { UserLoginMigrationNotActiveLoggableException } from './user-login-migration-not-active.loggable-exception'; +export { UserMigrationFailedLoggable } from './user-migration-failed.loggable'; diff --git a/apps/server/src/modules/user-import/loggable/user-migration-failed.loggable.spec.ts b/apps/server/src/modules/user-import/loggable/user-migration-failed.loggable.spec.ts new file mode 100644 index 00000000000..96363bb1ec9 --- /dev/null +++ b/apps/server/src/modules/user-import/loggable/user-migration-failed.loggable.spec.ts @@ -0,0 +1,38 @@ +import { NotFoundException } from '@nestjs/common'; +import { importUserFactory, setupEntities } from '@shared/testing'; +import { UserMigrationFailedLoggable } from './user-migration-failed.loggable'; + +describe(UserMigrationFailedLoggable.name, () => { + describe('getLogMessage', () => { + const setup = async () => { + await setupEntities(); + const importUser = importUserFactory.build(); + const error = new NotFoundException('user not found'); + const loggable = new UserMigrationFailedLoggable(importUser, error); + + return { + loggable, + importUser, + error, + }; + }; + + it('should return the correct log message', async () => { + const { loggable, importUser, error } = await setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + type: 'USER_MIGRATION_FAILED', + message: 'An error occurred while migrating a user with the migration wizard.', + stack: error.stack, + data: { + externalUserId: importUser.externalId, + userId: importUser.user?.id, + errorName: error.name, + errorMsg: error.message, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-import/loggable/user-migration-failed.loggable.ts b/apps/server/src/modules/user-import/loggable/user-migration-failed.loggable.ts new file mode 100644 index 00000000000..8f382e8424e --- /dev/null +++ b/apps/server/src/modules/user-import/loggable/user-migration-failed.loggable.ts @@ -0,0 +1,20 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { ImportUser } from '../entity'; + +export class UserMigrationFailedLoggable implements Loggable { + constructor(private readonly importUser: ImportUser, private readonly error: Error) {} + + public getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'USER_MIGRATION_FAILED', + message: 'An error occurred while migrating a user with the migration wizard.', + stack: this.error.stack, + data: { + externalUserId: this.importUser.externalId, + userId: this.importUser.user?.id, + errorName: this.error.name, + errorMsg: this.error.message, + }, + }; + } +} diff --git a/apps/server/src/modules/user-import/mapper/schulconnex-import-user.mapper.ts b/apps/server/src/modules/user-import/mapper/schulconnex-import-user.mapper.ts index f5f655a222d..83d7f5a7455 100644 --- a/apps/server/src/modules/user-import/mapper/schulconnex-import-user.mapper.ts +++ b/apps/server/src/modules/user-import/mapper/schulconnex-import-user.mapper.ts @@ -15,7 +15,8 @@ export class SchulconnexImportUserMapper { em: EntityManager ): ImportUser[] { const importUsers: ImportUser[] = response.map((externalUser: SchulconnexResponse): ImportUser => { - const role: RoleName = SchulconnexResponseMapper.mapSanisRoleToRoleName(externalUser); + const role: RoleName | undefined = SchulconnexResponseMapper.mapSanisRoleToRoleName(externalUser); + const groups: SchulconnexGruppenResponse[] | undefined = externalUser.personenkontexte[0]?.gruppen?.filter( (group) => group.gruppe.typ === SchulconnexGroupType.CLASS ); diff --git a/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts b/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts index af5c6d96fca..b924f67f54f 100644 --- a/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts +++ b/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts @@ -33,7 +33,11 @@ import { import { Logger } from '@src/core/logger'; import { ImportUserFilter, ImportUserMatchCreatorScope } from '../domain/interface'; import { ImportUser, MatchCreator } from '../entity'; -import { SchoolNotMigratedLoggableException, UserAlreadyMigratedLoggable } from '../loggable'; +import { + SchoolNotMigratedLoggableException, + UserAlreadyMigratedLoggable, + UserMigrationFailedLoggable, +} from '../loggable'; import { ImportUserRepo } from '../repo'; import { UserImportService } from '../service'; import { UserImportConfig } from '../user-import-config'; @@ -699,6 +703,7 @@ describe('[ImportUserModule]', () => { ); }); }); + describe('when user is already migrated', () => { const setup = () => { const system = systemEntityFactory.buildWithId(); @@ -762,6 +767,66 @@ describe('[ImportUserModule]', () => { expect(logger.notice).toHaveBeenCalledWith(new UserAlreadyMigratedLoggable(importUser.user!.id)); }); }); + + describe('when a user migration fails', () => { + const setup = () => { + const system = systemEntityFactory.buildWithId(); + const schoolEntity = schoolEntityFactory.buildWithId(); + const user = userFactory.buildWithId({ + school: schoolEntity, + }); + const school = legacySchoolDoFactory.build({ + id: schoolEntity.id, + externalId: 'externalId', + officialSchoolNumber: 'officialSchoolNumber', + inUserMigration: true, + inMaintenanceSince: new Date(), + systems: [system.id], + }); + const importUser = importUserFactory.buildWithId({ + school: schoolEntity, + user: userFactory.buildWithId({ + school: schoolEntity, + }), + matchedBy: MatchCreator.AUTO, + system, + externalId: 'externalId', + }); + const importUserWithoutUser = importUserFactory.buildWithId({ + school: schoolEntity, + system, + }); + const error = new Error(); + + userRepo.findById.mockResolvedValueOnce(user); + userService.findByExternalId.mockResolvedValueOnce(null); + schoolService.getSchoolById.mockResolvedValueOnce(school); + importUserRepo.findImportUsers.mockResolvedValueOnce([[importUser, importUserWithoutUser], 2]); + userMigrationService.migrateUser.mockRejectedValueOnce(error); + config.FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION = true; + + return { + user, + importUser, + importUserWithoutUser, + error, + }; + }; + + it('should not throw', async () => { + const { user } = setup(); + + await expect(uc.saveAllUsersMatches(user.id)).resolves.not.toThrow(); + }); + + it('should log information for skipped user ', async () => { + const { user, importUser, error } = setup(); + + await uc.saveAllUsersMatches(user.id); + + expect(logger.warning).toHaveBeenCalledWith(new UserMigrationFailedLoggable(importUser, error)); + }); + }); }); describe('when the user does not have an account', () => { diff --git a/apps/server/src/modules/user-import/uc/user-import.uc.ts b/apps/server/src/modules/user-import/uc/user-import.uc.ts index b33363583c4..69e8868ce7e 100644 --- a/apps/server/src/modules/user-import/uc/user-import.uc.ts +++ b/apps/server/src/modules/user-import/uc/user-import.uc.ts @@ -15,6 +15,10 @@ import { IFindOptions, Permission } from '@shared/domain/interface'; import { Counted, EntityId } from '@shared/domain/types'; import { UserRepo } from '@shared/repo'; import { Logger } from '@src/core/logger'; +import { isError } from 'lodash'; + +import { ImportUserFilter, ImportUserMatchCreatorScope, ImportUserNameMatchFilter } from '../domain/interface'; +import { ImportUser, MatchCreator } from '../entity'; import { MigrationMayBeCompleted, MigrationMayNotBeCompleted, @@ -23,10 +27,8 @@ import { SchoolInUserMigrationStartLoggable, SchoolNotMigratedLoggableException, UserAlreadyMigratedLoggable, + UserMigrationFailedLoggable, } from '../loggable'; - -import { ImportUserMatchCreatorScope, ImportUserNameMatchFilter, ImportUserFilter } from '../domain/interface'; -import { ImportUser, MatchCreator } from '../entity'; import { ImportUserRepo } from '../repo'; import { UserImportService } from '../service'; import { UserImportConfig } from '../user-import-config'; @@ -200,12 +202,18 @@ export class UserImportUc { }, }); for (const importUser of importUsers) { - // TODO: Find a better solution for this loop - // this needs to be synchronous, because otherwise it was leading to - // server crush when working with larger number of users (e.g. 1000) - // eslint-disable-next-line no-await-in-loop - await this.updateUserAndAccount(importUser, school); - migratedUser += 1; + try { + // TODO: Find a better solution for this loop + // this needs to be synchronous, because otherwise it was leading to + // server crush when working with larger number of users (e.g. 1000) + // eslint-disable-next-line no-await-in-loop + await this.updateUserAndAccount(importUser, school); + migratedUser += 1; + } catch (error: unknown) { + if (isError(error)) { + this.logger.warning(new UserMigrationFailedLoggable(importUser, error)); + } + } } } diff --git a/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.spec.ts b/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.spec.ts index 6ce87f1d431..3d52fb94525 100644 --- a/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.spec.ts +++ b/apps/server/src/modules/user-login-migration/uc/user-login-migration.uc.spec.ts @@ -4,13 +4,7 @@ import { AuthenticationService } from '@modules/authentication'; import { Action, AuthorizationService } from '@modules/authorization'; import { LegacySchoolService } from '@modules/legacy-school'; import { OAuthService, OAuthTokenDto } from '@modules/oauth'; -import { - ExternalSchoolDto, - ExternalUserDto, - OauthDataDto, - ProvisioningService, - ProvisioningSystemDto, -} from '@modules/provisioning'; +import { ExternalSchoolDto, OauthDataDto, ProvisioningService, ProvisioningSystemDto } from '@modules/provisioning'; import { SystemEntity } from '@modules/system/entity'; import { UserService } from '@modules/user'; import { ForbiddenException } from '@nestjs/common'; @@ -30,6 +24,7 @@ import { userLoginMigrationDOFactory, } from '@shared/testing'; import { Logger } from '@src/core/logger'; +import { externalUserDtoFactory } from '../../provisioning/testing'; import { ExternalSchoolNumberMissingLoggableException, InvalidUserLoginMigrationLoggableException, @@ -294,9 +289,7 @@ describe(UserLoginMigrationUc.name, () => { systemId: 'systemId', provisioningStrategy: SystemProvisioningStrategy.SANIS, }), - externalUser: new ExternalUserDto({ - externalId: 'externalUserId', - }), + externalUser: externalUserDtoFactory.build(), }); const tokenDto: OAuthTokenDto = new OAuthTokenDto({ @@ -376,9 +369,7 @@ describe(UserLoginMigrationUc.name, () => { systemId: 'systemId', provisioningStrategy: SystemProvisioningStrategy.SANIS, }), - externalUser: new ExternalUserDto({ - externalId: 'externalUserId', - }), + externalUser: externalUserDtoFactory.build(), externalSchool: new ExternalSchoolDto({ externalId: 'externalId', officialSchoolNumber: 'officialSchoolNumber', @@ -441,9 +432,7 @@ describe(UserLoginMigrationUc.name, () => { systemId: 'systemId', provisioningStrategy: SystemProvisioningStrategy.SANIS, }), - externalUser: new ExternalUserDto({ - externalId: 'externalUserId', - }), + externalUser: externalUserDtoFactory.build(), externalSchool: new ExternalSchoolDto({ externalId: 'externalId', officialSchoolNumber: 'officialSchoolNumber', @@ -490,9 +479,7 @@ describe(UserLoginMigrationUc.name, () => { systemId: 'systemId', provisioningStrategy: SystemProvisioningStrategy.SANIS, }), - externalUser: new ExternalUserDto({ - externalId: 'externalUserId', - }), + externalUser: externalUserDtoFactory.build(), }); const tokenDto: OAuthTokenDto = new OAuthTokenDto({ @@ -531,9 +518,7 @@ describe(UserLoginMigrationUc.name, () => { systemId: 'systemId', provisioningStrategy: SystemProvisioningStrategy.SANIS, }), - externalUser: new ExternalUserDto({ - externalId: 'externalUserId', - }), + externalUser: externalUserDtoFactory.build(), externalSchool: new ExternalSchoolDto({ externalId: 'externalId', name: 'schoolName', diff --git a/apps/server/src/modules/video-conference/controller/api-test/video-conference.api.spec.ts b/apps/server/src/modules/video-conference/controller/api-test/video-conference-course.api.spec.ts similarity index 99% rename from apps/server/src/modules/video-conference/controller/api-test/video-conference.api.spec.ts rename to apps/server/src/modules/video-conference/controller/api-test/video-conference-course.api.spec.ts index 0417ecbaa6f..b7c8ca7c4bc 100644 --- a/apps/server/src/modules/video-conference/controller/api-test/video-conference.api.spec.ts +++ b/apps/server/src/modules/video-conference/controller/api-test/video-conference-course.api.spec.ts @@ -271,7 +271,7 @@ describe('VideoConferenceController (API)', () => { }); }); - describe('when user has the required permission', () => { + describe('when user has the required permission in course scope', () => { const setup = async () => { const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); diff --git a/apps/server/src/modules/video-conference/controller/api-test/video-conference-room.api.spec.ts b/apps/server/src/modules/video-conference/controller/api-test/video-conference-room.api.spec.ts new file mode 100644 index 00000000000..a9eae2a7741 --- /dev/null +++ b/apps/server/src/modules/video-conference/controller/api-test/video-conference-room.api.spec.ts @@ -0,0 +1,1123 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Role, SchoolEntity, TargetModels, User, VideoConference } from '@shared/domain/entity'; +import { Permission, RoleName, VideoConferenceScope } from '@shared/domain/interface'; +import { SchoolFeature } from '@shared/domain/types'; +import { + TestApiClient, + UserAndAccountTestFactory, + cleanupCollections, + groupEntityFactory, + roleFactory, + schoolEntityFactory, + userFactory, +} from '@shared/testing'; +import { videoConferenceFactory } from '@shared/testing/factory/video-conference.factory'; +import { AccountEntity } from '@src/modules/account/domain/entity/account.entity'; +import { accountFactory } from '@src/modules/account/testing'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { Response } from 'supertest'; +import { roomEntityFactory } from '@src/modules/room/testing'; +import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing'; +import { VideoConferenceCreateParams, VideoConferenceJoinResponse } from '../dto'; + +describe('VideoConferenceController (API)', () => { + let app: INestApplication; + let em: EntityManager; + let axiosMock: MockAdapter; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + axiosMock = new MockAdapter(axios); + testApiClient = new TestApiClient(app, 'videoconference2'); + }); + + afterAll(async () => { + await app.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + axiosMock = new MockAdapter(axios); + }); + + const mockBbbMeetingInfoFailed = (meetingId: string) => { + axiosMock + .onGet(new RegExp(`.*/bigbluebutton/api/getMeetingInfo?.*meetingID=${meetingId}.*`)) + .replyOnce( + HttpStatus.INTERNAL_SERVER_ERROR, + '\n' + + '\n' + + ' FAILED\n' + + ' notFound\n' + + ' We could not find a meeting with that meeting ID - perhaps the meeting is not yet running?\n' + + '' + ); + }; + + const mockBbbMeetingInfoSuccess = (meetingId: string) => { + axiosMock + .onGet(new RegExp(`.*/bigbluebutton/api/getMeetingInfo?.*meetingID=${meetingId}.*`)) + .replyOnce( + HttpStatus.OK, + '\n' + + '\n' + + 'SUCCESS\n' + + 'Mathe\n' + + `${meetingId}\n` + + 'c7ae0ac13ace99c8b2239ce3919c28e47d5bbd2a-1686648423698\n' + + '1686648423698\n' + + 'Tue Jun 13 11:27:03 CEST 2023\n' + + '17878\n' + + '613-555-1234\n' + + 'VIEWER\n' + + 'MODERATOR\n' + + 'false\n' + + '0\n' + + 'false\n' + + 'false\n' + + 'false\n' + + '1686648423709\n' + + '0\n' + + '0\n' + + '0\n' + + '0\n' + + '0\n' + + '0\n' + + '0\n' + + '\n' + + '\n' + + '\n' + + 'localhost\n' + + '\n' + + 'false\n' + + '\n' + ); + }; + + const mockBbbCreateSuccess = (meetingId: string) => { + axiosMock + .onPost(new RegExp(`.*/bigbluebutton/api/create?.*meetingID=${meetingId}.*`)) + .replyOnce( + HttpStatus.OK, + '\n' + + '\n' + + ' SUCCESS\n' + + ` ${meetingId}\n` + + ' c7ae0ac13ace99c8b2239ce3919c28e47d5bbd2a-1686646947283\n' + + ' bbb-none\n' + + ' 1686646947283\n' + + ' 37466\n' + + ' 613-555-1234\n' + + ' Tue Jun 13 11:02:27 CEST 2023\n' + + ' false\n' + + ' 0\n' + + ' false\n' + + ' messageKey\n' + + ' message\n' + + '' + ); + }; + + const mockBbbEndSuccess = (meetingId: string) => { + axiosMock + .onGet(new RegExp(`.*/bigbluebutton/api/end?.*meetingID=${meetingId}.*`)) + .replyOnce( + HttpStatus.OK, + '\n' + + '\n' + + 'SUCCESS\n' + + 'sentEndMeetingRequest\n' + + 'A request to end the meeting was sent. Please wait a few seconds, and then use the getMeetingInfo or isMeetingRunning API calls to verify that it was ended.\n' + + '\n' + ); + }; + + describe('[PUT] /videoconference2/:scope/:scopeId/start', () => { + describe('when user is unauthorized', () => { + it('should return unauthorized', async () => { + const response: Response = await testApiClient.put('/anyScope/anyId/start'); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when the logoutUrl is from a wrong origin', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + ]); + em.clear(); + + const params: VideoConferenceCreateParams = { + everyAttendeeJoinsMuted: true, + everybodyJoinsAsModerator: true, + moderatorMustApproveJoinRequests: true, + logoutUrl: 'http://from.other.origin/', + }; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + params, + }; + }; + + it('should return bad request', async () => { + const { loggedInClient, params } = await setup(); + + const response: Response = await loggedInClient.put( + `${VideoConferenceScope.ROOM}/${new ObjectId().toHexString()}/start`, + params + ); + + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); + }); + }); + + describe('when conference params are given', () => { + describe('when school has not enabled the school feature videoconference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + ]); + em.clear(); + + const params: VideoConferenceCreateParams = { + everyAttendeeJoinsMuted: true, + everybodyJoinsAsModerator: true, + moderatorMustApproveJoinRequests: true, + }; + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId, params }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, params, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.put(`${scope}/${scopeId}/start`, params); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when user has not the required permission', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2025-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleViewer, user: studentUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + await em.persistAndFlush([ + room, + roomMembership, + school, + studentAccount, + studentUser, + userGroup, + roleEditor, + roleViewer, + ]); + em.clear(); + + const params: VideoConferenceCreateParams = { + everyAttendeeJoinsMuted: true, + everybodyJoinsAsModerator: true, + moderatorMustApproveJoinRequests: true, + }; + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(studentAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId, params }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, params, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.put(`${scope}/${scopeId}/start`, params); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when user has the required permission in room scope', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + ]); + em.clear(); + + const params: VideoConferenceCreateParams = { + everyAttendeeJoinsMuted: true, + everybodyJoinsAsModerator: true, + moderatorMustApproveJoinRequests: true, + }; + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + mockBbbMeetingInfoFailed(scopeId); + mockBbbCreateSuccess(scopeId); + + return { loggedInClient, scope, scopeId, params }; + }; + + it('should create the conference successfully and return with ok', async () => { + const { loggedInClient, params, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.put(`${scope}/${scopeId}/start`, params); + + expect(response.status).toEqual(HttpStatus.OK); + }); + }); + }); + + describe('when conference is for scope and scopeId is already running', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + ]); + em.clear(); + + const params: VideoConferenceCreateParams = { + everyAttendeeJoinsMuted: true, + everybodyJoinsAsModerator: true, + moderatorMustApproveJoinRequests: true, + }; + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoSuccess(scopeId); + + return { loggedInClient, scope, scopeId, params }; + }; + + it('should return ok', async () => { + const { loggedInClient, params, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.put(`${scope}/${scopeId}/start`, params); + + expect(response.status).toEqual(HttpStatus.OK); + }); + }); + + describe('[GET] /videoconference2/:scope/:scopeId/join', () => { + describe('when user is unauthorized', () => { + it('should return unauthorized', async () => { + const response: Response = await testApiClient.get('/anyScope/anyId/join'); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when scope and scopeId are given', () => { + describe('when school has not enabled the school feature videoconference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.ROOMS, + target: room.id, + }); + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/join`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when user has the required permission', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.ROOMS, + target: room.id, + }); + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoSuccess(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return the conference', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/join`); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + url: expect.any(String), + }); + }); + }); + + describe('when conference is not running', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.ROOMS, + target: room.id, + }); + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return internal server error', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/join`); + + expect(response.status).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); + }); + }); + }); + }); + + describe('[GET] /videoconference2/:scope/:scopeId/info', () => { + describe('when user is unauthorized', () => { + it('should return unauthorized', async () => { + const response: Response = await testApiClient.get('/anyScope/anyId/info'); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when scope and scopeId are given', () => { + describe('when school has not enabled the school feature videoconference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.ROOMS, + target: room.id, + }); + + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/info`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when user has the required permission', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.ROOMS, + target: room.id, + }); + + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoSuccess(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return ok', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/info`); + + expect(response.status).toEqual(HttpStatus.OK); + }); + }); + + describe('when guest want meeting info of conference without waiting room', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const expertRole: Role = roleFactory.buildWithId({ + name: RoleName.EXPERT, + permissions: [Permission.JOIN_MEETING], + }); + + const expertUser: User = userFactory.buildWithId({ school, roles: [expertRole] }); + const expertAccount: AccountEntity = accountFactory.buildWithId({ userId: expertUser.id }); + + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: expertRole, user: expertUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.ROOMS, + target: room.id, + options: { moderatorMustApproveJoinRequests: false }, + }); + + await em.persistAndFlush([ + expertAccount, + expertUser, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(expertAccount); + + mockBbbMeetingInfoSuccess(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/info`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when conference is not running', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.ROOMS, + target: room.id, + }); + + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return ok', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/info`); + + expect(response.status).toEqual(HttpStatus.OK); + }); + }); + }); + }); + + describe('[PUT] /videoconference2/:scope/:scopeId/end', () => { + describe('when user is unauthorized', () => { + it('should return unauthorized', async () => { + const response: Response = await testApiClient.get('/anyScope/anyId/end'); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when scope and scopeId are given', () => { + describe('when school has not enabled the school feature videoconference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.ROOMS, + target: room.id, + }); + + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/end`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when a user without required permission wants to end a conference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleViewer, user: studentUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.ROOMS, + target: room.id, + }); + + await em.persistAndFlush([ + room, + roomMembership, + school, + studentAccount, + studentUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(studentAccount); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/end`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when a user with required permission wants to end a conference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.ROOMS, + target: room.id, + }); + + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbEndSuccess(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return ok', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/end`); + + expect(response.status).toEqual(HttpStatus.OK); + }); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/video-conference/controller/api-test/video-conference-video-conference-element.api.spec.ts b/apps/server/src/modules/video-conference/controller/api-test/video-conference-video-conference-element.api.spec.ts new file mode 100644 index 00000000000..a622a760219 --- /dev/null +++ b/apps/server/src/modules/video-conference/controller/api-test/video-conference-video-conference-element.api.spec.ts @@ -0,0 +1,1302 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Role, SchoolEntity, TargetModels, User, VideoConference } from '@shared/domain/entity'; +import { Permission, RoleName, VideoConferenceScope } from '@shared/domain/interface'; +import { SchoolFeature } from '@shared/domain/types'; +import { + TestApiClient, + UserAndAccountTestFactory, + cleanupCollections, + groupEntityFactory, + roleFactory, + schoolEntityFactory, + userFactory, +} from '@shared/testing'; +import { videoConferenceFactory } from '@shared/testing/factory/video-conference.factory'; +import { AccountEntity } from '@src/modules/account/domain/entity/account.entity'; +import { accountFactory } from '@src/modules/account/testing'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { Response } from 'supertest'; +import { roomEntityFactory } from '@src/modules/room/testing'; +import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing'; +import { BoardExternalReferenceType } from '@src/modules/board'; +import { + columnBoardEntityFactory, + columnEntityFactory, + cardEntityFactory, + videoConferenceElementEntityFactory, +} from '@src/modules/board/testing'; +import { VideoConferenceCreateParams, VideoConferenceJoinResponse } from '../dto'; + +describe('VideoConferenceController (API)', () => { + let app: INestApplication; + let em: EntityManager; + let axiosMock: MockAdapter; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + axiosMock = new MockAdapter(axios); + testApiClient = new TestApiClient(app, 'videoconference2'); + }); + + afterAll(async () => { + await app.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + axiosMock = new MockAdapter(axios); + }); + + const mockBbbMeetingInfoFailed = (meetingId: string) => { + axiosMock + .onGet(new RegExp(`.*/bigbluebutton/api/getMeetingInfo?.*meetingID=${meetingId}.*`)) + .replyOnce( + HttpStatus.INTERNAL_SERVER_ERROR, + '\n' + + '\n' + + ' FAILED\n' + + ' notFound\n' + + ' We could not find a meeting with that meeting ID - perhaps the meeting is not yet running?\n' + + '' + ); + }; + + const mockBbbMeetingInfoSuccess = (meetingId: string) => { + axiosMock + .onGet(new RegExp(`.*/bigbluebutton/api/getMeetingInfo?.*meetingID=${meetingId}.*`)) + .replyOnce( + HttpStatus.OK, + '\n' + + '\n' + + 'SUCCESS\n' + + 'Mathe\n' + + `${meetingId}\n` + + 'c7ae0ac13ace99c8b2239ce3919c28e47d5bbd2a-1686648423698\n' + + '1686648423698\n' + + 'Tue Jun 13 11:27:03 CEST 2023\n' + + '17878\n' + + '613-555-1234\n' + + 'VIEWER\n' + + 'MODERATOR\n' + + 'false\n' + + '0\n' + + 'false\n' + + 'false\n' + + 'false\n' + + '1686648423709\n' + + '0\n' + + '0\n' + + '0\n' + + '0\n' + + '0\n' + + '0\n' + + '0\n' + + '\n' + + '\n' + + '\n' + + 'localhost\n' + + '\n' + + 'false\n' + + '\n' + ); + }; + + const mockBbbCreateSuccess = (meetingId: string) => { + axiosMock + .onPost(new RegExp(`.*/bigbluebutton/api/create?.*meetingID=${meetingId}.*`)) + .replyOnce( + HttpStatus.OK, + '\n' + + '\n' + + ' SUCCESS\n' + + ` ${meetingId}\n` + + ' c7ae0ac13ace99c8b2239ce3919c28e47d5bbd2a-1686646947283\n' + + ' bbb-none\n' + + ' 1686646947283\n' + + ' 37466\n' + + ' 613-555-1234\n' + + ' Tue Jun 13 11:02:27 CEST 2023\n' + + ' false\n' + + ' 0\n' + + ' false\n' + + ' messageKey\n' + + ' message\n' + + '' + ); + }; + + const mockBbbEndSuccess = (meetingId: string) => { + axiosMock + .onGet(new RegExp(`.*/bigbluebutton/api/end?.*meetingID=${meetingId}.*`)) + .replyOnce( + HttpStatus.OK, + '\n' + + '\n' + + 'SUCCESS\n' + + 'sentEndMeetingRequest\n' + + 'A request to end the meeting was sent. Please wait a few seconds, and then use the getMeetingInfo or isMeetingRunning API calls to verify that it was ended.\n' + + '\n' + ); + }; + + describe('[PUT] /videoconference2/:scope/:scopeId/start', () => { + describe('when user is unauthorized', () => { + it('should return unauthorized', async () => { + const response: Response = await testApiClient.put('/anyScope/anyId/start'); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when the logoutUrl is from a wrong origin', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + ]); + em.clear(); + + const params: VideoConferenceCreateParams = { + everyAttendeeJoinsMuted: true, + everybodyJoinsAsModerator: true, + moderatorMustApproveJoinRequests: true, + logoutUrl: 'http://from.other.origin/', + }; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + params, + }; + }; + + it('should return bad request', async () => { + const { loggedInClient, params } = await setup(); + + const response: Response = await loggedInClient.put( + `${VideoConferenceScope.ROOM}/${new ObjectId().toHexString()}/start`, + params + ); + + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); + }); + }); + + describe('when conference params are given', () => { + describe('when school has not enabled the school feature videoconference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + ]); + em.clear(); + + const params: VideoConferenceCreateParams = { + everyAttendeeJoinsMuted: true, + everybodyJoinsAsModerator: true, + moderatorMustApproveJoinRequests: true, + }; + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId, params }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, params, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.put(`${scope}/${scopeId}/start`, params); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when user has not the required permission', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2025-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleViewer, user: studentUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + studentAccount, + studentUser, + userGroup, + roleEditor, + roleViewer, + ]); + em.clear(); + + const params: VideoConferenceCreateParams = { + everyAttendeeJoinsMuted: true, + everybodyJoinsAsModerator: true, + moderatorMustApproveJoinRequests: true, + }; + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(studentAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId, params }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, params, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.put(`${scope}/${scopeId}/start`, params); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when user has the required permission in room scope', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + ]); + em.clear(); + + const params: VideoConferenceCreateParams = { + everyAttendeeJoinsMuted: true, + everybodyJoinsAsModerator: true, + moderatorMustApproveJoinRequests: true, + }; + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + mockBbbMeetingInfoFailed(scopeId); + mockBbbCreateSuccess(scopeId); + + return { loggedInClient, scope, scopeId, params }; + }; + + it('should create the conference successfully and return with ok', async () => { + const { loggedInClient, params, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.put(`${scope}/${scopeId}/start`, params); + + expect(response.status).toEqual(HttpStatus.OK); + }); + }); + }); + + describe('when conference is for scope and scopeId is already running', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + ]); + em.clear(); + + const params: VideoConferenceCreateParams = { + everyAttendeeJoinsMuted: true, + everybodyJoinsAsModerator: true, + moderatorMustApproveJoinRequests: true, + }; + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoSuccess(scopeId); + + return { loggedInClient, scope, scopeId, params }; + }; + + it('should return ok', async () => { + const { loggedInClient, params, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.put(`${scope}/${scopeId}/start`, params); + + expect(response.status).toEqual(HttpStatus.OK); + }); + }); + + describe('[GET] /videoconference2/:scope/:scopeId/join', () => { + describe('when user is unauthorized', () => { + it('should return unauthorized', async () => { + const response: Response = await testApiClient.get('/anyScope/anyId/join'); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when scope and scopeId are given', () => { + describe('when school has not enabled the school feature videoconference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.VIDEO_CONFERENCE_ELEMENTS, + target: elementNode.id, + }); + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/join`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when user has the required permission', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.VIDEO_CONFERENCE_ELEMENTS, + target: elementNode.id, + }); + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoSuccess(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return the conference', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/join`); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + url: expect.any(String), + }); + }); + }); + + describe('when conference is not running', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.VIDEO_CONFERENCE_ELEMENTS, + target: elementNode.id, + }); + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return internal server error', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/join`); + + expect(response.status).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); + }); + }); + }); + }); + + describe('[GET] /videoconference2/:scope/:scopeId/info', () => { + describe('when user is unauthorized', () => { + it('should return unauthorized', async () => { + const response: Response = await testApiClient.get('/anyScope/anyId/info'); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when scope and scopeId are given', () => { + describe('when school has not enabled the school feature videoconference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.VIDEO_CONFERENCE_ELEMENTS, + target: elementNode.id, + }); + + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/info`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when user has the required permission', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.VIDEO_CONFERENCE_ELEMENTS, + target: elementNode.id, + }); + + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoSuccess(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return ok', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/info`); + + expect(response.status).toEqual(HttpStatus.OK); + }); + }); + + describe('when guest want meeting info of conference without waiting room', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const expertRole: Role = roleFactory.buildWithId({ + name: RoleName.EXPERT, + permissions: [Permission.JOIN_MEETING], + }); + + const expertUser: User = userFactory.buildWithId({ school, roles: [expertRole] }); + const expertAccount: AccountEntity = accountFactory.buildWithId({ userId: expertUser.id }); + + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: expertRole, user: expertUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.VIDEO_CONFERENCE_ELEMENTS, + target: elementNode.id, + options: { moderatorMustApproveJoinRequests: false }, + }); + + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + expertAccount, + expertUser, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(expertAccount); + + mockBbbMeetingInfoSuccess(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/info`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when conference is not running', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.VIDEO_CONFERENCE_ELEMENTS, + target: elementNode.id, + }); + + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return ok', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/info`); + + expect(response.status).toEqual(HttpStatus.OK); + }); + }); + }); + }); + + describe('[PUT] /videoconference2/:scope/:scopeId/end', () => { + describe('when user is unauthorized', () => { + it('should return unauthorized', async () => { + const response: Response = await testApiClient.get('/anyScope/anyId/end'); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when scope and scopeId are given', () => { + describe('when school has not enabled the school feature videoconference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.VIDEO_CONFERENCE_ELEMENTS, + target: elementNode.id, + }); + + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/end`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when a user without required permission wants to end a conference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleViewer, user: studentUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.VIDEO_CONFERENCE_ELEMENTS, + target: elementNode.id, + }); + + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + studentAccount, + studentUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(studentAccount); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/end`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when a user with required permission wants to end a conference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.VIDEO_CONFERENCE_ELEMENTS, + target: elementNode.id, + }); + + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbEndSuccess(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return ok', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/end`); + + expect(response.status).toEqual(HttpStatus.OK); + }); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts b/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts index 3bc50b9f20f..1ee7f27ddb0 100644 --- a/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts +++ b/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts @@ -14,10 +14,18 @@ import { Course, TeamUserEntity } from '@shared/domain/entity'; import { Permission, RoleName, VideoConferenceScope } from '@shared/domain/interface'; import { EntityId, SchoolFeature } from '@shared/domain/types'; import { TeamsRepo, VideoConferenceRepo } from '@shared/repo'; -import { courseFactory, roleFactory, setupEntities, userDoFactory, userFactory } from '@shared/testing'; +import { courseFactory, groupFactory, roleFactory, setupEntities, userDoFactory, userFactory } from '@shared/testing'; import { teamFactory } from '@shared/testing/factory/team.factory'; import { teamUserFactory } from '@shared/testing/factory/teamuser.factory'; import { videoConferenceDOFactory } from '@shared/testing/factory/video-conference.do.factory'; +import { BoardNodeAuthorizable, BoardNodeAuthorizableService, BoardNodeService, BoardRoles } from '@src/modules/board'; +import { RoomService } from '@src/modules/room'; +import { RoomMembershipService } from '@src/modules/room-membership'; +import { GroupTypes } from '@src/modules/group'; +import { roomMembershipFactory } from '@src/modules/room-membership/testing'; +import { roomFactory } from '@src/modules/room/testing'; +import { columnBoardFactory, videoConferenceElementFactory } from '@src/modules/board/testing'; +import { VideoConferenceElement } from '@src/modules/board/domain'; import { BBBRole } from '../bbb'; import { ErrorStatus } from '../error'; import { VideoConferenceOptions } from '../interface'; @@ -27,9 +35,13 @@ import { VideoConferenceService } from './video-conference.service'; describe(VideoConferenceService.name, () => { let service: DeepMocked; + let boardNodeAuthorizableService: DeepMocked; + let boardNodeService: DeepMocked; let courseService: DeepMocked; let calendarService: DeepMocked; let authorizationService: DeepMocked; + let roomMembershipService: DeepMocked; + let roomService: DeepMocked; let schoolService: DeepMocked; let teamsRepo: DeepMocked; let userService: DeepMocked; @@ -40,6 +52,14 @@ describe(VideoConferenceService.name, () => { const module: TestingModule = await Test.createTestingModule({ providers: [ VideoConferenceService, + { + provide: BoardNodeAuthorizableService, + useValue: createMock(), + }, + { + provide: BoardNodeService, + useValue: createMock(), + }, { provide: ConfigService, useValue: createMock>(), @@ -60,6 +80,14 @@ describe(VideoConferenceService.name, () => { provide: LegacySchoolService, useValue: createMock(), }, + { + provide: RoomMembershipService, + useValue: createMock(), + }, + { + provide: RoomService, + useValue: createMock(), + }, { provide: TeamsRepo, useValue: createMock(), @@ -76,9 +104,13 @@ describe(VideoConferenceService.name, () => { }).compile(); service = module.get(VideoConferenceService); + boardNodeAuthorizableService = module.get(BoardNodeAuthorizableService); + boardNodeService = module.get(BoardNodeService); courseService = module.get(CourseService); calendarService = module.get(CalendarService); authorizationService = module.get(AuthorizationService); + roomMembershipService = module.get(RoomMembershipService); + roomService = module.get(RoomService); schoolService = module.get(LegacySchoolService); teamsRepo = module.get(TeamsRepo); userService = module.get(UserService); @@ -169,6 +201,78 @@ describe(VideoConferenceService.name, () => { }); }); + describe('when user has EXPERT role for a room', () => { + const setup = () => { + const user: UserDO = userDoFactory + .withRoles([{ id: new ObjectId().toHexString(), name: RoleName.EXPERT }]) + .build({ id: new ObjectId().toHexString() }); + const userId = user.id as EntityId; + const scopeId = new ObjectId().toHexString(); + + configService.get.mockReturnValueOnce('https://api.example.com'); + userService.findById.mockResolvedValueOnce(user); + + return { + user, + userId, + conferenceScope: VideoConferenceScope.ROOM, + scopeId, + }; + }; + + it('should return true', async () => { + const { conferenceScope, userId, scopeId } = setup(); + + const result = await service.hasExpertRole(userId, conferenceScope, scopeId); + + expect(result).toBe(true); + }); + + it('should call userService.findById', async () => { + const { conferenceScope, userId, scopeId } = setup(); + + await service.hasExpertRole(userId, conferenceScope, scopeId); + + expect(userService.findById).toHaveBeenCalledWith(userId); + }); + }); + + describe('when user has EXPERT role for a video conference element', () => { + const setup = () => { + const user: UserDO = userDoFactory + .withRoles([{ id: new ObjectId().toHexString(), name: RoleName.EXPERT }]) + .build({ id: new ObjectId().toHexString() }); + const userId = user.id as EntityId; + const scopeId = new ObjectId().toHexString(); + + configService.get.mockReturnValueOnce('https://api.example.com'); + userService.findById.mockResolvedValueOnce(user); + + return { + user, + userId, + conferenceScope: VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT, + scopeId, + }; + }; + + it('should return true', async () => { + const { conferenceScope, userId, scopeId } = setup(); + + const result = await service.hasExpertRole(userId, conferenceScope, scopeId); + + expect(result).toBe(true); + }); + + it('should call userService.findById', async () => { + const { conferenceScope, userId, scopeId } = setup(); + + await service.hasExpertRole(userId, conferenceScope, scopeId); + + expect(userService.findById).toHaveBeenCalledWith(userId); + }); + }); + describe('when user does not have the EXPERT role for a course conference', () => { const setup = () => { const user: UserDO = userDoFactory @@ -364,6 +468,99 @@ describe(VideoConferenceService.name, () => { }); }); + describe('when user has room editor role in room scope', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const group = groupFactory.build({ + type: GroupTypes.ROOM, + users: [{ userId: user.id, roleId: roleEditor.id }], + }); + const room = roomFactory.build(); + roomMembershipFactory.build({ roomId: room.id, userGroupId: group.id }); + const conferenceScope = VideoConferenceScope.ROOM; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + roomMembershipService.getRoomMembershipAuthorizable.mockResolvedValueOnce({ + id: 'foo', + roomId: room.id, + members: [{ userId: user.id, roles: [roleEditor] }], + schoolId: room.schoolId, + }); + roomService.getSingleRoom.mockResolvedValueOnce(room); + + return { + user, + userId: user.id, + room, + roomId: room.id, + conferenceScope, + }; + }; + + it('should call the correct service', async () => { + const { userId, conferenceScope, roomId } = setup(); + + await service.determineBbbRole(userId, roomId, conferenceScope); + + expect(roomMembershipService.getRoomMembershipAuthorizable).toHaveBeenCalledWith(roomId); + }); + + it('should return BBBRole.MODERATOR', async () => { + const { userId, conferenceScope, roomId } = setup(); + + const result = await service.determineBbbRole(userId, roomId, conferenceScope); + + expect(result).toBe(BBBRole.MODERATOR); + }); + }); + + describe('when user has editor role in video conference node', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const element = videoConferenceElementFactory.build(); + const conferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + const boardNodeAuthorizable = new BoardNodeAuthorizable({ + users: [{ userId: user.id, roles: [BoardRoles.EDITOR] }], + id: element.id, + boardNode: element, + rootNode: columnBoardFactory.build(), + }); + boardNodeAuthorizableService.getBoardAuthorizable.mockResolvedValueOnce(boardNodeAuthorizable); + boardNodeService.findByClassAndId.mockResolvedValueOnce(element); + + return { + user, + userId: user.id, + element, + elementId: element.id, + conferenceScope, + }; + }; + + it('should call the correct service', async () => { + const { userId, conferenceScope, element, elementId } = setup(); + + await service.determineBbbRole(userId, elementId, conferenceScope); + + expect(boardNodeAuthorizableService.getBoardAuthorizable).toHaveBeenCalledWith(element); + }); + + it('should return BBBRole.MODERATOR', async () => { + const { userId, conferenceScope, elementId } = setup(); + + const result = await service.determineBbbRole(userId, elementId, conferenceScope); + + expect(result).toBe(BBBRole.MODERATOR); + }); + }); + // can be removed when team / course / user is passed from UC // missing when course / team loading throw an error, but also not nessasary if it is passed to UC. describe('when user has START_MEETING permission and is in team(event) scope', () => { @@ -406,7 +603,7 @@ describe(VideoConferenceService.name, () => { }); }); - describe('when user has JOIN_MEETING permission', () => { + describe('when user has JOIN_MEETING permission and is in course scope', () => { const setup = () => { const user = userFactory.buildWithId(); const entity = courseFactory.buildWithId(); @@ -453,7 +650,107 @@ describe(VideoConferenceService.name, () => { }); }); - describe('when user has neither START_MEETING nor JOIN_MEETING permission', () => { + describe('when user has room viewer role in room scope', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const roleViewer = roleFactory.buildWithId({ name: RoleName.ROOMVIEWER, permissions: [Permission.ROOM_VIEW] }); + const group = groupFactory.build({ + type: GroupTypes.ROOM, + users: [{ userId: user.id, roleId: roleViewer.id }], + }); + const room = roomFactory.build(); + roomMembershipFactory.build({ roomId: room.id, userGroupId: group.id }); + const conferenceScope = VideoConferenceScope.ROOM; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + roomMembershipService.getRoomMembershipAuthorizable + .mockResolvedValueOnce({ + id: 'foo', + roomId: room.id, + members: [{ userId: user.id, roles: [roleViewer] }], + schoolId: room.schoolId, + }) + .mockResolvedValueOnce({ + id: 'foo', + roomId: room.id, + members: [{ userId: user.id, roles: [roleViewer] }], + schoolId: room.schoolId, + }); + roomService.getSingleRoom.mockResolvedValueOnce(room); + + return { + user, + userId: user.id, + room, + roomId: room.id, + conferenceScope, + }; + }; + + it('should call the correct service', async () => { + const { userId, conferenceScope, roomId } = setup(); + + await service.determineBbbRole(userId, roomId, conferenceScope); + + expect(roomMembershipService.getRoomMembershipAuthorizable).toHaveBeenCalledWith(roomId); + }); + + it('should return BBBRole.VIEWER', async () => { + jest.restoreAllMocks(); + const { userId, conferenceScope, roomId } = setup(); + + const result = await service.determineBbbRole(userId, roomId, conferenceScope); + + expect(result).toBe(BBBRole.VIEWER); + }); + }); + + describe('when user has reader role in video conference node', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const element = videoConferenceElementFactory.build(); + const conferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + const boardNodeAuthorizable = new BoardNodeAuthorizable({ + users: [{ userId: user.id, roles: [BoardRoles.READER] }], + id: element.id, + boardNode: element, + rootNode: columnBoardFactory.build(), + }); + boardNodeAuthorizableService.getBoardAuthorizable + .mockResolvedValueOnce(boardNodeAuthorizable) + .mockResolvedValueOnce(boardNodeAuthorizable); + boardNodeService.findByClassAndId.mockResolvedValueOnce(element); + + return { + user, + userId: user.id, + element, + elementId: element.id, + conferenceScope, + }; + }; + + it('should call the correct service', async () => { + const { userId, conferenceScope, element, elementId } = setup(); + + await service.determineBbbRole(userId, elementId, conferenceScope); + + expect(boardNodeAuthorizableService.getBoardAuthorizable).toHaveBeenCalledWith(element); + }); + + it('should return BBBRole.VIEWER', async () => { + const { userId, conferenceScope, elementId } = setup(); + + const result = await service.determineBbbRole(userId, elementId, conferenceScope); + + expect(result).toBe(BBBRole.VIEWER); + }); + }); + + describe('when user has neither START_MEETING nor JOIN_MEETING permission in course scope', () => { const setup = () => { const user = userFactory.buildWithId(); const entity = courseFactory.buildWithId(); @@ -480,6 +777,172 @@ describe(VideoConferenceService.name, () => { await expect(callDetermineBbbRole).rejects.toThrow(new ForbiddenException(ErrorStatus.INSUFFICIENT_PERMISSION)); }); }); + + describe('when user has neither editor nor viewer role in room scope and is not authorized for the room', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const role = roleFactory.buildWithId(); + const group = groupFactory.build({ + type: GroupTypes.ROOM, + users: [{ userId: user.id, roleId: role.id }], + }); + const room = roomFactory.build(); + roomMembershipFactory.build({ roomId: room.id, userGroupId: group.id }); + const conferenceScope = VideoConferenceScope.ROOM; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + roomMembershipService.getRoomMembershipAuthorizable + .mockResolvedValueOnce({ + id: 'foo', + roomId: room.id, + members: [{ userId: 'anotherUserId', roles: [] }], + schoolId: room.schoolId, + }) + .mockResolvedValueOnce({ + id: 'foo', + roomId: room.id, + members: [{ userId: 'anotherUserId', roles: [] }], + schoolId: room.schoolId, + }); + roomService.getSingleRoom.mockResolvedValueOnce(room); + + return { + user, + userId: user.id, + room, + roomId: room.id, + conferenceScope, + }; + }; + + it('should throw a ForbiddenException', async () => { + const { userId, conferenceScope, roomId } = setup(); + + const callDetermineBbbRole = () => service.determineBbbRole(userId, roomId, conferenceScope); + + await expect(callDetermineBbbRole).rejects.toThrow(new ForbiddenException(ErrorStatus.INSUFFICIENT_PERMISSION)); + }); + }); + + describe('when user has neither editor nor viewer role in room scope but is authorized for the room', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const role = roleFactory.buildWithId(); + const group = groupFactory.build({ + type: GroupTypes.ROOM, + users: [{ userId: user.id, roleId: role.id }], + }); + const room = roomFactory.build(); + roomMembershipFactory.build({ roomId: room.id, userGroupId: group.id }); + const conferenceScope = VideoConferenceScope.ROOM; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + roomMembershipService.getRoomMembershipAuthorizable + .mockResolvedValueOnce({ + id: 'foo', + roomId: room.id, + members: [{ userId: user.id, roles: [] }], + schoolId: room.schoolId, + }) + .mockResolvedValueOnce({ + id: 'foo', + roomId: room.id, + members: [{ userId: user.id, roles: [] }], + schoolId: room.schoolId, + }); + roomService.getSingleRoom.mockResolvedValueOnce(room); + + return { + user, + userId: user.id, + room, + roomId: room.id, + conferenceScope, + }; + }; + + it('should throw a ForbiddenException', async () => { + const { userId, conferenceScope, roomId } = setup(); + + const callDetermineBbbRole = () => service.determineBbbRole(userId, roomId, conferenceScope); + + await expect(callDetermineBbbRole).rejects.toThrow(new ForbiddenException(ErrorStatus.INSUFFICIENT_PERMISSION)); + }); + }); + + describe('when user has neither editor nor reader role in video conference node and is not authorized for the node', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const element = videoConferenceElementFactory.build(); + const conferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + const boardNodeAuthorizable = new BoardNodeAuthorizable({ + users: [{ userId: 'anotherUserId', roles: [] }], + id: element.id, + boardNode: element, + rootNode: columnBoardFactory.build(), + }); + boardNodeAuthorizableService.getBoardAuthorizable + .mockResolvedValueOnce(boardNodeAuthorizable) + .mockResolvedValueOnce(boardNodeAuthorizable); + boardNodeService.findByClassAndId.mockResolvedValueOnce(element); + + return { + user, + userId: user.id, + element, + elementId: element.id, + conferenceScope, + }; + }; + + it('should throw a ForbiddenException', async () => { + const { userId, conferenceScope, elementId } = setup(); + + const callDetermineBbbRole = () => service.determineBbbRole(userId, elementId, conferenceScope); + + await expect(callDetermineBbbRole).rejects.toThrow(new ForbiddenException(ErrorStatus.INSUFFICIENT_PERMISSION)); + }); + }); + + describe('when user has neither editor nor reader role in video conference node but is authorized for the node', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const element = videoConferenceElementFactory.build(); + const conferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + const boardNodeAuthorizable = new BoardNodeAuthorizable({ + users: [{ userId: user.id, roles: [] }], + id: element.id, + boardNode: element, + rootNode: columnBoardFactory.build(), + }); + boardNodeAuthorizableService.getBoardAuthorizable + .mockResolvedValueOnce(boardNodeAuthorizable) + .mockResolvedValueOnce(boardNodeAuthorizable); + boardNodeService.findByClassAndId.mockResolvedValueOnce(element); + + return { + user, + userId: user.id, + element, + elementId: element.id, + conferenceScope, + }; + }; + + it('should throw a ForbiddenException', async () => { + const { userId, conferenceScope, elementId } = setup(); + + const callDetermineBbbRole = () => service.determineBbbRole(userId, elementId, conferenceScope); + + await expect(callDetermineBbbRole).rejects.toThrow(new ForbiddenException(ErrorStatus.INSUFFICIENT_PERMISSION)); + }); + }); }); describe('throwOnFeaturesDisabled', () => { @@ -541,21 +1004,22 @@ describe(VideoConferenceService.name, () => { describe('getScopeInfo', () => { const setup = () => { const userId = 'user-id'; - const conferenceScope: VideoConferenceScope = VideoConferenceScope.COURSE; + const scopeId = new ObjectId().toHexString(); configService.get.mockReturnValue('https://api.example.com'); return { userId, - conferenceScope, + scopeId, }; }; describe('when conference scope is VideoConferenceScope.COURSE', () => { it('should return scope information for a course', async () => { - const { userId, conferenceScope, scopeId } = setup(); + const { userId, scopeId } = setup(); + const conferenceScope: VideoConferenceScope = VideoConferenceScope.COURSE; const course: Course = courseFactory.buildWithId({ name: 'Course' }); course.id = scopeId; courseService.findById.mockResolvedValue(course); @@ -564,7 +1028,7 @@ describe(VideoConferenceService.name, () => { expect(result).toEqual({ scopeId, - scopeName: 'courses', + scopeName: VideoConferenceScope.COURSE, logoutUrl: `${service.hostUrl}/courses/${scopeId}?activeTab=tools`, title: course.name, }); @@ -572,6 +1036,44 @@ describe(VideoConferenceService.name, () => { }); }); + describe('when conference scope is VideoConferenceScope.ROOM', () => { + it('should return scope information for a room', async () => { + const { userId } = setup(); + const conferenceScope: VideoConferenceScope = VideoConferenceScope.ROOM; + const room = roomFactory.build({ name: 'Room' }); + roomService.getSingleRoom.mockResolvedValueOnce(room); + + const result: ScopeInfo = await service.getScopeInfo(userId, room.id, conferenceScope); + + expect(result).toEqual({ + scopeId: room.id, + scopeName: VideoConferenceScope.ROOM, + logoutUrl: `${service.hostUrl}/rooms/${room.id}`, + title: room.name, + }); + expect(roomService.getSingleRoom).toHaveBeenCalledWith(room.id); + }); + }); + + describe('when conference scope is VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT', () => { + it('should return scope information for a video conference element', async () => { + const { userId } = setup(); + const conferenceScope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const element = videoConferenceElementFactory.build({ title: 'Element' }); + boardNodeService.findByClassAndId.mockResolvedValueOnce(element); + + const result: ScopeInfo = await service.getScopeInfo(userId, element.id, conferenceScope); + + expect(result).toEqual({ + scopeId: element.id, + scopeName: VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT, + logoutUrl: `${service.hostUrl}/boards/${element.id}`, + title: element.title, + }); + expect(boardNodeService.findByClassAndId).toHaveBeenCalledWith(VideoConferenceElement, element.id); + }); + }); + describe('when conference scope is VideoConferenceScope.EVENT', () => { it('should return scope information for a event', async () => { const { userId, scopeId } = setup(); @@ -583,7 +1085,7 @@ describe(VideoConferenceService.name, () => { expect(result).toEqual({ scopeId: teamId, - scopeName: 'teams', + scopeName: VideoConferenceScope.EVENT, logoutUrl: `${service.hostUrl}/teams/${teamId}?activeTab=events`, title: event.title, }); @@ -602,14 +1104,46 @@ describe(VideoConferenceService.name, () => { }); }); - describe('getUserRoleAndGuestStatusByUserId', () => { + describe('getUserRoleAndGuestStatusByUserIdForBbb', () => { const setup = (conferenceScope: VideoConferenceScope) => { const user: UserDO = userDoFactory.buildWithId(); const userId = user.id as EntityId; + const roomUser = userFactory.buildWithId(); const scopeId = new ObjectId().toHexString(); const team = teamFactory .withRoleAndUserId(roleFactory.build({ name: RoleName.EXPERT }), new ObjectId().toHexString()) .build(); + const roleEditor = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_EDIT] }); + const group = groupFactory.build({ + type: GroupTypes.ROOM, + users: [{ userId: roomUser.id, roleId: roleEditor.id }], + }); + const room = roomFactory.build(); + roomMembershipFactory.build({ roomId: room.id, userGroupId: group.id }); + roomMembershipService.getRoomMembershipAuthorizable + .mockResolvedValueOnce({ + id: 'foo', + roomId: room.id, + members: [{ userId: roomUser.id, roles: [roleEditor] }], + schoolId: room.schoolId, + }) + .mockResolvedValueOnce({ + id: 'foo', + roomId: room.id, + members: [{ userId: roomUser.id, roles: [roleEditor] }], + schoolId: room.schoolId, + }); + + const element = videoConferenceElementFactory.build(); + const boardNodeAuthorizable = new BoardNodeAuthorizable({ + users: [{ userId: roomUser.id, roles: [BoardRoles.READER] }], + id: element.id, + boardNode: element, + rootNode: columnBoardFactory.build(), + }); + boardNodeAuthorizableService.getBoardAuthorizable + .mockResolvedValueOnce(boardNodeAuthorizable) + .mockResolvedValueOnce(boardNodeAuthorizable); configService.get.mockReturnValue('https://api.example.com'); @@ -617,6 +1151,7 @@ describe(VideoConferenceService.name, () => { user, userId, conferenceScope, + roomUser, scopeId, team, }; @@ -652,6 +1187,67 @@ describe(VideoConferenceService.name, () => { }); }); + describe('when conference scope is VideoConferenceScope.ROOM', () => { + it('should call roomService.getSingleRoom', async () => { + const { user, userId, conferenceScope, scopeId } = setup(VideoConferenceScope.ROOM); + userService.findById.mockResolvedValue(user); + + await service.getUserRoleAndGuestStatusByUserIdForBbb(userId, scopeId, conferenceScope); + + expect(roomService.getSingleRoom).toHaveBeenCalledWith(scopeId); + }); + + it('should call userService.findById', async () => { + const { user, userId, conferenceScope, scopeId } = setup(VideoConferenceScope.ROOM); + userService.findById.mockResolvedValue(user); + + await service.getUserRoleAndGuestStatusByUserIdForBbb(userId, scopeId, conferenceScope); + + expect(userService.findById).toHaveBeenCalledWith(userId); + }); + + it('should return the user role and guest status for a room conference', async () => { + const { user, userId, conferenceScope, roomUser, scopeId } = setup(VideoConferenceScope.ROOM); + roomService.getSingleRoom.mockResolvedValue(roomFactory.build({ name: 'Room' })); + userService.findById.mockResolvedValue(user); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(roomUser); + + const result = await service.getUserRoleAndGuestStatusByUserIdForBbb(userId, scopeId, conferenceScope); + + expect(result).toEqual({ role: BBBRole.MODERATOR, isGuest: false }); + }); + }); + + describe('when conference scope is VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT', () => { + it('should call boardNodeService.findByClassAndId', async () => { + const { user, userId, conferenceScope, scopeId } = setup(VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT); + userService.findById.mockResolvedValue(user); + + await service.getUserRoleAndGuestStatusByUserIdForBbb(userId, scopeId, conferenceScope); + + expect(boardNodeService.findByClassAndId).toHaveBeenCalledWith(VideoConferenceElement, scopeId); + }); + + it('should call userService.findById', async () => { + const { user, userId, conferenceScope, scopeId } = setup(VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT); + userService.findById.mockResolvedValue(user); + + await service.getUserRoleAndGuestStatusByUserIdForBbb(userId, scopeId, conferenceScope); + + expect(userService.findById).toHaveBeenCalledWith(userId); + }); + + it('should return the user role and guest status for a video conference element conference', async () => { + const { user, userId, conferenceScope, scopeId } = setup(VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT); + courseService.findById.mockResolvedValue(courseFactory.buildWithId({ name: 'Course' })); + userService.findById.mockResolvedValue(user); + + const result = await service.getUserRoleAndGuestStatusByUserIdForBbb(userId, scopeId, conferenceScope); + + expect(result).toEqual({ role: BBBRole.MODERATOR, isGuest: false }); + }); + }); + describe('when conference scope is VideoConferenceScope.EVENT', () => { it('should throw a ForbiddenException if the user is not an expert for an event conference', async () => { const { userId, scopeId, team } = setup(VideoConferenceScope.EVENT); diff --git a/apps/server/src/modules/video-conference/service/video-conference.service.ts b/apps/server/src/modules/video-conference/service/video-conference.service.ts index f910e9e215b..68dd4f1a6f8 100644 --- a/apps/server/src/modules/video-conference/service/video-conference.service.ts +++ b/apps/server/src/modules/video-conference/service/video-conference.service.ts @@ -10,19 +10,29 @@ import { Course, TeamEntity, TeamUserEntity, User } from '@shared/domain/entity' import { Permission, RoleName, VideoConferenceScope } from '@shared/domain/interface'; import { EntityId, SchoolFeature } from '@shared/domain/types'; import { TeamsRepo, VideoConferenceRepo } from '@shared/repo'; +import { BoardNodeAuthorizableService, BoardNodeService, BoardRoles } from '@src/modules/board'; +import { VideoConferenceElement } from '@src/modules/board/domain'; +import { Room, RoomService } from '@src/modules/room'; +import { RoomMembershipService } from '@src/modules/room-membership'; import { BBBRole } from '../bbb'; import { ErrorStatus } from '../error'; import { VideoConferenceOptions } from '../interface'; import { ScopeInfo, VideoConferenceState } from '../uc/dto'; import { VideoConferenceConfig } from '../video-conference-config'; +type ConferenceResource = Course | Room | TeamEntity | VideoConferenceElement; + @Injectable() export class VideoConferenceService { constructor( + private readonly boardNodeAuthorizableService: BoardNodeAuthorizableService, + private readonly boardNodeService: BoardNodeService, private readonly configService: ConfigService, private readonly courseService: CourseService, private readonly calendarService: CalendarService, private readonly authorizationService: AuthorizationService, + private readonly roomMembershipService: RoomMembershipService, + private readonly roomService: RoomService, private readonly schoolService: LegacySchoolService, private readonly teamsRepo: TeamsRepo, private readonly userService: UserService, @@ -51,7 +61,9 @@ export class VideoConferenceService { ): Promise { let isExpert = false; switch (conferenceScope) { - case VideoConferenceScope.COURSE: { + case VideoConferenceScope.COURSE: + case VideoConferenceScope.ROOM: + case VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT: { const user: UserDO = await this.userService.findById(userId); isExpert = this.existsOnlyExpertRole(user.roles); @@ -88,35 +100,76 @@ export class VideoConferenceService { } // should be public to expose ressources to UC for passing it to authrisation and improve performance - private async loadScopeRessources( - scopeId: EntityId, - scope: VideoConferenceScope - ): Promise { - let scopeRessource: Course | TeamEntity | null = null; + private async loadScopeResources(scopeId: EntityId, scope: VideoConferenceScope): Promise { + let scopeResource: ConferenceResource | null = null; if (scope === VideoConferenceScope.COURSE) { - scopeRessource = await this.courseService.findById(scopeId); + scopeResource = await this.courseService.findById(scopeId); } else if (scope === VideoConferenceScope.EVENT) { - scopeRessource = await this.teamsRepo.findById(scopeId); + scopeResource = await this.teamsRepo.findById(scopeId); + } else if (scope === VideoConferenceScope.ROOM) { + scopeResource = await this.roomService.getSingleRoom(scopeId); + } else if (scope === VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT) { + scopeResource = await this.boardNodeService.findByClassAndId(VideoConferenceElement, scopeId); } else { // Need to be solve the null with throw by it self. } - return scopeRessource; + return scopeResource; } private isNullOrUndefined(value: unknown): value is null { return !value; } - private hasStartMeetingAndCanRead(authorizableUser: User, entity: Course | TeamEntity): boolean { + private async hasStartMeetingAndCanRead(authorizableUser: User, entity: ConferenceResource): Promise { + if (entity instanceof Room) { + const roomMembershipAuthorizable = await this.roomMembershipService.getRoomMembershipAuthorizable(entity.id); + const roomMember = roomMembershipAuthorizable.members.find((member) => member.userId === authorizableUser.id); + + if (roomMember) { + return roomMember.roles.some((role) => role.name === RoleName.ROOMEDITOR); + } + + return false; + } + if (entity instanceof VideoConferenceElement) { + const boardDoAuthorizable = await this.boardNodeAuthorizableService.getBoardAuthorizable(entity); + const boardAuthorisedUser = boardDoAuthorizable.users.find((user) => user.userId === authorizableUser.id); + + if (boardAuthorisedUser) { + return boardAuthorisedUser?.roles.includes(BoardRoles.EDITOR); + } + + return false; + } const context = AuthorizationContextBuilder.read([Permission.START_MEETING]); const hasPermission = this.authorizationService.hasPermission(authorizableUser, entity, context); return hasPermission; } - private hasJoinMeetingAndCanRead(authorizableUser: User, entity: Course | TeamEntity): boolean { + private async hasJoinMeetingAndCanRead(authorizableUser: User, entity: ConferenceResource): Promise { + if (entity instanceof Room) { + const roomMembershipAuthorizable = await this.roomMembershipService.getRoomMembershipAuthorizable(entity.id); + const roomMember = roomMembershipAuthorizable.members.find((member) => member.userId === authorizableUser.id); + + if (roomMember) { + return roomMember.roles.some((role) => role.name === RoleName.ROOMVIEWER); + } + + return false; + } + if (entity instanceof VideoConferenceElement) { + const boardDoAuthorizable = await this.boardNodeAuthorizableService.getBoardAuthorizable(entity); + const boardAuthorisedUser = boardDoAuthorizable.users.find((user) => user.userId === authorizableUser.id); + + if (boardAuthorisedUser) { + return boardAuthorisedUser?.roles.includes(BoardRoles.READER); + } + + return false; + } const context = AuthorizationContextBuilder.read([Permission.JOIN_MEETING]); const hasPermission = this.authorizationService.hasPermission(authorizableUser, entity, context); @@ -125,16 +178,16 @@ export class VideoConferenceService { async determineBbbRole(userId: EntityId, scopeId: EntityId, scope: VideoConferenceScope): Promise { // ressource loading need to be move to uc - const [authorizableUser, scopeRessource]: [User, TeamEntity | Course | null] = await Promise.all([ + const [authorizableUser, scopeResource]: [User, ConferenceResource | null] = await Promise.all([ this.authorizationService.getUserWithPermissions(userId), - this.loadScopeRessources(scopeId, scope), + this.loadScopeResources(scopeId, scope), ]); - if (!this.isNullOrUndefined(scopeRessource)) { - if (this.hasStartMeetingAndCanRead(authorizableUser, scopeRessource)) { + if (!this.isNullOrUndefined(scopeResource)) { + if (await this.hasStartMeetingAndCanRead(authorizableUser, scopeResource)) { return BBBRole.MODERATOR; } - if (this.hasJoinMeetingAndCanRead(authorizableUser, scopeRessource)) { + if (await this.hasJoinMeetingAndCanRead(authorizableUser, scopeResource)) { return BBBRole.VIEWER; } } @@ -167,7 +220,7 @@ export class VideoConferenceService { return { scopeId, - scopeName: 'courses', + scopeName: VideoConferenceScope.COURSE, logoutUrl: `${this.hostUrl}/courses/${scopeId}?activeTab=tools`, title: course.name, }; @@ -177,11 +230,31 @@ export class VideoConferenceService { return { scopeId: event.teamId, - scopeName: 'teams', + scopeName: VideoConferenceScope.EVENT, logoutUrl: `${this.hostUrl}/teams/${event.teamId}?activeTab=events`, title: event.title, }; } + case VideoConferenceScope.ROOM: { + const room: Room = await this.roomService.getSingleRoom(scopeId); + + return { + scopeId: room.id, + scopeName: VideoConferenceScope.ROOM, + logoutUrl: `${this.hostUrl}/rooms/${room.id}`, + title: room.name, + }; + } + case VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT: { + const element = await this.boardNodeService.findByClassAndId(VideoConferenceElement, scopeId); + + return { + scopeId: element.id, + scopeName: VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT, + logoutUrl: `${this.hostUrl}/boards/${element.rootId}`, + title: element.title, + }; + } default: throw new BadRequestException('Unknown scope name'); } diff --git a/apps/server/src/modules/video-conference/uc/dto/scope-info.interface.ts b/apps/server/src/modules/video-conference/uc/dto/scope-info.interface.ts index 5ec0decd414..3f113faad68 100644 --- a/apps/server/src/modules/video-conference/uc/dto/scope-info.interface.ts +++ b/apps/server/src/modules/video-conference/uc/dto/scope-info.interface.ts @@ -1,9 +1,10 @@ +import { VideoConferenceScope } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; export interface ScopeInfo { scopeId: EntityId; - scopeName: string; + scopeName: VideoConferenceScope; title: string; diff --git a/apps/server/src/modules/video-conference/uc/video-conference-create.uc.spec.ts b/apps/server/src/modules/video-conference/uc/video-conference-create.uc.spec.ts index 5af0215ba79..a2781a75a47 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-create.uc.spec.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-create.uc.spec.ts @@ -91,7 +91,7 @@ describe('VideoConferenceCreateUc', () => { const scopeInfo: ScopeInfo = { scopeId: scope.id, - scopeName: 'scopeName', + scopeName: VideoConferenceScope.COURSE, title: 'title', logoutUrl: 'logoutUrl', }; @@ -166,7 +166,7 @@ describe('VideoConferenceCreateUc', () => { const scopeInfo: ScopeInfo = { scopeId: scope.id, - scopeName: 'scopeName', + scopeName: VideoConferenceScope.COURSE, title: 'title', logoutUrl: 'logoutUrl', }; diff --git a/apps/server/src/modules/video-conference/uc/video-conference-create.uc.ts b/apps/server/src/modules/video-conference/uc/video-conference-create.uc.ts index e67412b567d..6ff2b00d63f 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-create.uc.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-create.uc.ts @@ -40,9 +40,9 @@ export class VideoConferenceCreateUc { private async create(currentUserId: EntityId, scope: ScopeRef, options: VideoConferenceOptions): Promise { /* need to be replace with - const [authorizableUser, scopeRessource]: [User, TeamEntity | Course] = await Promise.all([ + const [authorizableUser, scopeResource]: [User, TeamEntity | Course] = await Promise.all([ this.authorizationService.getUserWithPermissions(userId), - this.videoConferenceService.loadScopeRessources(scopeId, scope), + this.videoConferenceService.loadScopeResources(scopeId, scope), ]); */ const user: UserDO = await this.userService.findById(currentUserId); diff --git a/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.spec.ts b/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.spec.ts index fc31febf442..2225a0ffbda 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.spec.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.spec.ts @@ -196,7 +196,7 @@ describe('VideoConferenceUc', () => { // Assert expect(result.scopeInfo.scopeId).toEqual(course.id); expect(result.scopeInfo.logoutUrl).toEqual(`${hostUrl}/courses/${course.id}?activeTab=tools`); - expect(result.scopeInfo.scopeName).toEqual('courses'); + expect(result.scopeInfo.scopeName).toEqual(VideoConferenceScope.COURSE); expect(result.scopeInfo.title).toEqual(course.name); expect(result.object).toEqual(course); }); @@ -209,7 +209,7 @@ describe('VideoConferenceUc', () => { expect(result.scopeInfo.scopeId).toEqual(event.teamId); expect(result.scopeInfo.title).toEqual(event.title); expect(result.scopeInfo.logoutUrl).toEqual(`${hostUrl}/teams/${event.teamId}?activeTab=events`); - expect(result.scopeInfo.scopeName).toEqual('teams'); + expect(result.scopeInfo.scopeName).toEqual(VideoConferenceScope.EVENT); expect(result.object).toEqual(team); }); diff --git a/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.ts b/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.ts index c9a162036b9..8eede135f7b 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.ts @@ -337,7 +337,7 @@ export class VideoConferenceDeprecatedUc { return { scopeInfo: { scopeId: refId, - scopeName: 'courses', + scopeName: VideoConferenceScope.COURSE, logoutUrl: `${this.hostURL}/courses/${refId}?activeTab=tools`, title: course.name, }, @@ -351,7 +351,7 @@ export class VideoConferenceDeprecatedUc { return { scopeInfo: { scopeId: event.teamId, - scopeName: 'teams', + scopeName: VideoConferenceScope.EVENT, logoutUrl: `${this.hostURL}/teams/${event.teamId}?activeTab=events`, title: event.title, }, diff --git a/apps/server/src/modules/video-conference/uc/video-conference-end.uc.spec.ts b/apps/server/src/modules/video-conference/uc/video-conference-end.uc.spec.ts index ee552b35c17..2c640f3980a 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-end.uc.spec.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-end.uc.spec.ts @@ -61,7 +61,7 @@ describe('VideoConferenceEndUc', () => { const scope = { scope: VideoConferenceScope.COURSE, id: new ObjectId().toHexString() }; const scopeInfo: ScopeInfo = { scopeId: scope.id, - scopeName: 'scopeName', + scopeName: VideoConferenceScope.COURSE, title: 'title', logoutUrl: 'logoutUrl', }; @@ -97,7 +97,7 @@ describe('VideoConferenceEndUc', () => { const scope = { scope: VideoConferenceScope.COURSE, id: new ObjectId().toHexString() }; const scopeInfo: ScopeInfo = { scopeId: scope.id, - scopeName: 'scopeName', + scopeName: VideoConferenceScope.COURSE, title: 'title', logoutUrl: 'logoutUrl', }; diff --git a/apps/server/src/modules/video-conference/uc/video-conference-end.uc.ts b/apps/server/src/modules/video-conference/uc/video-conference-end.uc.ts index 211ade03e79..063e8382936 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-end.uc.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-end.uc.ts @@ -18,9 +18,9 @@ export class VideoConferenceEndUc { async end(currentUserId: EntityId, scope: ScopeRef): Promise> { /* need to be replace with - const [authorizableUser, scopeRessource]: [User, TeamEntity | Course] = await Promise.all([ + const [authorizableUser, scopeResource]: [User, TeamEntity | Course] = await Promise.all([ this.authorizationService.getUserWithPermissions(userId), - this.videoConferenceService.loadScopeRessources(scopeId, scope), + this.videoConferenceService.loadScopeResources(scopeId, scope), ]); */ const user: UserDO = await this.userService.findById(currentUserId); diff --git a/apps/server/src/modules/video-conference/uc/video-conference-info.uc.spec.ts b/apps/server/src/modules/video-conference/uc/video-conference-info.uc.spec.ts index 4a9c7b77591..fd7a4e2ceb8 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-info.uc.spec.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-info.uc.spec.ts @@ -80,7 +80,7 @@ describe('VideoConferenceInfoUc', () => { const scope = { scope: VideoConferenceScope.COURSE, id: new ObjectId().toHexString() }; const scopeInfo: ScopeInfo = { scopeId: scope.id, - scopeName: 'scopeName', + scopeName: VideoConferenceScope.COURSE, title: 'title', logoutUrl: 'logoutUrl', }; @@ -121,7 +121,7 @@ describe('VideoConferenceInfoUc', () => { const scope = { scope: VideoConferenceScope.COURSE, id: new ObjectId().toHexString() }; const scopeInfo: ScopeInfo = { scopeId: scope.id, - scopeName: 'scopeName', + scopeName: VideoConferenceScope.COURSE, title: 'title', logoutUrl: 'logoutUrl', }; @@ -239,7 +239,7 @@ describe('VideoConferenceInfoUc', () => { const scope = { scope: VideoConferenceScope.COURSE, id: new ObjectId().toHexString() }; const scopeInfo: ScopeInfo = { scopeId: scope.id, - scopeName: 'scopeName', + scopeName: VideoConferenceScope.COURSE, title: 'title', logoutUrl: 'logoutUrl', }; @@ -287,7 +287,7 @@ describe('VideoConferenceInfoUc', () => { const scope = { scope: VideoConferenceScope.COURSE, id: new ObjectId().toHexString() }; const scopeInfo: ScopeInfo = { scopeId: scope.id, - scopeName: 'scopeName', + scopeName: VideoConferenceScope.COURSE, title: 'title', logoutUrl: 'logoutUrl', }; @@ -331,7 +331,7 @@ describe('VideoConferenceInfoUc', () => { const scope = { scope: VideoConferenceScope.COURSE, id: new ObjectId().toHexString() }; const scopeInfo: ScopeInfo = { scopeId: scope.id, - scopeName: 'scopeName', + scopeName: VideoConferenceScope.COURSE, title: 'title', logoutUrl: 'logoutUrl', }; diff --git a/apps/server/src/modules/video-conference/uc/video-conference-info.uc.ts b/apps/server/src/modules/video-conference/uc/video-conference-info.uc.ts index a490fa6f83a..1aaebb99858 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-info.uc.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-info.uc.ts @@ -19,9 +19,9 @@ export class VideoConferenceInfoUc { async getMeetingInfo(currentUserId: EntityId, scope: ScopeRef): Promise { /* need to be replace with - const [authorizableUser, scopeRessource]: [User, TeamEntity | Course] = await Promise.all([ + const [authorizableUser, scopeResource]: [User, TeamEntity | Course] = await Promise.all([ this.authorizationService.getUserWithPermissions(userId), - this.videoConferenceService.loadScopeRessources(scopeId, scope), + this.videoConferenceService.loadScopeResources(scopeId, scope), ]); */ const user: UserDO = await this.userService.findById(currentUserId); diff --git a/apps/server/src/modules/video-conference/video-conference.module.ts b/apps/server/src/modules/video-conference/video-conference.module.ts index 769af5e953f..f2c71237320 100644 --- a/apps/server/src/modules/video-conference/video-conference.module.ts +++ b/apps/server/src/modules/video-conference/video-conference.module.ts @@ -8,20 +8,28 @@ import { Module } from '@nestjs/common'; import { TeamsRepo } from '@shared/repo'; import { VideoConferenceRepo } from '@shared/repo/videoconference/video-conference.repo'; import { LoggerModule } from '@src/core/logger'; +import { BoardModule } from '../board'; import { LearnroomModule } from '../learnroom'; import { BBBService } from './bbb'; import { VideoConferenceDeprecatedController } from './controller'; import { VideoConferenceService } from './service'; import { VideoConferenceDeprecatedUc } from './uc'; +import { RoleModule } from '../role'; +import { RoomMembershipModule } from '../room-membership'; +import { RoomModule } from '../room'; @Module({ imports: [ AuthorizationModule, AuthorizationReferenceModule, // can be removed wenn video-conference-deprecated is removed + BoardModule, CalendarModule, HttpModule, LegacySchoolModule, LoggerModule, + RoleModule, + RoomMembershipModule, + RoomModule, UserModule, LearnroomModule, UserModule, diff --git a/apps/server/src/shared/common/guards/type.guard.spec.ts b/apps/server/src/shared/common/guards/type.guard.spec.ts index 8436c8bc6d2..93fc82e67a5 100644 --- a/apps/server/src/shared/common/guards/type.guard.spec.ts +++ b/apps/server/src/shared/common/guards/type.guard.spec.ts @@ -1,27 +1,33 @@ import { TypeGuard } from './type.guard'; +type ExampleObjectType = { + id?: number; + name?: string; + email?: string; +}; + describe('TypeGuard', () => { describe('isError', () => { describe('when passing type of value is an Error', () => { - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isError(new Error())).toBe(true); }); }); describe('when passing type of value is NOT an Error', () => { - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isError(undefined)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isError(null)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isError({})).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isError('string')).toBe(false); }); }); @@ -29,29 +35,29 @@ describe('TypeGuard', () => { describe('isNull', () => { describe('when passing type of value is null', () => { - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isNull(null)).toBe(true); }); }); describe('when passing type of value is NOT null', () => { - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isNull(undefined)).toBe(false); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isNull('string')).toBe(false); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isNull('')).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isNull({})).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isNull(1)).toBe(false); }); }); @@ -59,29 +65,29 @@ describe('TypeGuard', () => { describe('isUndefined', () => { describe('when passing type of value is undefined', () => { - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isUndefined(undefined)).toBe(true); }); }); describe('when passing type of value is NOT undefined', () => { - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isUndefined(null)).toBe(false); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isUndefined('string')).toBe(false); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isUndefined('')).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isUndefined({})).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isUndefined(1)).toBe(false); }); }); @@ -89,33 +95,33 @@ describe('TypeGuard', () => { describe('isNumber', () => { describe('when passing type of value is a number', () => { - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isNumber(123)).toBe(true); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isNumber(-1)).toBe(true); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isNumber(NaN)).toBe(true); }); }); describe('when passing type of value is NOT a number', () => { - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isNumber(undefined)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isNumber(null)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isNumber({})).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isNumber('string')).toBe(false); }); }); @@ -137,19 +143,19 @@ describe('TypeGuard', () => { }); describe('when passing type of value is NOT a number', () => { - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkNumber(undefined)).toThrowError('Type is not a number'); }); - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkNumber(null)).toThrowError('Type is not a number'); }); - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkNumber({})).toThrowError('Type is not a number'); }); - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkNumber('string')).toThrowError('Type is not a number'); }); }); @@ -157,29 +163,29 @@ describe('TypeGuard', () => { describe('isString', () => { describe('when passing type of value is a string', () => { - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isString('string')).toBe(true); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isString('')).toBe(true); }); }); describe('when passing type of value is NOT a string', () => { - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isString(undefined)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isString(null)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isString({})).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isString(1)).toBe(false); }); }); @@ -197,19 +203,19 @@ describe('TypeGuard', () => { }); describe('when passing type of value is NOT a string', () => { - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkString(undefined)).toThrowError('Type is not a string'); }); - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkString(null)).toThrowError('Type is not a string'); }); - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkString({})).toThrowError('Type is not a string'); }); - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkString(1)).toThrowError('Type is not a string'); }); }); @@ -229,23 +235,23 @@ describe('TypeGuard', () => { }); describe('when value is NOT in values', () => { - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isStringOfStrings(undefined, ['string'])).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isStringOfStrings(null, ['string'])).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isStringOfStrings({}, ['string'])).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isStringOfStrings(1, ['string'])).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isStringOfStrings('string', [''])).toBe(false); }); }); @@ -306,29 +312,29 @@ describe('TypeGuard', () => { describe('isArray', () => { describe('when passing type of value is an array', () => { - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isArray([])).toBe(true); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isArray(['', '', ''])).toBe(true); }); }); describe('when passing type of value is NOT an array', () => { - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isArray(undefined)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isArray(null)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isArray({})).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isArray(1)).toBe(false); }); }); @@ -366,37 +372,37 @@ describe('TypeGuard', () => { describe('isArrayWithElements', () => { describe('when passing type of value is an array with elements', () => { - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isArrayWithElements([1, 2, 3])).toBe(true); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isArrayWithElements(['a', 'b', 'c'])).toBe(true); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isArrayWithElements([{ a: 1 }, { b: 2 }])).toBe(true); }); }); describe('when passing type of value is NOT an array with elements', () => { - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isArrayWithElements([])).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isArrayWithElements(undefined)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isArrayWithElements(null)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isArrayWithElements({})).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isArrayWithElements(1)).toBe(false); }); }); @@ -434,33 +440,33 @@ describe('TypeGuard', () => { describe('isDefinedObject', () => { describe('when passing type of value is an object', () => { - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isDefinedObject({})).toBe(true); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isDefinedObject({ a: 1 })).toBe(true); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isDefinedObject({ a: { b: 1 } })).toBe(true); }); }); describe('when passing type of value is NOT an object', () => { - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isDefinedObject(undefined)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isDefinedObject(null)).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isDefinedObject([])).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isDefinedObject('string')).toBe(false); }); }); @@ -482,19 +488,19 @@ describe('TypeGuard', () => { }); describe('when passing type of value is NOT an object', () => { - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkDefinedObject(undefined)).toThrowError('Type is not an object'); }); - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkDefinedObject(null)).toThrowError('Type is not an object'); }); - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkDefinedObject([])).toThrowError('Type is not an object'); }); - it('should be return false', () => { + it('should return false', () => { expect(() => TypeGuard.checkDefinedObject('string')).toThrowError('Type is not an object'); }); }); @@ -614,33 +620,33 @@ describe('TypeGuard', () => { describe('isEachKeyInObject', () => { describe('when passing value is an object that has all requested keys', () => { - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isEachKeyInObject({ x1: 'abc', x2: 'bcd' }, ['x1', 'x2'])).toBe(true); }); - it('should be return true', () => { + it('should return true', () => { expect(TypeGuard.isEachKeyInObject({ x1: 'abc', x2: 'bcd' }, ['x1'])).toBe(true); }); }); describe('when passing params do not match', () => { - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isEachKeyInObject({ x1: 'abc', x2: 'bcd' }, ['x1', 'x2', 'x3'])).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isEachKeyInObject({ x1: 'abc', x2: 'bcd' }, 'x1' as unknown as string[])).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isEachKeyInObject('string', ['x1', 'x2'])).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isEachKeyInObject(undefined, ['x1', 'x2'])).toBe(false); }); - it('should be return false', () => { + it('should return false', () => { expect(TypeGuard.isEachKeyInObject(null, ['x1', 'x2'])).toBe(false); }); }); @@ -649,7 +655,9 @@ describe('TypeGuard', () => { describe('checkKeyInObject', () => { describe('when passing value is an object that has the requested key', () => { it('should be return the key value', () => { - expect(TypeGuard.checkKeyInObject({ xyz: 'abc' }, 'xyz')).toEqual('abc'); + const result = TypeGuard.checkKeyInObject({ xyz: 'abc' }, 'xyz'); + + expect(result).toEqual('abc'); }); }); @@ -697,6 +705,44 @@ describe('TypeGuard', () => { }); }); + describe('checkKeysInInstance', () => { + describe('when passing value is an object that has the requested keys', () => { + it('should be return the object', () => { + const example: ExampleObjectType = { name: 'abc' }; + + const checkedObject = TypeGuard.checkKeysInInstance(example, ['name']); + + expect(checkedObject).toEqual(example); + }); + + it('should know the property is defined', () => { + const example: ExampleObjectType = { name: 'abc' }; + + const checkedObject = TypeGuard.checkKeysInInstance(example, ['name']); + + expect(checkedObject.name).toEqual('abc'); + }); + }); + + describe('when passing value and keys do not match', () => { + it('should throw an error', () => { + const example: ExampleObjectType = { id: 1, name: 'John Doe' }; + + expect(() => TypeGuard.checkKeysInInstance(example, ['email'])).toThrowError( + 'Object lacks this property: email. ' + ); + }); + + it('should throw an error', () => { + const example: ExampleObjectType = { id: 1, name: 'John Doe' }; + + expect(() => TypeGuard.checkKeysInInstance(example, ['email'])).toThrowError( + 'Object lacks this property: email. ' + ); + }); + }); + }); + describe('checkNotNullOrUndefined', () => { describe('when value is null', () => { it('should throw error if it is passed', () => { diff --git a/apps/server/src/shared/common/guards/type.guard.ts b/apps/server/src/shared/common/guards/type.guard.ts index d9f5b687308..342fc38c534 100644 --- a/apps/server/src/shared/common/guards/type.guard.ts +++ b/apps/server/src/shared/common/guards/type.guard.ts @@ -1,29 +1,31 @@ +type EnsureKeysAreSet = T & { [P in K]-?: T[P] }; + export class TypeGuard { - static isError(value: unknown): value is Error { + public static isError(value: unknown): value is Error { const isError = value instanceof Error; return isError; } - static isNull(value: unknown): value is null { + public static isNull(value: unknown): value is null { const isNull = value === null; return isNull; } - static isUndefined(value: unknown): value is undefined { + public static isUndefined(value: unknown): value is undefined { const isUndefined = value === undefined; return isUndefined; } - static isNumber(value: unknown): value is number { + public static isNumber(value: unknown): value is number { const isNumber = typeof value === 'number'; return isNumber; } - static checkNumber(value: unknown): number { + public static checkNumber(value: unknown): number { if (!TypeGuard.isNumber(value)) { throw new Error('Type is not a number'); } @@ -31,13 +33,13 @@ export class TypeGuard { return value; } - static isString(value: unknown): value is string { + public static isString(value: unknown): value is string { const isString = typeof value === 'string'; return isString; } - static checkString(value: unknown): string { + public static checkString(value: unknown): string { if (!TypeGuard.isString(value)) { throw new Error('Type is not a string'); } @@ -45,13 +47,13 @@ export class TypeGuard { return value; } - static isStringOfStrings(value: unknown, values: T[]): value is T { + public static isStringOfStrings(value: unknown, values: T[]): value is T { const isStringOfValue = TypeGuard.isString(value) && values.includes(value as T); return isStringOfValue; } - static checkStringOfStrings(value: unknown, values: T[]): T { + public static checkStringOfStrings(value: unknown, values: T[]): T { if (!TypeGuard.isStringOfStrings(value, values)) { throw new Error('Value is not in strings'); } @@ -59,13 +61,13 @@ export class TypeGuard { return value; } - static isArray(value: unknown): value is [] { + public static isArray(value: unknown): value is [] { const isArray = Array.isArray(value); return isArray; } - static checkArray(value: unknown): [] { + public static checkArray(value: unknown): [] { if (!TypeGuard.isArray(value)) { throw new Error('Type is not an array.'); } @@ -73,13 +75,13 @@ export class TypeGuard { return value; } - static isArrayWithElements(value: unknown): value is [] { + public static isArrayWithElements(value: unknown): value is [] { const isArrayWithElements = TypeGuard.isArray(value) && value.length > 0; return isArrayWithElements; } - static checkArrayWithElements(value: unknown): [] { + public static checkArrayWithElements(value: unknown): [] { if (!TypeGuard.isArrayWithElements(value)) { throw new Error('Type is not an array with elements.'); } @@ -87,13 +89,13 @@ export class TypeGuard { return value; } - static isDefinedObject(value: unknown): value is object { + public static isDefinedObject(value: unknown): value is object { const isObject = typeof value === 'object' && !TypeGuard.isArray(value) && !TypeGuard.isNull(value); return isObject; } - static checkDefinedObject(value: unknown): object { + public static checkDefinedObject(value: unknown): object { if (!TypeGuard.isDefinedObject(value)) { throw new Error('Type is not an object.'); } @@ -102,7 +104,7 @@ export class TypeGuard { } /** @return undefined if no object or key do not exists, otherwise the value of the key. */ - static getValueFromObjectKey(value: unknown, key: string): unknown { + public static getValueFromObjectKey(value: unknown, key: string): unknown { TypeGuard.checkString(key); const result: unknown = TypeGuard.isDefinedObject(value) ? value[key] : undefined; @@ -110,7 +112,7 @@ export class TypeGuard { return result; } - static getValueFromDeepObjectKey(value: unknown, keyPath: string[]): unknown { + public static getValueFromDeepObjectKey(value: unknown, keyPath: string[]): unknown { TypeGuard.checkArrayWithElements(keyPath); let result: unknown = value; @@ -122,7 +124,7 @@ export class TypeGuard { return result; } - static isEachKeyInObject>(value: unknown, keys: (keyof T)[]): value is T { + public static isEachKeyInObject>(value: unknown, keys: (keyof T)[]): value is T { if (!TypeGuard.isDefinedObject(value) || !TypeGuard.isArray(keys)) { return false; } @@ -139,7 +141,7 @@ export class TypeGuard { } /** @return value of requested key in object. */ - static checkKeyInObject(value: T, key: string, toThrow?: Error): unknown { + public static checkKeyInObject(value: T, key: string, toThrow?: Error): unknown { TypeGuard.checkString(key); const object = TypeGuard.checkDefinedObject(value); @@ -151,7 +153,11 @@ export class TypeGuard { return object[key]; } - static checkKeysInObject>(value: unknown, keys: (keyof T)[], toThrow?: Error): T { + public static checkKeysInObject>( + value: unknown, + keys: (keyof T)[], + toThrow?: Error + ): T { const object = TypeGuard.checkDefinedObject(value); if (!TypeGuard.isEachKeyInObject(object, keys)) { @@ -168,11 +174,20 @@ export class TypeGuard { return object; } - // add additional method checkKeysInObject with key array see use case for example in method mapEtherpadSessionToSession - // return an value that represent as type a interface that include all checked keys. - // Same interface can be usefull for checkKeyInObject + public static checkKeysInInstance( + obj: T, + keys: K[], + contextInfo = '' + ): EnsureKeysAreSet { + for (const key of keys) { + if (!(key in obj) || obj[key] === undefined) { + throw new Error(`Object lacks this property: ${String(key)}. ${contextInfo}`); + } + } + return obj as EnsureKeysAreSet; + } - static checkNotNullOrUndefined(value: T | null | undefined, toThrow?: Error): T { + public static checkNotNullOrUndefined(value: T | null | undefined, toThrow?: Error): T { if (TypeGuard.isNull(value)) { throw toThrow || new Error('Type is null.'); } diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index ff570b7c6ac..5dd2fb36fd1 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -10,11 +10,10 @@ import { OauthSessionTokenEntity } from '@modules/oauth/entity'; import { ExternalToolPseudonymEntity, PseudonymEntity } from '@modules/pseudonym/entity'; import { RegistrationPinEntity } from '@modules/registration-pin/entity'; import { RocketChatUserEntity } from '@modules/rocketchat-user/entity'; -import { RoomEntity } from '@modules/room/repo/entity'; import { RoomMembershipEntity } from '@modules/room-membership/repo/entity/room-membership.entity'; +import { RoomEntity } from '@modules/room/repo/entity'; import { ShareToken } from '@modules/sharing/entity/share-token.entity'; import { SystemEntity } from '@modules/system/entity/system.entity'; -import { TldrawDrawing } from '@modules/tldraw/entities'; import { ContextExternalToolEntity, LtiDeepLinkTokenEntity } from '@modules/tool/context-external-tool/entity'; import { ExternalToolEntity } from '@modules/tool/external-tool/entity'; import { SchoolExternalToolEntity } from '@modules/tool/school-external-tool/entity'; @@ -99,7 +98,6 @@ export const ALL_ENTITIES = [ VideoConference, GroupEntity, RegistrationPinEntity, - TldrawDrawing, UserLicenseEntity, MediaUserLicenseEntity, InstanceEntity, diff --git a/apps/server/src/shared/domain/entity/video-conference.entity.ts b/apps/server/src/shared/domain/entity/video-conference.entity.ts index eb30214b660..49eef001600 100644 --- a/apps/server/src/shared/domain/entity/video-conference.entity.ts +++ b/apps/server/src/shared/domain/entity/video-conference.entity.ts @@ -4,6 +4,8 @@ import { BaseEntityWithTimestamps } from './base.entity'; export enum TargetModels { COURSES = 'courses', EVENTS = 'events', + ROOMS = 'rooms', + VIDEO_CONFERENCE_ELEMENTS = 'video-conference-elements', } export class VideoConferenceOptions { diff --git a/apps/server/src/shared/domain/interface/permission.enum.ts b/apps/server/src/shared/domain/interface/permission.enum.ts index c5bed37ad11..bd1c2b0d255 100644 --- a/apps/server/src/shared/domain/interface/permission.enum.ts +++ b/apps/server/src/shared/domain/interface/permission.enum.ts @@ -104,6 +104,9 @@ export enum Permission { ROOM_EDIT = 'ROOM_EDIT', ROOM_VIEW = 'ROOM_VIEW', ROOM_DELETE = 'ROOM_DELETE', + ROOM_MEMBERS_ADD = 'ROOM_MEMBERS_ADD', + ROOM_MEMBERS_REMOVE = 'ROOM_MEMBERS_REMOVE', + ROOM_CHANGE_OWNER = 'ROOM_CHANGE_OWNER', SCHOOL_CHAT_MANAGE = 'SCHOOL_CHAT_MANAGE', SCHOOL_CREATE = 'SCHOOL_CREATE', SCHOOL_EDIT = 'SCHOOL_EDIT', diff --git a/apps/server/src/shared/domain/interface/rolename.enum.ts b/apps/server/src/shared/domain/interface/rolename.enum.ts index e354109efd3..310f80cf84f 100644 --- a/apps/server/src/shared/domain/interface/rolename.enum.ts +++ b/apps/server/src/shared/domain/interface/rolename.enum.ts @@ -13,6 +13,8 @@ export enum RoleName { HELPDESK = 'helpdesk', ROOMVIEWER = 'roomviewer', ROOMEDITOR = 'roomeditor', + ROOMADMIN = 'roomadmin', + ROOMOWNER = 'roomowner', STUDENT = 'student', SUPERHERO = 'superhero', TEACHER = 'teacher', @@ -32,7 +34,12 @@ export type IUserRoleName = | RoleName.DEMOSTUDENT | RoleName.DEMOTEACHER; -export const RoomRoleArray = [RoleName.ROOMEDITOR, RoleName.ROOMVIEWER] as const; +export const RoomRoleArray = [ + RoleName.ROOMOWNER, + RoleName.ROOMADMIN, + RoleName.ROOMEDITOR, + RoleName.ROOMVIEWER, +] as const; export type RoomRole = typeof RoomRoleArray[number]; export const GuestRoleArray = [RoleName.GUESTSTUDENT, RoleName.GUESTTEACHER] as const; diff --git a/apps/server/src/shared/domain/interface/video-conference-scope.enum.ts b/apps/server/src/shared/domain/interface/video-conference-scope.enum.ts index 3bf8f44e930..625f65e99f2 100644 --- a/apps/server/src/shared/domain/interface/video-conference-scope.enum.ts +++ b/apps/server/src/shared/domain/interface/video-conference-scope.enum.ts @@ -1,4 +1,6 @@ export enum VideoConferenceScope { COURSE = 'course', EVENT = 'event', + ROOM = 'room', + VIDEO_CONFERENCE_ELEMENT = 'video-conference-element', } diff --git a/apps/server/src/shared/repo/videoconference/video-conference.repo.ts b/apps/server/src/shared/repo/videoconference/video-conference.repo.ts index e16878b7071..0c21483c37a 100644 --- a/apps/server/src/shared/repo/videoconference/video-conference.repo.ts +++ b/apps/server/src/shared/repo/videoconference/video-conference.repo.ts @@ -8,11 +8,15 @@ import { BaseDORepo } from '@shared/repo/base.do.repo'; const TargetModelsMapping = { [VideoConferenceScope.EVENT]: TargetModels.EVENTS, [VideoConferenceScope.COURSE]: TargetModels.COURSES, + [VideoConferenceScope.ROOM]: TargetModels.ROOMS, + [VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT]: TargetModels.VIDEO_CONFERENCE_ELEMENTS, }; const VideoConferencingScopeMapping = { [TargetModels.EVENTS]: VideoConferenceScope.EVENT, [TargetModels.COURSES]: VideoConferenceScope.COURSE, + [TargetModels.ROOMS]: VideoConferenceScope.ROOM, + [TargetModels.VIDEO_CONFERENCE_ELEMENTS]: VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT, }; @Injectable() diff --git a/apps/server/src/shared/testing/factory/tldraw.ws.factory.ts b/apps/server/src/shared/testing/factory/tldraw.ws.factory.ts deleted file mode 100644 index af8c34b6b73..00000000000 --- a/apps/server/src/shared/testing/factory/tldraw.ws.factory.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { WsSharedDocDo } from '@modules/tldraw/domain/ws-shared-doc.do'; -import WebSocket from 'ws'; -import { WebSocketReadyStateEnum } from '../web-socket-ready-state-enum'; - -export class TldrawWsFactory { - public static createWsSharedDocDo(): WsSharedDocDo { - return { - connections: new Map(), - getMap: () => new Map(), - transact: () => {}, - destroy: () => {}, - } as unknown as WsSharedDocDo; - } - - public static createWebsocket(readyState: WebSocketReadyStateEnum): WebSocket { - return { - readyState, - close: () => {}, - send: () => {}, - } as unknown as WebSocket; - } -} diff --git a/backup/setup/migrations.json b/backup/setup/migrations.json index 980709a2218..1ff81558276 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -269,15 +269,6 @@ "$date": "2024-11-11T16:59:21.768Z" } }, - { - "_id": { - "$oid": "673387c13aba1e283484119d" - }, - "name": "Migration20241112163538", - "created_at": { - "$date": "2024-11-12T16:52:17.292Z" - } - }, { "_id": { "$oid": "67347bb8b1bcb78aecbab90f" @@ -289,11 +280,11 @@ }, { "_id": { - "$oid": "67361aa7776f2f3e5a519735" + "$oid": "673fca34cc4a3264457c8ad1" }, - "name": "Migration20241113152001", + "name": "Migration20241120100616", "created_at": { - "$date": "2024-11-14T15:43:35.024Z" + "$date": "2024-11-20T17:03:31.473Z" } }, { @@ -316,27 +307,27 @@ }, { "_id": { - "$oid": "67477a7455d881b78f7a79fa" + "$oid": "675abdb4e76b1142cd4c89e5" }, - "name": "Migration20241127195120", + "name": "Migration20241209165812", "created_at": { - "$date": "2024-11-27T20:00:52.582Z" + "$date": "2024-12-12T10:40:52.027Z" } }, { "_id": { - "$oid": "6748b0f451c62e9dc6899983" + "$oid": "675abdb4e76b1142cd4c89e6" }, - "name": "Migration20241128155801", + "name": "Migration20241210152600", "created_at": { - "$date": "2024-11-28T18:05:40.839Z" + "$date": "2024-12-12T10:40:52.029Z" } }, { "_id": { - "$oid": "673fca34cc4a3264457c8ad1" + "$oid": "675c3caac52cd071103a87bb" }, - "name": "Migration20241120100616", + "name": "Migration20241213145222", "created_at": { "$date": "2024-11-20T17:03:31.473Z" } diff --git a/backup/setup/roles.json b/backup/setup/roles.json index 81c1b5bc4af..0c494cb441f 100644 --- a/backup/setup/roles.json +++ b/backup/setup/roles.json @@ -599,8 +599,7 @@ "name": "roomeditor", "permissions": [ "ROOM_VIEW", - "ROOM_EDIT", - "ROOM_DELETE" + "ROOM_EDIT" ] }, { @@ -616,5 +615,31 @@ }, "name": "guestTeacher", "permissions": [] + }, + { + "_id": { + "$oid": "675abdb4e76b1142cd4c89e3" + }, + "name": "roomowner", + "permissions": [ + "ROOM_VIEW", + "ROOM_EDIT", + "ROOM_DELETE", + "ROOM_MEMBERS_ADD", + "ROOM_MEMBERS_REMOVE", + "ROOM_CHANGE_OWNER" + ] + }, + { + "_id": { + "$oid": "675abdb4e76b1142cd4c89e4" + }, + "name": "roomadmin", + "permissions": [ + "ROOM_VIEW", + "ROOM_EDIT", + "ROOM_MEMBERS_ADD", + "ROOM_MEMBERS_REMOVE" + ] } ] diff --git a/config/default.schema.json b/config/default.schema.json index 14cb3e99a1c..196edcfc6b9 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1209,6 +1209,11 @@ "default": false, "description": "Enable link elements in column board." }, + "FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED": { + "type": "boolean", + "default": false, + "description": "Enable video conference elements in column board." + }, "COLUMN_BOARD_HELP_LINK": { "type": "string", "default": "https://docs.dbildungscloud.de/pages/viewpage.action?pageId=270827606", @@ -1552,102 +1557,6 @@ "API_KEY": "" } }, - "TLDRAW": { - "type": "object", - "description": "Configuration of tldraw related settings", - "required": [ - "PING_TIMEOUT", - "FINALIZE_DELAY", - "SOCKET_PORT", - "WEBSOCKET_URL", - "GC_ENABLED", - "DB_COMPRESS_THRESHOLD", - "MAX_DOCUMENT_SIZE", - "ASSETS_ENABLED", - "ASSETS_SYNC_ENABLED", - "ASSETS_MAX_SIZE_BYTES", - "ASSETS_ALLOWED_MIME_TYPES_LIST" - ], - "properties": { - "SOCKET_PORT": { - "type": "number", - "default": 3345, - "description": "Web socket port for tldraw" - }, - "WEBSOCKET_URL": { - "type": "string", - "default": "ws://localhost:3345", - "description": "Web socket url for tldraw" - }, - "PING_TIMEOUT": { - "type": "number", - "default": 30000, - "description": "Websocket ping timeout in ms" - }, - "FINALIZE_DELAY": { - "type": "number", - "default": 5000, - "description": "Delay in milliseconds before checking if can finalize a tldraw board" - }, - "GC_ENABLED": { - "type": "boolean", - "default": true, - "description": "If tldraw garbage collector should be enabled" - }, - "DB_COMPRESS_THRESHOLD": { - "type": "integer", - "default": 400, - "description": "Mongo documents with same docName compress threshold size" - }, - "MAX_DOCUMENT_SIZE": { - "type": "number", - "default": 15000000, - "description": "Maximum size of a single tldraw document in mongo" - }, - "ASSETS_ENABLED": { - "type": "boolean", - "default": true, - "description": "Enables uploading assets to tldraw board" - }, - "ASSETS_SYNC_ENABLED": { - "type": "boolean", - "default": false, - "description": "Enables synchronization of tldraw board assets with file storage" - }, - "ASSETS_MAX_SIZE_BYTES": { - "type": "integer", - "default": 10485760, - "description": "Maximum asset size in bytes" - }, - "ASSETS_ALLOWED_MIME_TYPES_LIST": { - "type": "string", - "default": "image/png,image/jpeg,image/gif,image/svg+xml", - "description": "List with allowed assets MIME types, comma separated, empty if all MIME types supported by tldraw should be allowed", - "examples": ["image/gif,image/jpeg,video/webm"] - }, - "PERFORMANCE_MEASURE_ENABLED": { - "type": "boolean", - "description": "Activate the performance measure for observed areas.", - "default": true - }, - "LOG_LEVEL": { - "type": "string", - "default": "info", - "description": "Define log level for tldraw.", - "enum": ["emerg", "alert", "crit", "error", "warning", "notice", "info", "debug"] - } - } - }, - "TLDRAW_DB_URL": { - "type": "string", - "default": "mongodb://127.0.0.1:27017/tldraw", - "description": "DB connection url" - }, - "TLDRAW_URI": { - "type": "string", - "default": "http://localhost:3349", - "description": "Address for tldraw management app" - }, "SCHULCONNEX_CLIENT": { "type": "object", "description": "Configuration of the schulcloud's schulconnex client.", @@ -1670,6 +1579,11 @@ "type": "string", "description": "Client secret for accessing the schulconnex API (from server vault)" }, + "PERSON_INFO_TIMEOUT_IN_MS": { + "type": "integer", + "description": "Timeout in milliseconds for fetching person info from schulconnex", + "default": 3000 + }, "PERSONEN_INFO_TIMEOUT_IN_MS": { "type": "integer", "description": "Timeout in milliseconds for fetching personen info from schulconnex", @@ -1733,6 +1647,10 @@ "description": "URL for fetching policies info from moin.schule schulconnex", "examples": ["https://api-dienste.stage.niedersachsen-login.schule/v1/policies-info"] }, + "PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT": { + "type": "number", + "description": "Maximum number of users in group that still get processed during schulconnex provisioning" + }, "BOARD_COLLABORATION_URI": { "type": "string", "default": "ws://localhost:4450", diff --git a/config/development.json b/config/development.json index 839b8e21bfd..1f7f5b7b770 100644 --- a/config/development.json +++ b/config/development.json @@ -77,6 +77,7 @@ "FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED": true, "FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED": true, "FEATURE_COLUMN_BOARD_COLLABORATIVE_TEXT_EDITOR_ENABLED": true, + "FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED": true, "FEATURE_BOARD_LAYOUT_ENABLED": true, "SCHULCONNEX_CLIENT": { "API_URL": "http://localhost:8888/v1/", @@ -92,18 +93,5 @@ "ALLOWED_API_KEYS": "thisisasupersecureapikeythatisabsolutelysave" }, "TRAINING_URL": "https://lernen.dbildungscloud.de", - "FEATURE_ROOMS_ENABLED": true, - "TLDRAW": { - "WEBSOCKET_URL": "ws://localhost:3345", - "SOCKET_PORT": 3346, - "PING_TIMEOUT": 1, - "FINALIZE_DELAY": 1, - "GC_ENABLED": true, - "DB_COMPRESS_THRESHOLD": 400, - "MAX_DOCUMENT_SIZE": 15000000, - "ASSETS_ENABLED": true, - "ASSETS_SYNC_ENABLED": true, - "ASSETS_MAX_SIZE_BYTES": 25000000, - "ASSETS_ALLOWED_MIME_TYPES_LIST": "" - } + "FEATURE_ROOMS_ENABLED": true } diff --git a/config/test.json b/config/test.json index 06ac1d84582..7b32e2000e1 100644 --- a/config/test.json +++ b/config/test.json @@ -69,19 +69,6 @@ "FEATURE_VIDEOCONFERENCE_ENABLED": true, "VIDEOCONFERENCE_HOST": "https://bigbluebutton.schul-cloud.org/bigbluebutton", "VIDEOCONFERENCE_SALT": "ThisIsNOTaRealSaltThisIsNOTaRealSaltThisIsNOTaRealSalt1234567890", - "TLDRAW": { - "WEBSOCKET_URL": "ws://localhost:3345", - "SOCKET_PORT": 3346, - "PING_TIMEOUT": 1, - "FINALIZE_DELAY": 1, - "GC_ENABLED": true, - "DB_COMPRESS_THRESHOLD": 400, - "MAX_DOCUMENT_SIZE": 15000000, - "ASSETS_ENABLED": true, - "ASSETS_SYNC_ENABLED": true, - "ASSETS_MAX_SIZE_BYTES": 25000000, - "ASSETS_ALLOWED_MIME_TYPES_LIST": "" - }, "SCHULCONNEX_CLIENT": { "API_URL": "http://localhost:8888/v1/", "TOKEN_ENDPOINT": "http://localhost:8888/realms/SANIS/protocol/openid-connect/token", diff --git a/openapitools.json b/openapitools.json index bd567305968..e074aa9db9f 100644 --- a/openapitools.json +++ b/openapitools.json @@ -27,6 +27,37 @@ "withInterfaces": true, "withSeparateModelsAndApi": true } + }, + "svs-lesson-api": { + "generatorName": "typescript-axios", + "inputSpec": "http://localhost:3030/api/v3/docs-json", + "output": "./apps/server/src/modules/common-cartridge/common-cartridge-client/lesson-client/new-lesson-api-client", + "skipValidateSpec": true, + "enablePostProcessFile": true, + "openapiNormalizer": { + "FILTER": "operationId:LessonController_getLesson|LessonController_getLessonTasks" + }, + "globalProperty": { + "models": "LessonResponse:LessonLinkedTaskResponse:LessonContentResponse:ComponentTextPropsImpl:ComponentEtherpadPropsImpl:ComponentGeogebraPropsImpl:ComponentInternalPropsImpl:ComponentLernstorePropsImpl:ComponentNexboardPropsImpl", + "apis": "", + "supportingFiles": "" + } + }, + "tldraw-api": { + "generatorName": "typescript-axios", + "inputSpec": "http://localhost:3349/docs-json", + "output": "./apps/server/src/infra/tldraw-client/generated", + "skipValidateSpec": true, + "enablePostProcessFile": true, + "additionalProperties": { + "apiPackage": "api", + "enumNameSuffix": "", + "enumPropertyNaming": "UPPERCASE", + "modelPackage": "models", + "supportsES6": true, + "withInterfaces": true, + "withSeparateModelsAndApi": true + } } } } diff --git a/package-lock.json b/package-lock.json index 81a0835d7ee..8811d3ab93c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -144,9 +144,7 @@ "winston": "^3.8.2", "ws": "^8.17.1", "xml2js": "^0.6.2", - "y-protocols": "^1.0.6", - "yaml": "^2.5.0", - "yjs": "^13.6.11" + "yaml": "^2.5.0" }, "devDependencies": { "@aws-sdk/client-s3": "^3.617.0", @@ -16331,14 +16329,6 @@ "ws": "*" } }, - "node_modules/isomorphic.js": { - "version": "0.2.5", - "license": "MIT", - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - } - }, "node_modules/isstream": { "version": "0.1.2", "license": "MIT" @@ -18956,24 +18946,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lib0": { - "version": "0.2.87", - "license": "MIT", - "dependencies": { - "isomorphic.js": "^0.2.4" - }, - "bin": { - "0gentesthtml": "bin/gentesthtml.js", - "0serve": "bin/0serve.js" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - } - }, "node_modules/libphonenumber-js": { "version": "1.10.24", "license": "MIT" @@ -26424,24 +26396,6 @@ "node": ">=0.4" } }, - "node_modules/y-protocols": { - "version": "1.0.6", - "license": "MIT", - "dependencies": { - "lib0": "^0.2.85" - }, - "engines": { - "node": ">=16.0.0", - "npm": ">=8.0.0" - }, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - }, - "peerDependencies": { - "yjs": "^13.0.0" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -26579,21 +26533,6 @@ "buffer-crc32": "~0.2.3" } }, - "node_modules/yjs": { - "version": "13.6.11", - "license": "MIT", - "dependencies": { - "lib0": "^0.2.86" - }, - "engines": { - "node": ">=16.0.0", - "npm": ">=8.0.0" - }, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - } - }, "node_modules/yn": { "version": "3.1.1", "dev": true, diff --git a/package.json b/package.json index 21409c40dcb..06a9cc0204b 100644 --- a/package.json +++ b/package.json @@ -86,9 +86,6 @@ "nest:start:h5p:library-management": "nest start h5p-library-management", "nest:start:h5p:library-management:dev": "nest start h5p-library-management --debug --watch", "nest:start:h5p:library-management:prod": "node dist/apps/server/apps/h5p-library-management.app", - "nest:start:tldraw": "nest start tldraw", - "nest:start:tldraw:dev": "nest start tldraw --debug --watch", - "nest:start:tldraw:prod": "node dist/apps/server/apps/tldraw.app", "nest:start:console": "nest start console --", "nest:start:console:dev": "nest start console --watch --", "nest:start:console:debug": "nest start console --debug --watch --", @@ -98,9 +95,6 @@ "nest:start:idp-console": "nest start idp-console --", "nest:start:idp-console:dev": "nest start idp-console --watch --", "nest:start:idp-console:debug": "nest start idp-console --debug --watch --", - "nest:start:tldraw-console": "nest start tldraw-console --", - "nest:start:tldraw-console:dev": "nest start tldraw-console --watch --", - "nest:start:tldraw-console:debug": "nest start tldraw-console --debug --watch --", "nest:start:common-cartridge": "node dist/apps/server/apps/common-cartridge.app", "nest:start:common-cartridge:dev": "nest start common-cartridge --watch --", "nest:start:common-cartridge:debug": "nest start common-cartridge --debug --watch --", @@ -121,7 +115,10 @@ "generate-client:authorization": "node ./scripts/generate-client.js -u 'http://localhost:3030/api/v3/docs-json/' -p 'apps/server/src/infra/authorization-client/authorization-api-client' -c 'openapitools-config.json' -f 'operationId:AuthorizationReferenceController_authorizeByReference'", "generate-client:etherpad": "node ./scripts/generate-client.js -u 'http://localhost:9001/api/openapi.json' -p 'apps/server/src/infra/etherpad-client/etherpad-api-client' -c 'openapitools-config.json'", "pregenerate-client:tsp-api": "rimraf ./apps/server/src/infra/tsp-client/generated", - "generate-client:tsp-api": "openapi-generator-cli generate -c ./openapitools.json --generator-key tsp-api" + "generate-client:tsp-api": "openapi-generator-cli generate -c ./openapitools.json --generator-key tsp-api", + "generate-client:lessons-api":"openapi-generator-cli generate -c ./openapitools.json --generator-key svs-lesson-api", + "pregenerate-client:tldraw-api": "rimraf ./apps/server/src/infra/tldraw-client/generated", + "generate-client:tldraw-api": "openapi-generator-cli generate -c ./openapitools.json --generator-key tldraw-api" }, "dependencies": { "@aws-sdk/lib-storage": "^3.617.0", @@ -260,9 +257,7 @@ "winston": "^3.8.2", "ws": "^8.17.1", "xml2js": "^0.6.2", - "y-protocols": "^1.0.6", - "yaml": "^2.5.0", - "yjs": "^13.6.11" + "yaml": "^2.5.0" }, "devDependencies": { "@aws-sdk/client-s3": "^3.617.0", @@ -339,4 +334,4 @@ "tsconfig-paths": "^4.1.1", "typescript": "^5.5.4" } -} \ No newline at end of file +} diff --git a/src/services/school/hooks/index.js b/src/services/school/hooks/index.js index d2904cf33c4..7bda1e83f0d 100644 --- a/src/services/school/hooks/index.js +++ b/src/services/school/hooks/index.js @@ -205,21 +205,25 @@ const isNotAuthenticated = async (context) => { const validateOfficialSchoolNumber = async (context) => { if (context && context.data && context.data.officialSchoolNumber) { const { officialSchoolNumber } = context.data; - const schools = await context.app.service('schools').find({ - query: { - _id: context.id, - $populate: 'federalState', - $limit: 1, - }, - }); - const currentSchool = schools.data[0]; - if (!currentSchool) { - throw new Error(`Internal error`); - } const isSuperHero = await globalHooks.hasRole(context, context.params.account.userId, 'superhero'); - if (!isSuperHero && currentSchool.officialSchoolNumber) { - throw new Error(`This school already have an officialSchoolNumber`); + if (!isSuperHero) { + const schools = await context.app.service('schools').find({ + query: { + _id: context.id, + $populate: 'federalState', + $limit: 1, + }, + }); + + const currentSchool = schools.data[0]; + if (!currentSchool) { + throw new Error(`Internal error`); + } + if (currentSchool.officialSchoolNumber) { + throw new Error(`This school already have an officialSchoolNumber`); + } } + const officialSchoolNumberFormat = /^[a-zA-Z0-9-]+$/; if (!officialSchoolNumberFormat.test(officialSchoolNumber)) { throw new Error( @@ -233,34 +237,47 @@ const validateOfficialSchoolNumber = async (context) => { // school County const validateCounty = async (context) => { if (context && context.data && context.data.county) { - const schools = await context.app.service('schools').find({ - query: { - _id: context.id, - $populate: 'federalState', - $limit: 1, - }, - }); - const currentSchool = schools.data[0]; - if (!currentSchool) { - throw new Error(`Internal error`); - } + let federalState; const isSuperHero = await globalHooks.hasRole(context, context.params.account.userId, 'superhero'); + if (!isSuperHero) { + const schools = await context.app.service('schools').find({ + query: { + _id: context.id, + $populate: 'federalState', + $limit: 1, + }, + }); - const { county } = context.data; - if ( - !isSuperHero && - (!currentSchool.federalState.counties.length || - !currentSchool.federalState.counties.some((c) => c._id.toString() === county.toString())) - ) { - throw new Error(`The state doesn't not have a matching county`); + const currentSchool = schools.data[0]; + if (!currentSchool) { + throw new Error(`Internal error`); + } + + const { county } = context.data; + if ( + !currentSchool.federalState.counties.length || + !currentSchool.federalState.counties.some((c) => c._id.toString() === county.toString()) + ) { + throw new Error(`The state doesn't not have a matching county`); + } + + /* Tries to replace the existing county with a new one */ + if (currentSchool.county && JSON.stringify(currentSchool.county) !== JSON.stringify(county)) { + throw new Error(`This school already have a county`); + } + + // eslint-disable-next-line prefer-destructuring + federalState = currentSchool.federalState; } - /* Tries to replace the existing county with a new one */ - if (!isSuperHero && currentSchool.county && JSON.stringify(currentSchool.county) !== JSON.stringify(county)) { - throw new Error(`This school already have a county`); + if (!federalState) { + federalState = await context.app.service('federalStates').get(context.data.federalState); + } + if (!federalState) { + throw new Error(`Unknown federal state was provided for the school`); } - context.data.county = currentSchool.federalState.counties.find((c) => c._id.toString() === county.toString()); + context.data.county = federalState.counties.find((c) => c._id.toString() === context.data.county.toString()); } // checks for empty value and deletes it from context if (context && context.data && Object.keys(context.data).includes('county') && !context.data.county) {