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/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/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/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/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/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/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/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/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/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..fb67f57ea03 --- /dev/null +++ b/apps/server/src/modules/provisioning/loggable/group-provisioning-info.loggable.spec.ts @@ -0,0 +1,38 @@ +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); + + 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, + }, + }); + }); + }); +}); 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..537a31e7855 --- /dev/null +++ b/apps/server/src/modules/provisioning/loggable/group-provisioning-info.loggable.ts @@ -0,0 +1,22 @@ +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) {} + + 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, + }, + }; + } +} diff --git a/apps/server/src/modules/provisioning/loggable/index.ts b/apps/server/src/modules/provisioning/loggable/index.ts index 01e7c2ae5cd..93010e22353 100644 --- a/apps/server/src/modules/provisioning/loggable/index.ts +++ b/apps/server/src/modules/provisioning/loggable/index.ts @@ -8,3 +8,4 @@ export { FetchingPoliciesInfoFailedLoggable } from './fetching-policies-info-fai 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/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/strategy/schulconnex/sanis.strategy.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/sanis.strategy.ts index bc57f6fee50..6a441c35909 100644 --- a/apps/server/src/modules/provisioning/strategy/schulconnex/sanis.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/sanis.strategy.ts @@ -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,7 +58,8 @@ export class SanisProvisioningStrategy extends SchulconnexProvisioningStrategy { schulconnexLicenseProvisioningService, schulconnexToolProvisioningService, groupService, - configService + configService, + logger ); } diff --git a/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.spec.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.spec.ts index 36ad4321943..1d413fd4aad 100644 --- a/apps/server/src/modules/provisioning/strategy/schulconnex/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/schulconnex/schulconnex-response-mapper.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.ts index 4a7543cac70..07ce885a1b9 100644 --- a/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.ts @@ -120,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) { @@ -144,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)) diff --git a/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.spec.ts index 26fbc0202df..f86346d37eb 100644 --- a/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.spec.ts @@ -14,6 +14,7 @@ import { legacySchoolDoFactory, userDoFactory, } from '@shared/testing'; +import { Logger } from '@src/core/logger'; import { ExternalGroupDto, ExternalSchoolDto, @@ -98,6 +99,10 @@ describe(SchulconnexProvisioningStrategy.name, () => { get: jest.fn().mockImplementation((key: keyof ProvisioningConfig) => config[key]), }, }, + { + provide: Logger, + useValue: createMock(), + }, ], }).compile(); diff --git a/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.ts index b965aabebcd..1c3737a6877 100644 --- a/apps/server/src/modules/provisioning/strategy/schulconnex/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,7 +26,8 @@ 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(); } @@ -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,9 @@ export abstract class SchulconnexProvisioningStrategy extends ProvisioningStrate await Promise.all(groupProvisioningPromises); } + + const endTime = performance.now(); + this.logger.warning(new GroupProvisioningInfoLoggable(data.externalGroups ?? [], endTime - startTime)); } private async removeUserFromGroups(data: OauthDataDto): Promise { 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/index.ts b/apps/server/src/modules/provisioning/testing/index.ts index 770f3e74f37..32854894142 100644 --- a/apps/server/src/modules/provisioning/testing/index.ts +++ b/apps/server/src/modules/provisioning/testing/index.ts @@ -1 +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 0326bb2d02b..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 @@ -116,6 +116,17 @@ describe(RoomMembershipRule.name, () => { 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', () => { 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 3336e93892f..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 @@ -10,18 +10,18 @@ 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 { - const primarySchoolId = user.school.id; - const secondarySchools = user.secondarySchools ?? []; - const secondarySchoolIds = secondarySchools.map(({ school }) => school.id); + if (!this.hasAccessToSchool(user, object.schoolId)) { + return false; + } - if (![primarySchoolId, ...secondarySchoolIds].includes(object.schoolId)) { + if (!this.hasRequiredRoomPermissions(user, object, context.requiredPermissions)) { return false; } @@ -36,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/service/room-membership.service.spec.ts b/apps/server/src/modules/room-membership/service/room-membership.service.spec.ts index 98763e33358..c6249661dc4 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,26 +87,6 @@ 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 }]); - - expect(roomMembershipRepo.save).toHaveBeenCalledWith( - expect.objectContaining({ - schoolId: room.schoolId, - }) - ); - }); - describe('when no user is provided', () => { it('should throw an exception', async () => { const { room } = setup(); @@ -189,118 +169,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]); + + expect(groupService.removeUsersFromGroup).toHaveBeenCalledWith(group.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(); + it('should remove user from secondary school', async () => { + const { secondarySchool, externalUserId, room } = setup(); - const { room, group, roomMembership } = setupGroupAndRoom(secondarySchool.id); - const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR }); - 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(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 }); + + mockGroupsAtSchoolAfterRemoval([group2]); - await service.removeMembersFromRoom(room.id, [externalUser.id as string]); + 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).toHaveBeenCalledWith([externalUser.id], secondarySchool.id); + 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); + }); }); }); }); 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 8baa23dd643..fd978721d04 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(), @@ -79,16 +75,14 @@ export class RoomMembershipService { public async addMembersToRoom( roomId: EntityId, - userIdsAndRoles: Array<{ userId: EntityId; roleName: RoleName.ROOMEDITOR | RoleName.ROOMVIEWER }> + userIdsAndRoles: Array<{ + userId: EntityId; + roleName: RoleName.ROOMADMIN | RoleName.ROOMEDITOR | RoleName.ROOMVIEWER; + }> ): 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'); } await this.groupService.addUsersToGroup(roomMembership.userGroupId, userIdsAndRoles); @@ -106,6 +100,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); @@ -151,6 +147,17 @@ export class RoomMembershipService { 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..9980d106fb9 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,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsMongoId, IsString, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; -import { RoomRole, RoomRoleArray } from '@shared/domain/interface'; +import { RoleName, RoomRoleArray } from '@shared/domain/interface'; class UserIdAndRole { @ApiProperty({ @@ -17,7 +17,7 @@ class UserIdAndRole { enum: RoomRoleArray, }) @IsString() - roleName!: RoomRole; + roleName!: RoleName.ROOMADMIN | RoleName.ROOMEDITOR | RoleName.ROOMVIEWER; } export class AddRoomMembersBodyParams { 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..1de7fd11b5c 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, RoleName } 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[] }> { @@ -129,14 +128,17 @@ export class RoomUc { public async addMembersToRoom( currentUserId: EntityId, roomId: EntityId, - userIdsAndRoles: Array<{ userId: EntityId; roleName: RoomRole }> + userIdsAndRoles: Array<{ + userId: EntityId; + roleName: RoleName.ROOMADMIN | RoleName.ROOMEDITOR | RoleName.ROOMVIEWER; + }> ): Promise { this.checkFeatureEnabled(); - await this.checkRoomAuthorization(currentUserId, roomId, Action.write); + await this.checkRoomAuthorization(currentUserId, roomId, Action.write, [Permission.ROOM_MEMBERS_ADD]); await this.roomMembershipService.addMembersToRoom(roomId, userIdsAndRoles); } - private mapToMember(member: UserWithRoomRoles, user: UserDO) { + private mapToMember(member: UserWithRoomRoles, user: UserDO): RoomMemberResponse { return new RoomMemberResponse({ userId: member.userId, firstName: user.firstName, @@ -148,7 +150,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 ad8f5e6a3b7..d4d5761ad5f 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], }); @@ -77,6 +86,7 @@ describe('Room Controller (API)', () => { teacherUser, teacherGuestRole, studentGuestRole, + roomEditorRole, otherTeacherUser, otherTeacherAccount, userGroupEntity, 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 4e8be194dfe..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 @@ -96,32 +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 school = schoolEntityFactory.buildWithId(); - const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + 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, - schoolId: teacherUser.school.id, + schoolId: teacherOwnerUser.school.id, }); - await em.persistAndFlush([room, roomMembership, teacherAccount, teacherUser, userGroup, role]); + 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); @@ -129,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(); @@ -137,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-remove-members.api.spec.ts b/apps/server/src/modules/room/api/test/room-remove-members.api.spec.ts index 3810a9f4f39..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 @@ -46,15 +46,30 @@ 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 () => { @@ -62,17 +77,17 @@ describe('Room Controller (API)', () => { const room = roomEntityFactory.buildWithId({ schoolId: school.id }); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); - const { teacherUser: inRoomEditor2 } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); - const { teacherUser: inRoomEditor3 } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.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 }); @@ -89,7 +104,14 @@ describe('Room Controller (API)', () => { 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); @@ -137,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); @@ -148,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/server/api/dto/config.response.ts b/apps/server/src/modules/server/api/dto/config.response.ts index 95ff7239dbe..40113bcb8bc 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; @@ -301,10 +289,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..6371109be84 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 @@ -95,10 +95,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..078662d11f4 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, @@ -115,10 +113,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; @@ -218,12 +212,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 +255,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/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 1acced96d98..00000000000 --- a/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.ts +++ /dev/null @@ -1,42 +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'; - -type ApiKeyHeader = { - 'X-Api-Key': string; - Accept: string; -}; - -@Injectable() -export class DrawingElementAdapterService { - constructor( - private logger: LegacyLogger, - private readonly httpService: HttpService, - private readonly configService: ConfigService - ) { - this.logger.setContext(DrawingElementAdapterService.name); - } - - public 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(): ApiKeyHeader { - const apiKey = this.configService.get('TLDRAW_ADMIN_API_CLIENT_API_KEY'); - - return { 'X-Api-Key': apiKey, Accept: 'Application/json' }; - } - - private defaultHeaders(): { headers: ApiKeyHeader } { - 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 88b24bce8b9..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 204 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/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/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/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/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/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 d99686e576e..1ff81558276 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -278,6 +278,15 @@ "$date": "2024-11-13T10:13:12.411Z" } }, + { + "_id": { + "$oid": "673fca34cc4a3264457c8ad1" + }, + "name": "Migration20241120100616", + "created_at": { + "$date": "2024-11-20T17:03:31.473Z" + } + }, { "_id": { "$oid": "674444262ba8186272dc8abd" @@ -298,9 +307,27 @@ }, { "_id": { - "$oid": "673fca34cc4a3264457c8ad1" + "$oid": "675abdb4e76b1142cd4c89e5" }, - "name": "Migration20241120100616", + "name": "Migration20241209165812", + "created_at": { + "$date": "2024-12-12T10:40:52.027Z" + } + }, + { + "_id": { + "$oid": "675abdb4e76b1142cd4c89e6" + }, + "name": "Migration20241210152600", + "created_at": { + "$date": "2024-12-12T10:40:52.029Z" + } + }, + { + "_id": { + "$oid": "675c3caac52cd071103a87bb" + }, + "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..4c51ff2338c 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1552,102 +1552,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 +1574,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 +1642,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..c79872b8dbf 100644 --- a/config/development.json +++ b/config/development.json @@ -92,18 +92,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..386abefb9e9 100644 --- a/openapitools.json +++ b/openapitools.json @@ -27,6 +27,22 @@ "withInterfaces": true, "withSeparateModelsAndApi": true } + }, + "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..1a09275bc37 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,9 @@ "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", + "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 +256,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 +333,4 @@ "tsconfig-paths": "^4.1.1", "typescript": "^5.5.4" } -} \ No newline at end of file +}