diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 44a0644b141..8a8b06c219e 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -13,4 +13,5 @@ jobs: - name: 'Dependency Review' uses: actions/dependency-review-action@v3 with: - allow-licenses: AGPL-3.0-only, LGPL-3.0, MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, X11, 0BSD, GPL-3.0, Unlicense + allow-licenses: AGPL-3.0-only, LGPL-3.0, MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, X11, 0BSD, GPL-3.0 AND BSD-3-Clause-Clear, Unlicense + allow-dependencies-licenses: 'pkg:npm/parse-mongo-url' diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index a5688568696..c042be2c2a9 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -172,7 +172,7 @@ jobs: uses: github/codeql-action/upload-sarif@v2 with: sarif_file: 'trivy-results.sarif' - + end-to-end-tests: needs: - build_and_push diff --git a/ansible/roles/schulcloud-server-core/tasks/main.yml b/ansible/roles/schulcloud-server-core/tasks/main.yml index e64021b5f67..47ace3c6f85 100644 --- a/ansible/roles/schulcloud-server-core/tasks/main.yml +++ b/ansible/roles/schulcloud-server-core/tasks/main.yml @@ -65,7 +65,7 @@ kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: deployment.yml.j2 - + - name: Ingress kubernetes.core.k8s: kubeconfig: ~/.kube/config @@ -155,3 +155,22 @@ when: - KEDA_ENABLED is defined and KEDA_ENABLED|bool - SCALED_PREVIEW_GENERATOR_ENABLED is defined and SCALED_PREVIEW_GENERATOR_ENABLED|bool + + - name: TlDraw server deployment + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: tldraw-deployment.yml.j2 + + - name: TlDraw server service + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: tldraw-server-svc.yml.j2 + + - name: Tldraw ingress + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: tldraw-ingress.yml.j2 + apply: yes diff --git a/ansible/roles/schulcloud-server-core/templates/tldraw-deployment.yml.j2 b/ansible/roles/schulcloud-server-core/templates/tldraw-deployment.yml.j2 new file mode 100644 index 00000000000..f9dc4f09d9e --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/tldraw-deployment.yml.j2 @@ -0,0 +1,67 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tldraw-deployment + namespace: {{ NAMESPACE }} + 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 + envFrom: + - configMapRef: + name: api-configmap + - secretRef: + name: api-secret + 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) }} diff --git a/ansible/roles/schulcloud-server-core/templates/tldraw-ingress.yml.j2 b/ansible/roles/schulcloud-server-core/templates/tldraw-ingress.yml.j2 new file mode 100644 index 00000000000..e80028a5985 --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/tldraw-ingress.yml.j2 @@ -0,0 +1,42 @@ +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 +{% if CLUSTER_ISSUER is defined %} + cert-manager.io/cluster-issuer: {{ CLUSTER_ISSUER }} +{% endif %} + +spec: + ingressClassName: nginx +{% if CLUSTER_ISSUER is defined or (TLS_ENABELD is defined and TLS_ENABELD|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-svc.yml.j2 b/ansible/roles/schulcloud-server-core/templates/tldraw-server-svc.yml.j2 new file mode 100644 index 00000000000..8a1ded9a1d9 --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/tldraw-server-svc.yml.j2 @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: Service +metadata: + name: tldraw-server-svc + namespace: {{ NAMESPACE }} + labels: + app: tldraw-server +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 + selector: + app: tldraw-server diff --git a/apps/server/src/apps/tldraw.app.ts b/apps/server/src/apps/tldraw.app.ts new file mode 100644 index 00000000000..a394b1e8deb --- /dev/null +++ b/apps/server/src/apps/tldraw.app.ts @@ -0,0 +1,50 @@ +/* istanbul ignore file */ +/* eslint-disable no-console */ +import { NestFactory } from '@nestjs/core'; +import { install as sourceMapInstall } from 'source-map-support'; +import { TldrawModule, TldrawWsModule } from '@modules/tldraw'; +import { LegacyLogger, Logger } from '@src/core/logger'; +import * as WebSocket from 'ws'; +import { WsAdapter } from '@nestjs/platform-ws'; +import { enableOpenApiDocs } from '@shared/controller/swagger'; +import { AppStartLoggable } from '@src/apps/helpers/app-start-loggable'; +import { ExpressAdapter } from '@nestjs/platform-express'; +import express from 'express'; + +async function bootstrap() { + sourceMapInstall(); + + const nestExpress = express(); + const nestExpressAdapter = new ExpressAdapter(nestExpress); + const nestApp = await NestFactory.create(TldrawModule, 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(); + + 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', + }) + ); +} + +void bootstrap(); diff --git a/apps/server/src/config/database.config.ts b/apps/server/src/config/database.config.ts index ad97e4c3d66..17c45dd1887 100644 --- a/apps/server/src/config/database.config.ts +++ b/apps/server/src/config/database.config.ts @@ -4,9 +4,10 @@ interface GlobalConstants { DB_URL: string; DB_PASSWORD?: string; DB_USERNAME?: string; + TLDRAW_DB_URL: string; } const usedGlobals: GlobalConstants = globals; /** Database URL */ -export const { DB_URL, DB_PASSWORD, DB_USERNAME } = usedGlobals; +export const { DB_URL, DB_PASSWORD, DB_USERNAME, TLDRAW_DB_URL } = usedGlobals; diff --git a/apps/server/src/modules/board/board.module.ts b/apps/server/src/modules/board/board.module.ts index ffa1e7ad580..880d9607cc9 100644 --- a/apps/server/src/modules/board/board.module.ts +++ b/apps/server/src/modules/board/board.module.ts @@ -6,6 +6,8 @@ import { ContentElementFactory } from '@shared/domain'; import { ConsoleWriterModule } from '@infra/console'; import { CourseRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; +import { DrawingElementAdapterService } from '@modules/tldraw-client/service/drawing-element-adapter.service'; +import { HttpModule } from '@nestjs/axios'; import { BoardDoRepo, BoardNodeRepo, RecursiveDeleteVisitor } from './repo'; import { BoardDoAuthorizableService, @@ -20,7 +22,14 @@ import { BoardDoCopyService, SchoolSpecificFileCopyServiceFactory } from './serv import { ColumnBoardCopyService } from './service/column-board-copy.service'; @Module({ - imports: [ConsoleWriterModule, FilesStorageClientModule, LoggerModule, UserModule, ContextExternalToolModule], + imports: [ + ConsoleWriterModule, + FilesStorageClientModule, + LoggerModule, + UserModule, + ContextExternalToolModule, + HttpModule, + ], providers: [ BoardDoAuthorizableService, BoardDoRepo, @@ -37,6 +46,7 @@ import { ColumnBoardCopyService } from './service/column-board-copy.service'; BoardDoCopyService, ColumnBoardCopyService, SchoolSpecificFileCopyServiceFactory, + DrawingElementAdapterService, ], exports: [ BoardDoAuthorizableService, diff --git a/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts b/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts index 84681de7691..ec3f1beb96c 100644 --- a/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts +++ b/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts @@ -1,4 +1,5 @@ import { ExternalToolElementResponse } from './external-tool-element.response'; +import { DrawingElementResponse } from './drawing-element.response'; import { FileElementResponse } from './file-element.response'; import { LinkElementResponse } from './link-element.response'; import { RichTextElementResponse } from './rich-text-element.response'; @@ -9,7 +10,8 @@ export type AnyContentElementResponse = | LinkElementResponse | RichTextElementResponse | SubmissionContainerElementResponse - | ExternalToolElementResponse; + | ExternalToolElementResponse + | DrawingElementResponse; export const isFileElementResponse = (element: AnyContentElementResponse): element is FileElementResponse => element instanceof FileElementResponse; diff --git a/apps/server/src/modules/board/controller/dto/element/drawing-element.response.ts b/apps/server/src/modules/board/controller/dto/element/drawing-element.response.ts new file mode 100644 index 00000000000..7c2b0e20852 --- /dev/null +++ b/apps/server/src/modules/board/controller/dto/element/drawing-element.response.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ContentElementType } from '@shared/domain'; +import { TimestampsResponse } from '../timestamps.response'; + +export class DrawingElementContent { + constructor({ description }: DrawingElementContent) { + this.description = description; + } + + @ApiProperty() + description: string; +} + +export class DrawingElementResponse { + constructor({ id, content, timestamps, type }: DrawingElementResponse) { + this.id = id; + this.timestamps = timestamps; + this.type = type; + this.content = content; + } + + @ApiProperty({ pattern: '[a-f0-9]{24}' }) + id: string; + + @ApiProperty({ enum: ContentElementType, enumName: 'ContentElementType' }) + type: ContentElementType.DRAWING; + + @ApiProperty() + timestamps: TimestampsResponse; + + @ApiProperty() + content: DrawingElementContent; +} diff --git a/apps/server/src/modules/board/controller/dto/element/index.ts b/apps/server/src/modules/board/controller/dto/element/index.ts index 6787c007c1b..3b85cb57f3f 100644 --- a/apps/server/src/modules/board/controller/dto/element/index.ts +++ b/apps/server/src/modules/board/controller/dto/element/index.ts @@ -1,5 +1,6 @@ export * from './any-content-element.response'; export * from './create-content-element.body.params'; +export * from './drawing-element.response'; export * from './external-tool-element.response'; export * from './file-element.response'; export * from './link-element.response'; diff --git a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts index 7d0314208c6..f6fbfd11043 100644 --- a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts +++ b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts @@ -73,6 +73,12 @@ export class RichTextContentBody { inputFormat!: InputFormat; } +export class DrawingContentBody { + @IsString() + @ApiProperty() + description!: string; +} + export class RichTextElementContentBody extends ElementContentBody { @ApiProperty({ type: ContentElementType.RICH_TEXT }) type!: ContentElementType.RICH_TEXT; @@ -118,6 +124,7 @@ export class ExternalToolElementContentBody extends ElementContentBody { export type AnyElementContentBody = | FileContentBody + | DrawingContentBody | LinkContentBody | RichTextContentBody | SubmissionContainerContentBody diff --git a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts index 2b61e273185..c4fb577f5c2 100644 --- a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts +++ b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts @@ -1,6 +1,7 @@ import { NotImplementedException } from '@nestjs/common'; import { fileElementFactory, + drawingElementFactory, linkElementFactory, richTextElementFactory, submissionContainerElementFactory, @@ -8,6 +9,7 @@ import { import { FileElementResponse, LinkElementResponse, + DrawingElementResponse, RichTextElementResponse, SubmissionContainerElementResponse, } from '../dto'; @@ -37,6 +39,14 @@ describe(ContentElementResponseFactory.name, () => { expect(result).toBeInstanceOf(RichTextElementResponse); }); + it('should return instance of DrawingElementResponse', () => { + const drawingElement = drawingElementFactory.build(); + + const result = ContentElementResponseFactory.mapToResponse(drawingElement); + + expect(result).toBeInstanceOf(DrawingElementResponse); + }); + it('should return instance of SubmissionContainerElementResponse', () => { const submissionContainerElement = submissionContainerElementFactory.build(); diff --git a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts index 8431b630be9..72311882bb8 100644 --- a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts +++ b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts @@ -8,6 +8,7 @@ import { isRichTextElementResponse, } from '../dto'; import { BaseResponseMapper } from './base-mapper.interface'; +import { DrawingElementResponseMapper } from './drawing-element-response.mapper'; import { ExternalToolElementResponseMapper } from './external-tool-element-response.mapper'; import { FileElementResponseMapper } from './file-element-response.mapper'; import { LinkElementResponseMapper } from './link-element-response.mapper'; @@ -19,6 +20,7 @@ export class ContentElementResponseFactory { FileElementResponseMapper.getInstance(), LinkElementResponseMapper.getInstance(), RichTextElementResponseMapper.getInstance(), + DrawingElementResponseMapper.getInstance(), SubmissionContainerElementResponseMapper.getInstance(), ExternalToolElementResponseMapper.getInstance(), ]; diff --git a/apps/server/src/modules/board/controller/mapper/drawing-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/drawing-element-response.mapper.ts new file mode 100644 index 00000000000..1ab07081f07 --- /dev/null +++ b/apps/server/src/modules/board/controller/mapper/drawing-element-response.mapper.ts @@ -0,0 +1,32 @@ +import { ContentElementType } from '@shared/domain'; +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; +import { DrawingElementContent, DrawingElementResponse } from '../dto/element/drawing-element.response'; +import { TimestampsResponse } from '../dto'; +import { BaseResponseMapper } from './base-mapper.interface'; + +export class DrawingElementResponseMapper implements BaseResponseMapper { + private static instance: DrawingElementResponseMapper; + + public static getInstance(): DrawingElementResponseMapper { + if (!DrawingElementResponseMapper.instance) { + DrawingElementResponseMapper.instance = new DrawingElementResponseMapper(); + } + + return DrawingElementResponseMapper.instance; + } + + mapToResponse(element: DrawingElement): DrawingElementResponse { + const result = new DrawingElementResponse({ + id: element.id, + timestamps: new TimestampsResponse({ lastUpdatedAt: element.updatedAt, createdAt: element.createdAt }), + type: ContentElementType.DRAWING, + content: new DrawingElementContent({ description: element.description }), + }); + + return result; + } + + canMap(element: DrawingElement): boolean { + return element instanceof DrawingElement; + } +} diff --git a/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts b/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts index 8bbc859fa17..43bdc325623 100644 --- a/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts +++ b/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts @@ -10,6 +10,7 @@ import { setupEntities, submissionContainerElementNodeFactory, } from '@shared/testing'; +import { drawingElementNodeFactory } from '@shared/testing/factory/boardnode/drawing-element-node.factory'; import { BoardDoBuilderImpl } from './board-do.builder-impl'; describe(BoardDoBuilderImpl.name, () => { @@ -168,6 +169,25 @@ describe(BoardDoBuilderImpl.name, () => { }); }); + describe('when building a drawing element', () => { + it('should work without descendants', () => { + const drawingElementNode = drawingElementNodeFactory.build(); + + const domainObject = new BoardDoBuilderImpl().buildDrawingElement(drawingElementNode); + + expect(domainObject.constructor.name).toBe('DrawingElement'); + }); + + it('should throw error if drawingElement is not a leaf', () => { + const drawingElementNode = drawingElementNodeFactory.buildWithId(); + const columnNode = columnNodeFactory.buildWithId({ parent: drawingElementNode }); + + expect(() => { + new BoardDoBuilderImpl([columnNode]).buildDrawingElement(drawingElementNode); + }).toThrowError(); + }); + }); + describe('when building a submission container element', () => { it('should work without descendants', () => { const submissionContainerElementNode = submissionContainerElementNodeFactory.build(); diff --git a/apps/server/src/modules/board/repo/board-do.builder-impl.ts b/apps/server/src/modules/board/repo/board-do.builder-impl.ts index 6e2b375991e..2154264fd20 100644 --- a/apps/server/src/modules/board/repo/board-do.builder-impl.ts +++ b/apps/server/src/modules/board/repo/board-do.builder-impl.ts @@ -25,6 +25,8 @@ import { SubmissionContainerElement, SubmissionItem, } from '@shared/domain'; +import { DrawingElementNode } from '@shared/domain/entity/boardnode/drawing-element-node.entity'; +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; export class BoardDoBuilderImpl implements BoardDoBuilder { private childrenMap: Record = {}; @@ -77,6 +79,7 @@ export class BoardDoBuilderImpl implements BoardDoBuilder { BoardNodeType.FILE_ELEMENT, BoardNodeType.LINK_ELEMENT, BoardNodeType.RICH_TEXT_ELEMENT, + BoardNodeType.DRAWING_ELEMENT, BoardNodeType.SUBMISSION_CONTAINER_ELEMENT, BoardNodeType.EXTERNAL_TOOL, ]); @@ -139,6 +142,19 @@ export class BoardDoBuilderImpl implements BoardDoBuilder { return element; } + public buildDrawingElement(boardNode: DrawingElementNode): DrawingElement { + this.ensureLeafNode(boardNode); + + const element = new DrawingElement({ + id: boardNode.id, + description: boardNode.description, + children: [], + createdAt: boardNode.createdAt, + updatedAt: boardNode.updatedAt, + }); + return element; + } + public buildSubmissionContainerElement(boardNode: SubmissionContainerElementNode): SubmissionContainerElement { this.ensureBoardNodeType(this.getChildren(boardNode), [BoardNodeType.SUBMISSION_ITEM]); const elements = this.buildChildren(boardNode); diff --git a/apps/server/src/modules/board/repo/board-do.repo.spec.ts b/apps/server/src/modules/board/repo/board-do.repo.spec.ts index 2f9a6633e69..2bf3037973d 100644 --- a/apps/server/src/modules/board/repo/board-do.repo.spec.ts +++ b/apps/server/src/modules/board/repo/board-do.repo.spec.ts @@ -28,6 +28,7 @@ import { richTextElementFactory, richTextElementNodeFactory, } from '@shared/testing'; +import { DrawingElementAdapterService } from '@modules/tldraw-client/service/drawing-element-adapter.service'; import { BoardDoRepo } from './board-do.repo'; import { BoardNodeRepo } from './board-node.repo'; import { RecursiveDeleteVisitor } from './recursive-delete.vistor'; @@ -48,6 +49,7 @@ describe(BoardDoRepo.name, () => { RecursiveDeleteVisitor, { provide: FilesStorageClientAdapterService, useValue: createMock() }, { provide: ContextExternalToolService, useValue: createMock() }, + { provide: DrawingElementAdapterService, useValue: createMock() }, ], }).compile(); repo = module.get(BoardDoRepo); diff --git a/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts b/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts index 544d492fe65..49604d6fcbf 100644 --- a/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts +++ b/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts @@ -8,6 +8,7 @@ import { columnBoardFactory, columnFactory, contextExternalToolFactory, + drawingElementFactory, externalToolElementFactory, fileElementFactory, linkElementFactory, @@ -15,6 +16,7 @@ import { submissionContainerElementFactory, submissionItemFactory, } from '@shared/testing'; +import { DrawingElementAdapterService } from '@modules/tldraw-client/service/drawing-element-adapter.service'; import { RecursiveDeleteVisitor } from './recursive-delete.vistor'; describe(RecursiveDeleteVisitor.name, () => { @@ -24,6 +26,7 @@ describe(RecursiveDeleteVisitor.name, () => { let em: DeepMocked; let filesStorageClientAdapterService: DeepMocked; let contextExternalToolService: DeepMocked; + let drawingElementAdapterService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -32,6 +35,7 @@ describe(RecursiveDeleteVisitor.name, () => { { provide: EntityManager, useValue: createMock() }, { provide: FilesStorageClientAdapterService, useValue: createMock() }, { provide: ContextExternalToolService, useValue: createMock() }, + { provide: DrawingElementAdapterService, useValue: createMock() }, ], }).compile(); @@ -39,6 +43,7 @@ describe(RecursiveDeleteVisitor.name, () => { em = module.get(EntityManager); filesStorageClientAdapterService = module.get(FilesStorageClientAdapterService); contextExternalToolService = module.get(ContextExternalToolService); + drawingElementAdapterService = module.get(DrawingElementAdapterService); await setupEntities(); }); @@ -181,6 +186,30 @@ describe(RecursiveDeleteVisitor.name, () => { }); }); + describe('visitDrawingElementAsync', () => { + const setup = () => { + const childDrawingElement = drawingElementFactory.build(); + + return { childDrawingElement }; + }; + + it('should call entity remove', async () => { + const { childDrawingElement } = setup(); + + await service.visitDrawingElementAsync(childDrawingElement); + + expect(em.remove).toHaveBeenCalledWith(em.getReference(childDrawingElement.constructor, childDrawingElement.id)); + }); + + it('should trigger deletion of tldraw data via adapter', async () => { + const { childDrawingElement } = setup(); + + await service.visitDrawingElementAsync(childDrawingElement); + + expect(drawingElementAdapterService.deleteDrawingBinData).toHaveBeenCalledWith(childDrawingElement.id); + }); + }); + describe('visitSubmissionContainerElementAsync', () => { const setup = () => { const childSubmissionContainerElement = submissionContainerElementFactory.build(); diff --git a/apps/server/src/modules/board/repo/recursive-delete.vistor.ts b/apps/server/src/modules/board/repo/recursive-delete.vistor.ts index 6c8301f6b6f..a4ba34425e0 100644 --- a/apps/server/src/modules/board/repo/recursive-delete.vistor.ts +++ b/apps/server/src/modules/board/repo/recursive-delete.vistor.ts @@ -17,13 +17,16 @@ import { SubmissionItem, } from '@shared/domain'; import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; +import { DrawingElementAdapterService } from '@modules/tldraw-client/service/drawing-element-adapter.service'; @Injectable() export class RecursiveDeleteVisitor implements BoardCompositeVisitorAsync { constructor( private readonly em: EntityManager, private readonly filesStorageClientAdapterService: FilesStorageClientAdapterService, - private readonly contextExternalToolService: ContextExternalToolService + private readonly contextExternalToolService: ContextExternalToolService, + private readonly drawingElementAdapterService: DrawingElementAdapterService ) {} async visitColumnBoardAsync(columnBoard: ColumnBoard): Promise { @@ -60,6 +63,13 @@ export class RecursiveDeleteVisitor implements BoardCompositeVisitorAsync { await this.visitChildrenAsync(richTextElement); } + async visitDrawingElementAsync(drawingElement: DrawingElement): Promise { + await this.drawingElementAdapterService.deleteDrawingBinData(drawingElement.id); + + this.deleteNode(drawingElement); + await this.visitChildrenAsync(drawingElement); + } + async visitSubmissionContainerElementAsync(submissionContainerElement: SubmissionContainerElement): Promise { this.deleteNode(submissionContainerElement); await this.visitChildrenAsync(submissionContainerElement); diff --git a/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts b/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts index 3fd95c18525..55be7d41d75 100644 --- a/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts +++ b/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts @@ -9,6 +9,7 @@ import { FileElementNode, LinkElementNode, RichTextElementNode, + DrawingElementNode, SubmissionContainerElementNode, SubmissionItemNode, } from '@shared/domain'; @@ -23,6 +24,7 @@ import { linkElementFactory, richTextElementFactory, setupEntities, + drawingElementFactory, submissionContainerElementFactory, submissionItemFactory, } from '@shared/testing'; @@ -120,6 +122,16 @@ describe(RecursiveSaveVisitor.name, () => { expect(richTextElement.accept).toHaveBeenCalledWith(visitor); }); + + it('should visit the children (drawing)', () => { + const drawingElement = drawingElementFactory.build(); + jest.spyOn(drawingElement, 'accept'); + const card = cardFactory.build({ children: [drawingElement] }); + + card.accept(visitor); + + expect(drawingElement.accept).toHaveBeenCalledWith(visitor); + }); }); describe('when visiting a file element composite', () => { @@ -171,6 +183,21 @@ describe(RecursiveSaveVisitor.name, () => { }); }); + describe('when visiting a drawing element composite', () => { + it('should create or update the node', () => { + const drawingElement = drawingElementFactory.build(); + jest.spyOn(visitor, 'createOrUpdateBoardNode'); + + visitor.visitDrawingElement(drawingElement); + + const expectedNode: Partial = { + id: drawingElement.id, + type: BoardNodeType.DRAWING_ELEMENT, + }; + expect(visitor.createOrUpdateBoardNode).toHaveBeenCalledWith(expect.objectContaining(expectedNode)); + }); + }); + describe('when visiting a submission container element composite', () => { it('should create or update the node', () => { const submissionContainerElement = submissionContainerElementFactory.build(); diff --git a/apps/server/src/modules/board/repo/recursive-save.visitor.ts b/apps/server/src/modules/board/repo/recursive-save.visitor.ts index e379cd5c788..32abcfb0747 100644 --- a/apps/server/src/modules/board/repo/recursive-save.visitor.ts +++ b/apps/server/src/modules/board/repo/recursive-save.visitor.ts @@ -25,6 +25,8 @@ import { import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; import { LinkElementNode } from '@shared/domain/entity/boardnode/link-element-node.entity'; import { ContextExternalToolEntity } from '@modules/tool/context-external-tool/entity'; +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; +import { DrawingElementNode } from '@shared/domain/entity/boardnode/drawing-element-node.entity'; import { BoardNodeRepo } from './board-node.repo'; type ParentData = { @@ -136,6 +138,19 @@ export class RecursiveSaveVisitor implements BoardCompositeVisitor { this.saveRecursive(boardNode, richTextElement); } + visitDrawingElement(drawingElement: DrawingElement): void { + const parentData = this.parentsMap.get(drawingElement.id); + + const boardNode = new DrawingElementNode({ + id: drawingElement.id, + description: drawingElement.description ?? '', + parent: parentData?.boardNode, + position: parentData?.position, + }); + + this.saveRecursive(boardNode, drawingElement); + } + visitSubmissionContainerElement(submissionContainerElement: SubmissionContainerElement): void { const parentData = this.parentsMap.get(submissionContainerElement.id); diff --git a/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts b/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts index 40a62ede2ed..04a3e8dce3c 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts @@ -4,11 +4,13 @@ import { Card, Column, ColumnBoard, + DrawingElement, ExternalToolElement, FileElement, isCard, isColumn, isColumnBoard, + isDrawingElement, isExternalToolElement, isFileElement, isLinkElement, @@ -23,6 +25,7 @@ import { cardFactory, columnBoardFactory, columnFactory, + drawingElementFactory, externalToolElementFactory, fileElementFactory, linkElementFactory, @@ -437,6 +440,62 @@ describe('recursive board copy visitor', () => { }); }); + describe('when copying a drawing element', () => { + const setup = () => { + const original = drawingElementFactory.build(); + + return { original, ...setupfileCopyService() }; + }; + + const getDrawingElementFromStatus = (status: CopyStatus) => { + const copy = status.copyEntity; + expect(isDrawingElement(copy)).toEqual(true); + return copy as DrawingElement; + }; + + it('should return a drawing element as copy', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(isDrawingElement(result.copyEntity)).toEqual(true); + }); + + it('should copy description', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getDrawingElementFromStatus(result); + + expect(copy.description).toEqual(original.description); + }); + + it('should create new id', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getDrawingElementFromStatus(result); + + expect(copy.id).not.toEqual(original.id); + }); + + it('should show status successful', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.status).toEqual(CopyStatusEnum.SUCCESS); + }); + + it('should show type RichTextElement', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.type).toEqual(CopyElementType.DRAWING_ELEMENT); + }); + }); + describe('when copying a file element', () => { const setup = () => { const original = fileElementFactory.build(); diff --git a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts index 03cdfb15b69..a70d133d324 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts @@ -6,6 +6,7 @@ import { Card, Column, ColumnBoard, + DrawingElement, EntityId, ExternalToolElement, FileElement, @@ -123,6 +124,24 @@ export class RecursiveCopyVisitor implements BoardCompositeVisitorAsync { this.copyMap.set(original.id, copy); } + async visitDrawingElementAsync(original: DrawingElement): Promise { + const copy = new DrawingElement({ + id: new ObjectId().toHexString(), + description: original.description, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + this.resultMap.set(original.id, { + copyEntity: copy, + type: CopyElementType.DRAWING_ELEMENT, + status: CopyStatusEnum.SUCCESS, + }); + this.copyMap.set(original.id, copy); + + return Promise.resolve(); + } + async visitLinkElementAsync(original: LinkElement): Promise { const copy = new LinkElement({ id: new ObjectId().toHexString(), diff --git a/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts b/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts index 0b55dfdb1de..6061c05eb42 100644 --- a/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts +++ b/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts @@ -5,6 +5,7 @@ import { columnBoardFactory, columnFactory, externalToolElementFactory, + drawingElementFactory, fileElementFactory, linkElementFactory, richTextElementFactory, @@ -93,6 +94,23 @@ describe(ContentElementUpdateVisitor.name, () => { }); }); + describe('when visiting a drawing element using the wrong content', () => { + const setup = () => { + const drawingElement = drawingElementFactory.build(); + const content = new FileContentBody(); + content.caption = 'a caption'; + const updater = new ContentElementUpdateVisitor(content); + + return { drawingElement, updater }; + }; + + it('should throw an error', async () => { + const { drawingElement, updater } = setup(); + + await expect(() => updater.visitDrawingElementAsync(drawingElement)).rejects.toThrow(); + }); + }); + describe('when visiting a submission container element using the wrong content', () => { const setup = () => { const submissionContainerElement = submissionContainerElementFactory.build(); diff --git a/apps/server/src/modules/board/service/content-element-update.visitor.ts b/apps/server/src/modules/board/service/content-element-update.visitor.ts index 86e3fb67985..77d9581dec4 100644 --- a/apps/server/src/modules/board/service/content-element-update.visitor.ts +++ b/apps/server/src/modules/board/service/content-element-update.visitor.ts @@ -13,9 +13,11 @@ import { SubmissionContainerElement, SubmissionItem, } from '@shared/domain'; +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; import { AnyElementContentBody, + DrawingContentBody, ExternalToolContentBody, FileContentBody, LinkContentBody, @@ -82,6 +84,14 @@ export class ContentElementUpdateVisitor implements BoardCompositeVisitorAsync { return this.rejectNotHandled(richTextElement); } + async visitDrawingElementAsync(drawingElement: DrawingElement): Promise { + if (this.content instanceof DrawingContentBody) { + drawingElement.description = this.content.description; + return Promise.resolve(); + } + return this.rejectNotHandled(drawingElement); + } + async visitSubmissionContainerElementAsync(submissionContainerElement: SubmissionContainerElement): Promise { if (this.content instanceof SubmissionContainerContentBody) { if (this.content.dueDate !== undefined) { diff --git a/apps/server/src/modules/board/service/content-element.service.spec.ts b/apps/server/src/modules/board/service/content-element.service.spec.ts index d70fe591bbb..73bbf150f80 100644 --- a/apps/server/src/modules/board/service/content-element.service.spec.ts +++ b/apps/server/src/modules/board/service/content-element.service.spec.ts @@ -1,5 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { NotFoundException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ContentElementFactory, @@ -9,7 +9,7 @@ import { RichTextElement, SubmissionContainerElement, } from '@shared/domain'; -import { setupEntities } from '@shared/testing'; +import { drawingElementFactory, setupEntities } from '@shared/testing'; import { cardFactory, fileElementFactory, @@ -18,6 +18,7 @@ import { submissionContainerElementFactory, } from '@shared/testing/factory/domainobject'; import { + DrawingContentBody, FileContentBody, LinkContentBody, RichTextContentBody, @@ -190,6 +191,25 @@ describe(ContentElementService.name, () => { expect(boardDoRepo.save).toHaveBeenCalledWith([richTextElement], card); }); }); + + describe('when creating a drawing element multiple times', () => { + const setup = () => { + const card = cardFactory.build(); + const drawingElement = drawingElementFactory.build(); + + contentElementFactory.build.mockReturnValue(drawingElement); + + return { card, drawingElement }; + }; + + it('should return error for second creation', async () => { + const { card } = setup(); + + await service.create(card, ContentElementType.DRAWING); + + await expect(service.create(card, ContentElementType.DRAWING)).rejects.toThrow(BadRequestException); + }); + }); }); describe('delete', () => { @@ -248,6 +268,34 @@ describe(ContentElementService.name, () => { }); }); + describe('when element is a drawing element', () => { + const setup = () => { + const drawingElement = drawingElementFactory.build(); + const content = new DrawingContentBody(); + content.description = 'test-description'; + const card = cardFactory.build(); + boardDoRepo.findParentOfId.mockResolvedValue(card); + + return { drawingElement, content, card }; + }; + + it('should update the element', async () => { + const { drawingElement, content } = setup(); + + await service.update(drawingElement, content); + + expect(drawingElement.description).toEqual(content.description); + }); + + it('should persist the element', async () => { + const { drawingElement, content, card } = setup(); + + await service.update(drawingElement, content); + + expect(boardDoRepo.save).toHaveBeenCalledWith(drawingElement, card); + }); + }); + describe('when element is a file element', () => { const setup = () => { const fileElement = fileElementFactory.build(); diff --git a/apps/server/src/modules/board/uc/card.uc.spec.ts b/apps/server/src/modules/board/uc/card.uc.spec.ts index 7df87747b9c..004944f6dd5 100644 --- a/apps/server/src/modules/board/uc/card.uc.spec.ts +++ b/apps/server/src/modules/board/uc/card.uc.spec.ts @@ -6,6 +6,7 @@ import { cardFactory, richTextElementFactory } from '@shared/testing/factory/dom import { LegacyLogger } from '@src/core/logger'; import { AuthorizationService } from '@modules/authorization'; import { ObjectId } from 'bson'; +import { HttpService } from '@nestjs/axios'; import { BoardDoAuthorizableService, ContentElementService, CardService } from '../service'; import { CardUc } from './card.uc'; @@ -41,6 +42,10 @@ describe(CardUc.name, () => { provide: LegacyLogger, useValue: createMock(), }, + { + provide: HttpService, + useValue: createMock(), + }, ], }).compile(); diff --git a/apps/server/src/modules/board/uc/element.uc.spec.ts b/apps/server/src/modules/board/uc/element.uc.spec.ts index e17c20bb067..e28fb7638ec 100644 --- a/apps/server/src/modules/board/uc/element.uc.spec.ts +++ b/apps/server/src/modules/board/uc/element.uc.spec.ts @@ -3,6 +3,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BoardDoAuthorizable, InputFormat } from '@shared/domain'; import { fileElementFactory, + drawingElementFactory, richTextElementFactory, setupEntities, submissionContainerElementFactory, @@ -13,6 +14,7 @@ import { Logger } from '@src/core/logger'; import { AuthorizationService, Action } from '@modules/authorization'; import { ObjectId } from 'bson'; import { ForbiddenException } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; import { BoardDoAuthorizableService, ContentElementService, SubmissionItemService } from '../service'; import { ElementUc } from './element.uc'; @@ -47,6 +49,10 @@ describe(ElementUc.name, () => { provide: Logger, useValue: createMock(), }, + { + provide: HttpService, + useValue: createMock(), + }, ], }).compile(); @@ -179,16 +185,18 @@ describe(ElementUc.name, () => { expect(elementService.delete).toHaveBeenCalledWith(element); }); }); + describe('when deleting a content element', () => { const setup = () => { const user = userFactory.build(); const element = richTextElementFactory.build(); + const drawingElement = drawingElementFactory.build(); boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValue( new BoardDoAuthorizable({ users: [], id: new ObjectId().toHexString() }) ); - return { user, element }; + return { user, element, drawingElement }; }; it('should call the service to find the element', async () => { diff --git a/apps/server/src/modules/copy-helper/types/copy.types.ts b/apps/server/src/modules/copy-helper/types/copy.types.ts index fff1f0da795..b1968ec4dee 100644 --- a/apps/server/src/modules/copy-helper/types/copy.types.ts +++ b/apps/server/src/modules/copy-helper/types/copy.types.ts @@ -22,6 +22,7 @@ export enum CopyElementType { 'EXTERNAL_TOOL_ELEMENT' = 'EXTERNAL_TOOL_ELEMENT', 'FILE' = 'FILE', 'FILE_ELEMENT' = 'FILE_ELEMENT', + 'DRAWING_ELEMENT' = 'DRAWING_ELEMENT', 'FILE_GROUP' = 'FILE_GROUP', 'LEAF' = 'LEAF', 'LESSON' = 'LESSON', diff --git a/apps/server/src/modules/tldraw-client/index.ts b/apps/server/src/modules/tldraw-client/index.ts new file mode 100644 index 00000000000..5b97403deca --- /dev/null +++ b/apps/server/src/modules/tldraw-client/index.ts @@ -0,0 +1 @@ +export * from './tldraw-client.module'; 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 new file mode 100644 index 00000000000..6d738d8782d --- /dev/null +++ b/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.spec.ts @@ -0,0 +1,65 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { HttpStatus } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { axiosResponseFactory, setupEntities } from '@shared/testing'; +import { HttpService } from '@nestjs/axios'; +import { of } from 'rxjs'; +import { LegacyLogger } from '@src/core/logger'; +import { DrawingElementAdapterService } from './drawing-element-adapter.service'; + +describe(DrawingElementAdapterService.name, () => { + let module: TestingModule; + let service: DrawingElementAdapterService; + let httpService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + DrawingElementAdapterService, + { + provide: HttpService, + useValue: createMock(), + }, + { + provide: LegacyLogger, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(DrawingElementAdapterService); + httpService = module.get(HttpService); + + await setupEntities(); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('deleteDrawingBinData', () => { + describe('when calling the delete drawing method', () => { + const setup = () => { + httpService.delete.mockReturnValue( + of( + axiosResponseFactory.build({ + data: '', + status: HttpStatus.OK, + statusText: 'OK', + }) + ) + ); + }; + + it('should call axios delete method', async () => { + setup(); + await service.deleteDrawingBinData('test'); + expect(httpService.delete).toHaveBeenCalled(); + }); + }); + }); +}); 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 new file mode 100644 index 00000000000..ff3f18abfb6 --- /dev/null +++ b/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { LegacyLogger } from '@src/core/logger'; +import { firstValueFrom } from 'rxjs'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { HttpService } from '@nestjs/axios'; + +@Injectable() +export class DrawingElementAdapterService { + constructor(private logger: LegacyLogger, private readonly httpService: HttpService) { + this.logger.setContext(DrawingElementAdapterService.name); + } + + async deleteDrawingBinData(docName: string): Promise { + await firstValueFrom( + this.httpService.delete(`${Configuration.get('TLDRAW_URI') as string}/api/v3/tldraw-document/${docName}`, { + headers: { + Accept: 'Application/json', + }, + }) + ); + } +} diff --git a/apps/server/src/modules/tldraw-client/service/index.ts b/apps/server/src/modules/tldraw-client/service/index.ts new file mode 100644 index 00000000000..10a16c9972a --- /dev/null +++ b/apps/server/src/modules/tldraw-client/service/index.ts @@ -0,0 +1 @@ +export * from './drawing-element-adapter.service'; diff --git a/apps/server/src/modules/tldraw-client/tldraw-client.module.ts b/apps/server/src/modules/tldraw-client/tldraw-client.module.ts new file mode 100644 index 00000000000..e015715b208 --- /dev/null +++ b/apps/server/src/modules/tldraw-client/tldraw-client.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; +import { DrawingElementAdapterService } from './service'; + +@Module({ + imports: [LoggerModule], + providers: [DrawingElementAdapterService], + exports: [], +}) +export class TldrawClientModule {} diff --git a/apps/server/src/modules/tldraw/config.ts b/apps/server/src/modules/tldraw/config.ts new file mode 100644 index 00000000000..a892ee6c843 --- /dev/null +++ b/apps/server/src/modules/tldraw/config.ts @@ -0,0 +1,30 @@ +import { Configuration } from '@hpi-schul-cloud/commons'; + +export interface TldrawConfig { + NEST_LOG_LEVEL: string; + INCOMING_REQUEST_TIMEOUT: number; + TLDRAW_DB_COLLECTION_NAME: string; + TLDRAW_DB_FLUSH_SIZE: string; + TLDRAW_DB_MULTIPLE_COLLECTIONS: boolean; + CONNECTION_STRING: string; + FEATURE_TLDRAW_ENABLED: boolean; + TLDRAW_PING_TIMEOUT: number; + TLDRAW_GC_ENABLED: number; +} + +const tldrawConnectionString: string = Configuration.get('TLDRAW_DB_URL') as string; + +const tldrawConfig = { + NEST_LOG_LEVEL: Configuration.get('NEST_LOG_LEVEL') as string, + INCOMING_REQUEST_TIMEOUT: Configuration.get('INCOMING_REQUEST_TIMEOUT_API') as number, + TLDRAW_DB_COLLECTION_NAME: Configuration.get('TLDRAW__DB_COLLECTION_NAME') as string, + TLDRAW_DB_FLUSH_SIZE: Configuration.get('TLDRAW__DB_FLUSH_SIZE') as number, + TLDRAW_DB_MULTIPLE_COLLECTIONS: Configuration.get('TLDRAW__DB_MULTIPLE_COLLECTIONS') as boolean, + FEATURE_TLDRAW_ENABLED: Configuration.get('FEATURE_TLDRAW_ENABLED') as boolean, + CONNECTION_STRING: tldrawConnectionString, + TLDRAW_PING_TIMEOUT: Configuration.get('TLDRAW__PING_TIMEOUT') as number, + TLDRAW_GC_ENABLED: Configuration.get('TLDRAW__GC_ENABLED') as boolean, +}; + +export const SOCKET_PORT = Configuration.get('TLDRAW__SOCKET_PORT') as number; +export const config = () => tldrawConfig; 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 new file mode 100644 index 00000000000..ade447b127c --- /dev/null +++ b/apps/server/src/modules/tldraw/controller/api-test/tldraw.ws.api.spec.ts @@ -0,0 +1,129 @@ +import { WsAdapter } from '@nestjs/platform-ws'; +import { Test } from '@nestjs/testing'; +import WebSocket from 'ws'; +import { TextEncoder } from 'util'; +import { INestApplication } from '@nestjs/common'; +import { TldrawWsTestModule } from '@src/modules/tldraw/tldraw-ws-test.module'; +import { TldrawWs } from '../tldraw.ws'; +import { TestConnection } from '../../testing/test-connection'; + +describe('WebSocketController (WsAdapter)', () => { + let app: INestApplication; + let gateway: TldrawWs; + let ws: WebSocket; + + 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: [TldrawWsTestModule], + }).compile(); + gateway = testingModule.get(TldrawWs); + app = testingModule.createNestApplication(); + app.useWebSocketAdapter(new WsAdapter(app)); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + jest.useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when tldraw is correctly setup', () => { + 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 and data transfer`, async () => { + const { handleConnectionSpy, buffer } = await setup(); + ws.send(buffer, () => {}); + + expect(handleConnectionSpy).toHaveBeenCalledTimes(1); + ws.close(); + }); + + it(`check if client will receive message`, async () => { + const { buffer } = await setup(); + ws.send(buffer, () => {}); + + gateway.server.on('connection', (client) => { + client.on('message', (payload) => { + expect(payload).toBeInstanceOf(ArrayBuffer); + }); + }); + + ws.close(); + }); + }); + + describe('when tldraw doc has multiple clients', () => { + const setup = async () => { + const handleConnectionSpy = jest.spyOn(gateway, 'handleConnection'); + ws = await TestConnection.setupWs(wsUrl, 'TEST2'); + const ws2 = await TestConnection.setupWs(wsUrl, 'TEST2'); + + 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).toHaveBeenCalled(); + expect(handleConnectionSpy).toHaveBeenCalledTimes(2); + + ws.close(); + ws2.close(); + }); + }); + + describe('when tldraw is not correctly setup', () => { + const setup = async () => { + const handleConnectionSpy = jest.spyOn(gateway, 'handleConnection'); + + ws = await TestConnection.setupWs(wsUrl); + + return { + handleConnectionSpy, + }; + }; + + it(`should refuse connection if there is no docName`, async () => { + const { handleConnectionSpy } = await setup(); + + const { buffer } = getMessage(); + ws.send(buffer); + + expect(gateway.server).toBeDefined(); + expect(handleConnectionSpy).toHaveBeenCalled(); + expect(handleConnectionSpy).toHaveBeenCalledTimes(1); + + ws.close(); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/controller/index.ts b/apps/server/src/modules/tldraw/controller/index.ts new file mode 100644 index 00000000000..0b0cf7d103b --- /dev/null +++ b/apps/server/src/modules/tldraw/controller/index.ts @@ -0,0 +1 @@ +export * from './tldraw.ws'; diff --git a/apps/server/src/modules/tldraw/controller/tldraw.controller.spec.ts b/apps/server/src/modules/tldraw/controller/tldraw.controller.spec.ts new file mode 100644 index 00000000000..2528fd8c4d7 --- /dev/null +++ b/apps/server/src/modules/tldraw/controller/tldraw.controller.spec.ts @@ -0,0 +1,53 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { TldrawController } from './tldraw.controller'; +import { TldrawService } from '../service/tldraw.service'; +import { TldrawDeleteParams } from './tldraw.params'; + +describe('TldrawController', () => { + let module: TestingModule; + let controller: TldrawController; + let service: TldrawService; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + { + provide: TldrawService, + useValue: createMock(), + }, + ], + controllers: [TldrawController], + }).compile(); + + controller = module.get(TldrawController); + service = module.get(TldrawService); + }); + + afterAll(async () => { + await module.close(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('delete', () => { + describe('when task should be copied via API call', () => { + const setup = () => { + const params: TldrawDeleteParams = { + docName: 'test-name', + }; + + const ucSpy = jest.spyOn(service, 'deleteByDocName').mockImplementation(() => Promise.resolve()); + return { params, ucSpy }; + }; + + it('should call service with parentIds', async () => { + const { params, ucSpy } = setup(); + await controller.deleteByDocName(params); + expect(ucSpy).toHaveBeenCalledWith('test-name'); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/controller/tldraw.controller.ts b/apps/server/src/modules/tldraw/controller/tldraw.controller.ts new file mode 100644 index 00000000000..3bc7137f5ec --- /dev/null +++ b/apps/server/src/modules/tldraw/controller/tldraw.controller.ts @@ -0,0 +1,22 @@ +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Controller, Delete, ForbiddenException, HttpCode, NotFoundException, Param } from '@nestjs/common'; +import { ApiValidationError } from '@shared/common'; +import { TldrawService } from '../service/tldraw.service'; +import { TldrawDeleteParams } from './tldraw.params'; + +@ApiTags('Tldraw Document') +@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 new file mode 100644 index 00000000000..860b46332bf --- /dev/null +++ b/apps/server/src/modules/tldraw/controller/tldraw.params.ts @@ -0,0 +1,12 @@ +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 new file mode 100644 index 00000000000..343997b2aba --- /dev/null +++ b/apps/server/src/modules/tldraw/controller/tldraw.ws.ts @@ -0,0 +1,48 @@ +import { WebSocketGateway, WebSocketServer, OnGatewayInit, OnGatewayConnection } from '@nestjs/websockets'; +import { Server, WebSocket } from 'ws'; +import { ConfigService } from '@nestjs/config'; +import { TldrawConfig, SOCKET_PORT } from '../config'; +import { WsCloseCodeEnum } from '../types'; +import { TldrawWsService } from '../service'; + +@WebSocketGateway(SOCKET_PORT) +export class TldrawWs implements OnGatewayInit, OnGatewayConnection { + @WebSocketServer() + server!: Server; + + constructor( + private readonly configService: ConfigService, + private readonly tldrawWsService: TldrawWsService + ) {} + + public handleConnection(client: WebSocket, request: Request): void { + const docName = this.getDocNameFromRequest(request); + + if (docName.length > 0 && this.configService.get('FEATURE_TLDRAW_ENABLED')) { + this.tldrawWsService.setupWSConnection(client, docName); + } else { + client.close( + WsCloseCodeEnum.WS_CLIENT_BAD_REQUEST_CODE, + 'Document name is mandatory in url or Tldraw Tool is turned off.' + ); + } + } + + public afterInit(): void { + this.tldrawWsService.setPersistence({ + bindState: async (docName, ydoc) => { + await this.tldrawWsService.updateDocument(docName, ydoc); + }, + writeState: async (docName) => { + // This is called when all connections to the document are closed. + // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access + await this.tldrawWsService.flushDocument(docName); + }, + }); + } + + private getDocNameFromRequest(request: Request): string { + const urlStripped = request.url.replace(/(\/)|(tldraw-server)/g, ''); + return urlStripped; + } +} 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 new file mode 100644 index 00000000000..78cf9ea9428 --- /dev/null +++ b/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.spec.ts @@ -0,0 +1,165 @@ +import { Test } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import WebSocket from 'ws'; +import { WsAdapter } from '@nestjs/platform-ws'; +import { CoreModule } from '@src/core'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; +import { createMock } from '@golevelup/ts-jest'; +import * as AwarenessProtocol from 'y-protocols/awareness'; +import { config } from '../config'; +import { TldrawBoardRepo } from '../repo/tldraw-board.repo'; +import { TldrawWsService } from '../service'; +import { WsSharedDocDo } from './ws-shared-doc.do'; +import { TldrawWs } from '../controller'; +import { TestConnection } from '../testing/test-connection'; + +describe('WsSharedDocDo', () => { + let app: INestApplication; + let ws: WebSocket; + let service: TldrawWsService; + + const gatewayPort = 3346; + const wsUrl = TestConnection.getWsUrl(gatewayPort); + + jest.useFakeTimers(); + + beforeAll(async () => { + const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))]; + const testingModule = await Test.createTestingModule({ + imports, + providers: [ + TldrawWs, + TldrawBoardRepo, + { + provide: TldrawWsService, + useValue: createMock(), + }, + ], + }).compile(); + + service = testingModule.get(TldrawWsService); + app = testingModule.createNestApplication(); + app.useWebSocketAdapter(new WsAdapter(app)); + jest.useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('ydoc client awareness change handler', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + + class MockAwareness { + on = jest.fn(); + } + const doc = new WsSharedDocDo('TEST', service); + 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(ws, mockIDs); + doc.conns = 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: [], + }; + doc.awarenessChangeHandler(awarenessUpdate, ws); + + 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(); + + ws.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: [], + }; + + doc.awarenessChangeHandler(awarenessUpdate, ws); + + awarenessUpdate = { + added: [], + updated: [], + removed: [1], + }; + + doc.awarenessChangeHandler(awarenessUpdate, ws); + + expect(mockIDs.size).toBe(1); + expect(mockIDs.has(1)).toBe(false); + expect(mockIDs.has(3)).toBe(true); + expect(sendSpy).toBeCalled(); + + ws.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: [], + }; + + doc.awarenessChangeHandler(awarenessUpdate, ws); + + awarenessUpdate = { + added: [], + updated: [1], + removed: [], + }; + + doc.awarenessChangeHandler(awarenessUpdate, ws); + + expect(mockIDs.size).toBe(1); + expect(mockIDs.has(1)).toBe(true); + expect(sendSpy).toBeCalled(); + + ws.close(); + sendSpy.mockRestore(); + }); + }); + }); +}); 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 new file mode 100644 index 00000000000..a7084ada0da --- /dev/null +++ b/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.ts @@ -0,0 +1,88 @@ +import { Doc } from 'yjs'; +import WebSocket from 'ws'; +import { Awareness, encodeAwarenessUpdate } from 'y-protocols/awareness'; +import { encoding } from 'lib0'; +import { TldrawWsService } from '@modules/tldraw/service'; +import { WSMessageType } from '../types/connection-enum'; + +export class WsSharedDocDo extends Doc { + public name: string; + + public conns: Map>; + + public awareness: Awareness; + + /** + * @param {string} name + * @param {TldrawWsService} tldrawService + * @param {boolean} gcEnabled + */ + constructor(name: string, private tldrawService: TldrawWsService, gcEnabled = true) { + super({ gc: gcEnabled }); + this.name = name; + this.conns = new Map(); + this.awareness = new Awareness(this); + this.awareness.setLocalState(null); + + this.awareness.on('update', this.awarenessChangeHandler); + this.on('update', (update: Uint8Array, origin, doc: WsSharedDocDo) => { + this.tldrawService.updateHandler(update, origin, doc); + }); + } + + /** + * @param {{ added: Array, updated: Array, removed: Array }} changes + * @param {WebSocket | null} wsConnection Origin is the connection that made the change + */ + public awarenessChangeHandler = ( + { added, updated, removed }: { added: Array; updated: Array; removed: Array }, + wsConnection: WebSocket | null + ): void => { + const changedClients = this.manageClientsConnections({ added, updated, removed }, wsConnection); + const buff = this.prepareAwarenessMessage(changedClients); + this.sendAwarenessMessage(buff); + }; + + /** + * @param {{ added: Array, updated: Array, removed: Array }} changes + * @param {WebSocket | null} wsConnection Origin is the connection that made the change + */ + private manageClientsConnections( + { added, updated, removed }: { added: Array; updated: Array; removed: Array }, + wsConnection: WebSocket | null + ): number[] { + const changedClients = added.concat(updated, removed); + if (wsConnection !== null) { + const connControlledIDs = this.conns.get(wsConnection); + if (connControlledIDs !== undefined) { + added.forEach((clientID) => { + connControlledIDs.add(clientID); + }); + removed.forEach((clientID) => { + connControlledIDs.delete(clientID); + }); + } + } + return changedClients; + } + + /** + * @param changedClients array of changed clients + */ + private prepareAwarenessMessage(changedClients: number[]): Uint8Array { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, WSMessageType.AWARENESS); + encoding.writeVarUint8Array(encoder, encodeAwarenessUpdate(this.awareness, changedClients)); + const message = encoding.toUint8Array(encoder); + return message; + } + + /** + * @param {{ Uint8Array }} buff encoded message about changes + */ + private sendAwarenessMessage(buff: Uint8Array): void { + this.conns.forEach((_, c) => { + this.tldrawService.send(this, c, buff); + }); + } +} diff --git a/apps/server/src/modules/tldraw/entities/index.ts b/apps/server/src/modules/tldraw/entities/index.ts new file mode 100644 index 00000000000..2e9bb23bb67 --- /dev/null +++ b/apps/server/src/modules/tldraw/entities/index.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000000..a85ae26319c --- /dev/null +++ b/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.spec.ts @@ -0,0 +1,29 @@ +import { setupEntities } from '@shared/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 = new TldrawDrawing({ + docName: 'test', + version: 'v1_tst', + value: 'bindatamock', + _id: 'test-id', + clock: 0, + action: 'update', + }); + expect(tldraw).toBeInstanceOf(TldrawDrawing); + }); + + it('should throw with empty docName', () => { + const call = () => new TldrawDrawing({ docName: '', version: 'v1_tst', value: 'bindatamock', _id: 'test-id' }); + expect(call).toThrow(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.ts b/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.ts new file mode 100644 index 00000000000..b6db76a3f2e --- /dev/null +++ b/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.ts @@ -0,0 +1,46 @@ +import { Entity, PrimaryKey, Property } from '@mikro-orm/core'; +import { BadRequestException } from '@nestjs/common'; +import { ObjectId } from '@mikro-orm/mongodb'; + +@Entity({ tableName: 'drawings' }) +export class TldrawDrawing { + constructor(props: TldrawDrawingProps) { + if (!props.docName) throw new BadRequestException('Tldraw element should have name.'); + this.docName = props.docName; + this.version = props.version; + this.value = props.value; + if (typeof props.clock === 'number') { + this.clock = props.clock; + } + if (props.action) { + this.action = props.action; + } + } + + @PrimaryKey() + _id!: ObjectId; + + @Property({ nullable: false }) + docName: string; + + @Property({ nullable: false }) + version: string; + + @Property({ nullable: false }) + value: string; + + @Property({ nullable: true }) + clock?: number; + + @Property({ nullable: true }) + action?: string; +} + +export interface TldrawDrawingProps { + _id?: string; + docName: string; + version: string; + clock?: number; + action?: string; + value: string; +} diff --git a/apps/server/src/modules/tldraw/factory/index.ts b/apps/server/src/modules/tldraw/factory/index.ts new file mode 100644 index 00000000000..7a5f39169bf --- /dev/null +++ b/apps/server/src/modules/tldraw/factory/index.ts @@ -0,0 +1 @@ +export * from './tldraw.factory'; diff --git a/apps/server/src/modules/tldraw/factory/tldraw.factory.ts b/apps/server/src/modules/tldraw/factory/tldraw.factory.ts new file mode 100644 index 00000000000..3cb63e9418b --- /dev/null +++ b/apps/server/src/modules/tldraw/factory/tldraw.factory.ts @@ -0,0 +1,14 @@ +import { BaseFactory } from '@shared/testing/factory/base.factory'; +import { TldrawDrawing, TldrawDrawingProps } from '../entities'; + +export const tldrawEntityFactory = BaseFactory.define( + TldrawDrawing, + ({ sequence }) => { + return { + _id: 'test-id', + docName: 'test-name', + value: 'test-value', + version: `test-version-${sequence}`, + }; + } +); diff --git a/apps/server/src/modules/tldraw/index.ts b/apps/server/src/modules/tldraw/index.ts new file mode 100644 index 00000000000..8966e72549e --- /dev/null +++ b/apps/server/src/modules/tldraw/index.ts @@ -0,0 +1,3 @@ +export * from './tldraw.module'; +export * from './tldraw-test.module'; +export * from './tldraw-ws.module'; diff --git a/apps/server/src/modules/tldraw/repo/index.ts b/apps/server/src/modules/tldraw/repo/index.ts new file mode 100644 index 00000000000..0c1ae29e62f --- /dev/null +++ b/apps/server/src/modules/tldraw/repo/index.ts @@ -0,0 +1 @@ +export * from './tldraw-board.repo'; 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 new file mode 100644 index 00000000000..6d9b3c799bb --- /dev/null +++ b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.spec.ts @@ -0,0 +1,221 @@ +import { Test } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import WebSocket from 'ws'; +import { WsAdapter } from '@nestjs/platform-ws'; +import { CoreModule } from '@src/core'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; +import { createMock } from '@golevelup/ts-jest'; +import { Doc } from 'yjs'; +import * as YjsUtils from '../utils/ydoc-utils'; +import { config } from '../config'; +import { TldrawBoardRepo } from './tldraw-board.repo'; +import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; +import { TldrawWsService } from '../service'; +import { TldrawWs } from '../controller'; +import { TestConnection } from '../testing/test-connection'; + +describe('TldrawBoardRepo', () => { + let app: INestApplication; + let repo: TldrawBoardRepo; + let ws: WebSocket; + let service: TldrawWsService; + + const gatewayPort = 3346; + const wsUrl = TestConnection.getWsUrl(gatewayPort); + + jest.useFakeTimers(); + + beforeAll(async () => { + const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))]; + const testingModule = await Test.createTestingModule({ + imports, + providers: [ + TldrawWs, + TldrawBoardRepo, + { + provide: TldrawWsService, + useValue: createMock(), + }, + ], + }).compile(); + + service = testingModule.get(TldrawWsService); + repo = testingModule.get(TldrawBoardRepo); + app = testingModule.createNestApplication(); + app.useWebSocketAdapter(new WsAdapter(app)); + jest.useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('should check if repo and its properties are set correctly', () => { + expect(repo).toBeDefined(); + expect(repo.mdb).toBeDefined(); + expect(repo.configService).toBeDefined(); + expect(repo.flushSize).toBeDefined(); + expect(repo.multipleCollections).toBeDefined(); + expect(repo.connectionString).toBeDefined(); + expect(repo.collectionName).toBeDefined(); + }); + + describe('updateDocument', () => { + describe('when document receives empty update', () => { + const setup = async () => { + const doc = new WsSharedDocDo('TEST2', service); + ws = await TestConnection.setupWs(wsUrl, 'TEST2'); + const wsSet = new Set(); + wsSet.add(ws); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + doc.conns.set(ws, wsSet); + const storeGetYDocSpy = jest + .spyOn(repo.mdb, 'getYDoc') + .mockImplementation(() => Promise.resolve(new WsSharedDocDo('TEST', service))); + const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdate').mockImplementation(() => Promise.resolve(1)); + + return { + doc, + storeUpdateSpy, + storeGetYDocSpy, + }; + }; + + it('should not update db with diff', async () => { + const { doc, storeUpdateSpy, storeGetYDocSpy } = await setup(); + + await repo.updateDocument('TEST2', doc); + expect(storeUpdateSpy).toHaveBeenCalledTimes(0); + storeUpdateSpy.mockRestore(); + storeGetYDocSpy.mockRestore(); + ws.close(); + }); + }); + + describe('when document receive update', () => { + const setup = async () => { + const clientMessageMock = 'test-message'; + const doc = new WsSharedDocDo('TEST', service); + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + const wsSet = new Set(); + wsSet.add(ws); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + doc.conns.set(ws, wsSet); + const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdate').mockImplementation(() => Promise.resolve(1)); + const storeGetYDocSpy = jest + .spyOn(repo.mdb, 'getYDoc') + .mockImplementation(() => Promise.resolve(new WsSharedDocDo('TEST', service))); + const byteArray = new TextEncoder().encode(clientMessageMock); + + return { + doc, + byteArray, + storeUpdateSpy, + storeGetYDocSpy, + }; + }; + + it('should update db with diff', async () => { + const { doc, byteArray, storeUpdateSpy, storeGetYDocSpy } = await setup(); + + await repo.updateDocument('TEST', doc); + doc.emit('update', [byteArray, undefined, doc]); + expect(storeUpdateSpy).toHaveBeenCalled(); + expect(storeUpdateSpy).toHaveBeenCalledTimes(1); + storeUpdateSpy.mockRestore(); + storeGetYDocSpy.mockRestore(); + ws.close(); + }); + }); + }); + + describe('getYDocFromMdb', () => { + describe('when taking doc data from db', () => { + const setup = () => { + const storeGetYDocSpy = jest + .spyOn(repo.mdb, 'getYDoc') + .mockImplementation(() => Promise.resolve(new WsSharedDocDo('TEST', service))); + + return { + storeGetYDocSpy, + }; + }; + + it('should return ydoc', async () => { + const { storeGetYDocSpy } = setup(); + expect(await repo.getYDocFromMdb('test')).toBeInstanceOf(Doc); + + storeGetYDocSpy.mockRestore(); + }); + }); + }); + + describe('updateStoredDocWithDiff', () => { + describe('when the difference between update and current drawing is more than 0', () => { + const setup = () => { + const calculateDiffSpy = jest.spyOn(YjsUtils, 'calculateDiff').mockImplementationOnce(() => 1); + const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdate').mockResolvedValueOnce(Promise.resolve(1)); + + return { + calculateDiffSpy, + storeUpdateSpy, + }; + }; + + it('should call store update method', () => { + const { storeUpdateSpy, calculateDiffSpy } = setup(); + const diffArray = new Uint8Array(); + repo.updateStoredDocWithDiff('test', diffArray); + + expect(storeUpdateSpy).toHaveBeenCalled(); + + calculateDiffSpy.mockRestore(); + storeUpdateSpy.mockRestore(); + }); + }); + + describe('when the difference between update and current drawing is 0', () => { + const setup = () => { + const calculateDiffSpy = jest.spyOn(YjsUtils, 'calculateDiff').mockImplementationOnce(() => 0); + const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdate'); + + return { + calculateDiffSpy, + storeUpdateSpy, + }; + }; + + it('should not call store update method', () => { + const { storeUpdateSpy, calculateDiffSpy } = setup(); + const diffArray = new Uint8Array(); + repo.updateStoredDocWithDiff('test', diffArray); + + expect(storeUpdateSpy).not.toHaveBeenCalled(); + + calculateDiffSpy.mockRestore(); + storeUpdateSpy.mockRestore(); + }); + }); + }); + + describe('flushDocument', () => { + const setup = () => { + const flushDocumentSpy = jest.spyOn(repo.mdb, 'flushDocument').mockResolvedValueOnce(Promise.resolve()); + + return { flushDocumentSpy }; + }; + + it('should call flush method on mdbPersistence', async () => { + const { flushDocumentSpy } = setup(); + await repo.flushDocument('test'); + + expect(flushDocumentSpy).toHaveBeenCalled(); + + flushDocumentSpy.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 new file mode 100644 index 00000000000..ce3a124f7f0 --- /dev/null +++ b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@nestjs/common'; +import { MongodbPersistence } from 'y-mongodb-provider'; +import { ConfigService } from '@nestjs/config'; +import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector } from 'yjs'; +import { TldrawConfig } from '../config'; +import { calculateDiff } from '../utils'; +import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; + +@Injectable() +export class TldrawBoardRepo { + public connectionString: string; + + public collectionName: string; + + public flushSize: number; + + public multipleCollections: boolean; + + public mdb: MongodbPersistence; + + constructor(public readonly configService: ConfigService) { + this.connectionString = this.configService.get('CONNECTION_STRING'); + this.collectionName = this.configService.get('TLDRAW_DB_COLLECTION_NAME') ?? 'drawings'; + this.flushSize = this.configService.get('TLDRAW_DB_FLUSH_SIZE') ?? 400; + this.multipleCollections = this.configService.get('TLDRAW_DB_MULTIPLE_COLLECTIONS'); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-assignment + this.mdb = new MongodbPersistence(this.connectionString, { + collectionName: this.collectionName, + flushSize: this.flushSize, + multipleCollections: this.multipleCollections, + }); + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // eslint-disable-next-line consistent-return + public async getYDocFromMdb(docName: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access + const yDoc = await this.mdb.getYDoc(docName); + if (yDoc instanceof Doc) { + return yDoc; + } + } + + public updateStoredDocWithDiff(docName: string, diff: Uint8Array): void { + const calc = calculateDiff(diff); + if (calc > 0) { + void this.mdb.storeUpdate(docName, diff); + } + } + + public async updateDocument(docName: string, ydoc: WsSharedDocDo): Promise { + const persistedYdoc = await this.getYDocFromMdb(docName); + const persistedStateVector = encodeStateVector(persistedYdoc); + const diff = encodeStateAsUpdate(ydoc, persistedStateVector); + this.updateStoredDocWithDiff(docName, diff); + + applyUpdate(ydoc, encodeStateAsUpdate(persistedYdoc)); + + ydoc.on('update', (update: Uint8Array) => { + void this.mdb.storeUpdate(docName, update); + }); + + persistedYdoc.destroy(); + } + + public async flushDocument(docName: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access + await this.mdb.flushDocument(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 new file mode 100644 index 00000000000..9e6f5eabb14 --- /dev/null +++ b/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts @@ -0,0 +1,92 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { cleanupCollections } from '@shared/testing'; +import { tldrawEntityFactory } from '@src/modules/tldraw/factory'; +import { TldrawDrawing } from '@src/modules/tldraw/entities'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { TldrawRepo } from './tldraw.repo'; + +describe(TldrawRepo.name, () => { + let module: TestingModule; + let repo: TldrawRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] })], + providers: [TldrawRepo], + }).compile(); + repo = module.get(TldrawRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('create', () => { + describe('when called', () => { + it('should create new drawing node', async () => { + const drawing = tldrawEntityFactory.build(); + + await repo.create(drawing); + em.clear(); + + 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); + }); + + it('should not find any record giving wrong docName', async () => { + const result = await repo.findByDocName('invalid-name'); + expect(result.length).toEqual(0); + }); + }); + }); + + describe('delete', () => { + describe('when finding by docName and deleting all records', () => { + it('should delete all records', async () => { + const drawing = tldrawEntityFactory.build(); + await repo.create(drawing); + + const results = await repo.findByDocName(drawing.docName); + await repo.delete(results); + + const emptyResults = await repo.findByDocName(drawing.docName); + expect(emptyResults.length).toEqual(0); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/repo/tldraw.repo.ts b/apps/server/src/modules/tldraw/repo/tldraw.repo.ts new file mode 100644 index 00000000000..d826b2876ff --- /dev/null +++ b/apps/server/src/modules/tldraw/repo/tldraw.repo.ts @@ -0,0 +1,20 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { TldrawDrawing } from '../entities'; + +@Injectable() +export class TldrawRepo { + constructor(private readonly _em: EntityManager) {} + + async create(entity: TldrawDrawing): Promise { + await this._em.persistAndFlush(entity); + } + + async findByDocName(docName: string): Promise { + return this._em.find(TldrawDrawing, { docName }); + } + + async delete(entity: TldrawDrawing | TldrawDrawing[]): Promise { + await this._em.removeAndFlush(entity); + } +} diff --git a/apps/server/src/modules/tldraw/service/index.ts b/apps/server/src/modules/tldraw/service/index.ts new file mode 100644 index 00000000000..a056b2ece10 --- /dev/null +++ b/apps/server/src/modules/tldraw/service/index.ts @@ -0,0 +1 @@ +export * from './tldraw.ws.service'; diff --git a/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts b/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts new file mode 100644 index 00000000000..cc3a317ec3c --- /dev/null +++ b/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts @@ -0,0 +1,53 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { cleanupCollections } from '@shared/testing'; +import { TldrawDrawing } from '../entities'; +import { tldrawEntityFactory } from '../factory'; +import { TldrawRepo } from '../repo/tldraw.repo'; +import { TldrawService } from './tldraw.service'; + +describe(TldrawService.name, () => { + let module: TestingModule; + let service: TldrawService; + let repo: TldrawRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] })], + 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.resetAllMocks(); + }); + + 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); + const result = await repo.findByDocName(drawing.docName); + + expect(result.length).toEqual(1); + + await service.deleteByDocName(drawing.docName); + const emptyResult = await repo.findByDocName(drawing.docName); + + expect(emptyResult.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 new file mode 100644 index 00000000000..4e0aa3db8db --- /dev/null +++ b/apps/server/src/modules/tldraw/service/tldraw.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { TldrawRepo } from '../repo/tldraw.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 new file mode 100644 index 00000000000..ddd186fed0a --- /dev/null +++ b/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts @@ -0,0 +1,449 @@ +import { Test } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import WebSocket from 'ws'; +import { WsAdapter } from '@nestjs/platform-ws'; +import { CoreModule } from '@src/core'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; +import { TextEncoder } from 'util'; +import * as SyncProtocols from 'y-protocols/sync'; +import * as AwarenessProtocol from 'y-protocols/awareness'; +import { encoding } from 'lib0'; +import { TldrawWsFactory } from '@shared/testing/factory/tldraw.ws.factory'; +import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; +import { config } from '../config'; +import { TldrawBoardRepo } from '../repo'; +import { TldrawWs } from '../controller'; +import { TldrawWsService } from '.'; +import { TestConnection } from '../testing/test-connection'; + +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; +}); + +describe('TldrawWSService', () => { + let app: INestApplication; + let ws: WebSocket; + let service: TldrawWsService; + + const gatewayPort = 3346; + const wsUrl = TestConnection.getWsUrl(gatewayPort); + + const delay = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + + jest.useFakeTimers(); + + beforeAll(async () => { + const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))]; + const testingModule = await Test.createTestingModule({ + imports, + providers: [TldrawWs, TldrawBoardRepo, TldrawWsService], + }).compile(); + + service = testingModule.get(TldrawWsService); + app = testingModule.createNestApplication(); + app.useWebSocketAdapter(new WsAdapter(app)); + jest.useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + 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, + }; + }; + + it('should chcek if service properties are set correctly', () => { + expect(service).toBeDefined(); + expect(service.pingTimeout).toBeDefined(); + expect(service.persistence).toBeDefined(); + }); + + describe('send', () => { + describe('when client is not connected to WS', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + const clientMessageMock = 'test-message'; + + const closeConSpy = jest.spyOn(service, 'closeConn').mockImplementationOnce(() => {}); + const sendSpy = jest.spyOn(service, 'send'); + const doc = TldrawWsFactory.createWsSharedDocDo(); + const byteArray = new TextEncoder().encode(clientMessageMock); + + return { + closeConSpy, + sendSpy, + doc, + byteArray, + }; + }; + + it('should throw error for send message', async () => { + const { closeConSpy, sendSpy, doc, byteArray } = await setup(); + + service.send(doc, ws, byteArray); + + expect(sendSpy).toThrow(); + expect(sendSpy).toHaveBeenCalledWith(doc, ws, byteArray); + expect(closeConSpy).toHaveBeenCalled(); + + ws.close(); + sendSpy.mockRestore(); + }); + }); + + describe('when websocket has ready state different than 0 or 1', () => { + const setup = () => { + const clientMessageMock = 'test-message'; + const closeConSpy = jest.spyOn(service, 'closeConn'); + const sendSpy = jest.spyOn(service, 'send'); + const doc = TldrawWsFactory.createWsSharedDocDo(); + const socketMock = TldrawWsFactory.createWebsocket(3); + 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); + expect(closeConSpy).toHaveBeenCalled(); + + closeConSpy.mockRestore(); + sendSpy.mockRestore(); + }); + }); + + describe('when websocket has ready state 0', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + const clientMessageMock = 'test-message'; + + const sendSpy = jest.spyOn(service, 'send'); + const doc = TldrawWsFactory.createWsSharedDocDo(); + const socketMock = TldrawWsFactory.createWebsocket(0); + doc.conns.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, + }; + }; + + it('should call send in updateHandler', async () => { + const { sendSpy, doc, msg } = await setup(); + + service.updateHandler(msg, {}, doc); + + expect(sendSpy).toHaveBeenCalled(); + + ws.close(); + sendSpy.mockRestore(); + }); + }); + + describe('when received message of specific type', () => { + const setup = async (messageValues: number[]) => { + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + + 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', service); + const { msg } = createMessage(messageValues); + + return { + sendSpy, + 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(ws, doc, msg); + + expect(sendSpy).toHaveBeenCalledTimes(1); + + ws.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(ws, doc, msg); + + expect(sendSpy).toHaveBeenCalledTimes(0); + expect(applyAwarenessUpdateSpy).toHaveBeenCalledTimes(1); + + ws.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(ws, doc, msg); + + expect(sendSpy).toHaveBeenCalledTimes(0); + expect(applyAwarenessUpdateSpy).toHaveBeenCalledTimes(0); + + ws.close(); + sendSpy.mockRestore(); + applyAwarenessUpdateSpy.mockRestore(); + syncProtocolUpdateSpy.mockRestore(); + }); + }); + + describe('when error is thrown during receiving message', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + + const sendSpy = jest.spyOn(service, 'send'); + jest.spyOn(SyncProtocols, 'readSyncMessage').mockImplementationOnce(() => { + throw new Error('error'); + }); + const doc = new WsSharedDocDo('TEST', service); + const { msg } = createMessage([0]); + + return { + sendSpy, + doc, + msg, + }; + }; + + it('should not call send method', async () => { + const { sendSpy, doc, msg } = await setup(); + + service.messageHandler(ws, doc, msg); + + expect(sendSpy).toHaveBeenCalledTimes(0); + + ws.close(); + sendSpy.mockRestore(); + }); + }); + + describe('when awareness states (clients) size is greater then one', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + + const doc = new WsSharedDocDo('TEST', service); + doc.awareness.states = new Map(); + doc.awareness.states.set(1, ['test1']); + doc.awareness.states.set(2, ['test2']); + + const messageHandlerSpy = jest.spyOn(service, 'messageHandler').mockImplementationOnce(() => {}); + const sendSpy = jest.spyOn(service, 'send'); + const getYDocSpy = jest.spyOn(service, 'getYDoc').mockImplementationOnce(() => doc); + const { msg } = createMessage([0]); + jest.spyOn(AwarenessProtocol, 'encodeAwarenessUpdate').mockImplementationOnce(() => msg); + + return { + messageHandlerSpy, + sendSpy, + getYDocSpy, + }; + }; + + it('should send to every client', async () => { + const { messageHandlerSpy, sendSpy, getYDocSpy } = await setup(); + + service.setupWSConnection(ws); + + expect(sendSpy).toHaveBeenCalledTimes(2); + + ws.close(); + messageHandlerSpy.mockRestore(); + sendSpy.mockRestore(); + getYDocSpy.mockRestore(); + }); + }); + }); + + describe('closeConn', () => { + describe('when trying to close already closed connection', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + + jest.spyOn(ws, 'close').mockImplementationOnce(() => { + throw new Error('some error'); + }); + }; + + it('should throw error', async () => { + await setup(); + try { + const doc = TldrawWsFactory.createWsSharedDocDo(); + service.closeConn(doc, ws); + } catch (err) { + expect(err).toBeDefined(); + } + + ws.close(); + }); + }); + + describe('when ping failed', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + + const messageHandlerSpy = jest.spyOn(service, 'messageHandler').mockImplementationOnce(() => {}); + const closeConnSpy = jest.spyOn(service, 'closeConn'); + jest.spyOn(ws, 'ping').mockImplementationOnce(() => { + throw new Error('error'); + }); + + return { + messageHandlerSpy, + closeConnSpy, + }; + }; + + it('should close connection', async () => { + const { messageHandlerSpy, closeConnSpy } = await setup(); + + service.setupWSConnection(ws); + + await delay(10); + + expect(closeConnSpy).toHaveBeenCalled(); + + ws.close(); + messageHandlerSpy.mockRestore(); + closeConnSpy.mockRestore(); + }); + }); + }); + + describe('messageHandler', () => { + describe('when message is received', () => { + const setup = async (messageValues: number[]) => { + ws = 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 { msg } = createMessage(messageValues); + + return { + messageHandlerSpy, + msg, + readSyncMessageSpy, + }; + }; + + it('should handle message', async () => { + const { messageHandlerSpy, msg, readSyncMessageSpy } = await setup([0, 1]); + + service.setupWSConnection(ws); + ws.emit('message', msg); + + expect(messageHandlerSpy).toHaveBeenCalledTimes(1); + + ws.close(); + messageHandlerSpy.mockRestore(); + readSyncMessageSpy.mockRestore(); + }); + }); + }); + + describe('getYDoc', () => { + describe('when getting yDoc by name', () => { + it('should assign to service.doc and return instance', () => { + const docName = 'get-test'; + const doc = service.getYDoc(docName); + expect(doc).toBeInstanceOf(WsSharedDocDo); + expect(service.docs.get(docName)).not.toBeUndefined(); + }); + }); + }); + + describe('updateDocument', () => { + const setup = () => { + const updateDocumentSpy = jest.spyOn(service, 'updateDocument').mockImplementation(() => Promise.resolve()); + + return { updateDocumentSpy }; + }; + + it('should call update method', async () => { + const { updateDocumentSpy } = setup(); + await service.updateDocument('test', TldrawWsFactory.createWsSharedDocDo()); + + expect(updateDocumentSpy).toHaveBeenCalled(); + + updateDocumentSpy.mockRestore(); + }); + }); + + describe('flushDocument', () => { + const setup = () => { + const flushDocumentSpy = jest.spyOn(service, 'flushDocument').mockResolvedValueOnce(Promise.resolve()); + + return { flushDocumentSpy }; + }; + + it('should call flush method', async () => { + const { flushDocumentSpy } = setup(); + await service.flushDocument('test'); + + expect(flushDocumentSpy).toHaveBeenCalled(); + + flushDocumentSpy.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 new file mode 100644 index 00000000000..660f5258fa8 --- /dev/null +++ b/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts @@ -0,0 +1,209 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import WebSocket from 'ws'; +import { applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'; +import { encoding, decoding, map } from 'lib0'; +import { readSyncMessage, writeSyncStep1, writeUpdate } from 'y-protocols/sync'; +import { Persitence, WSConnectionState, WSMessageType } from '../types'; +import { TldrawConfig } from '../config'; +import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; +import { TldrawBoardRepo } from '../repo'; + +@Injectable() +export class TldrawWsService { + public pingTimeout: number; + + public persistence: Persitence | null = null; + + public docs = new Map(); + + constructor( + private readonly configService: ConfigService, + private readonly tldrawBoardRepo: TldrawBoardRepo + ) { + this.pingTimeout = this.configService.get('TLDRAW_PING_TIMEOUT'); + } + + public setPersistence(persistence_: Persitence): void { + this.persistence = persistence_; + } + + /** + * @param {WsSharedDocDo} doc + * @param {WebSocket} ws + */ + public closeConn(doc: WsSharedDocDo, ws: WebSocket): void { + if (doc.conns.has(ws)) { + const controlledIds = doc.conns.get(ws) as Set; + doc.conns.delete(ws); + removeAwarenessStates(doc.awareness, Array.from(controlledIds), null); + if (doc.conns.size === 0 && this.persistence !== null) { + // if persisted, we store state and destroy ydocument + this.persistence + .writeState(doc.name, doc) + .then(() => { + doc.destroy(); + return null; + }) + .catch(() => {}); + this.docs.delete(doc.name); + } + } + + try { + ws.close(); + } catch (err) { + throw new Error('Cannot close the connection. It is possible that connection is already closed.'); + } + } + + /** + * @param {WsSharedDocDo} doc + * @param {WebSocket} conn + * @param {Uint8Array} message + */ + public send(doc: WsSharedDocDo, conn: WebSocket, message: Uint8Array): void { + if (conn.readyState !== WSConnectionState.CONNECTING && conn.readyState !== WSConnectionState.OPEN) { + this.closeConn(doc, conn); + } + try { + conn.send(message, (err: Error | undefined) => { + if (err != null) { + this.closeConn(doc, conn); + } + }); + } catch (e) { + this.closeConn(doc, conn); + } + } + + /** + * @param {Uint8Array} update + * @param {any} origin + * @param {WsSharedDocDo} doc + */ + public updateHandler(update: Uint8Array, origin, doc: WsSharedDocDo): void { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, WSMessageType.SYNC); + writeUpdate(encoder, update); + const message = encoding.toUint8Array(encoder); + doc.conns.forEach((_, conn) => { + this.send(doc, conn, message); + }); + } + + /** + * Gets a Y.Doc by name, whether in memory or on disk + * + * @param {string} docName - the name of the Y.Doc to find or create + * @param {boolean} gc - whether to allow gc on the doc (applies only when created) + * @return {WsSharedDocDo} + */ + getYDoc(docName: string, gc = true): WsSharedDocDo { + return map.setIfUndefined(this.docs, docName, () => { + const doc = new WsSharedDocDo(docName, this, gc); + if (this.persistence !== null) { + this.persistence.bindState(docName, doc).catch(() => {}); + } + this.docs.set(docName, doc); + return doc; + }); + } + + public messageHandler(conn: WebSocket, doc: WsSharedDocDo, message: Uint8Array): void { + try { + const encoder = encoding.createEncoder(); + const decoder = decoding.createDecoder(message); + const messageType = decoding.readVarUint(decoder); + switch (messageType) { + case WSMessageType.SYNC: + encoding.writeVarUint(encoder, WSMessageType.SYNC); + readSyncMessage(decoder, encoder, doc, conn); + + // 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, conn, encoding.toUint8Array(encoder)); + } + break; + case WSMessageType.AWARENESS: { + applyAwarenessUpdate(doc.awareness, decoding.readVarUint8Array(decoder), conn); + break; + } + default: + break; + } + } catch (err) { + doc.emit('error', [err]); + } + } + + /** + * @param {WebSocket} ws + * @param {string} docName + */ + public setupWSConnection(ws: WebSocket, docName = 'GLOBAL'): void { + ws.binaryType = 'arraybuffer'; + // get doc, initialize if it does not exist yet + const doc = this.getYDoc(docName, true); + doc.conns.set(ws, new Set()); + + // listen and reply to events + ws.on('message', (message: ArrayBufferLike) => { + this.messageHandler(ws, doc, new Uint8Array(message)); + }); + + // Check if connection is still alive + let pongReceived = true; + const pingInterval = setInterval(() => { + const hasConn = doc.conns.has(ws); + + if (pongReceived) { + if (!hasConn) return; + pongReceived = false; + + try { + ws.ping(); + } catch (e) { + this.closeConn(doc, ws); + clearInterval(pingInterval); + } + return; + } + + if (hasConn) { + this.closeConn(doc, ws); + } + + clearInterval(pingInterval); + }, this.pingTimeout); + ws.on('close', () => { + this.closeConn(doc, ws); + clearInterval(pingInterval); + }); + ws.on('pong', () => { + pongReceived = true; + }); + { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, WSMessageType.SYNC); + writeSyncStep1(encoder, doc); + this.send(doc, ws, encoding.toUint8Array(encoder)); + const awarenessStates = doc.awareness.getStates(); + if (awarenessStates.size > 0) { + encoding.writeVarUint(encoder, WSMessageType.AWARENESS); + encoding.writeVarUint8Array(encoder, encodeAwarenessUpdate(doc.awareness, Array.from(awarenessStates.keys()))); + this.send(doc, ws, encoding.toUint8Array(encoder)); + } + } + } + + public async updateDocument(docName: string, ydoc: WsSharedDocDo): Promise { + await this.tldrawBoardRepo.updateDocument(docName, ydoc); + } + + public async flushDocument(docName: string): Promise { + await this.tldrawBoardRepo.flushDocument(docName); + } +} diff --git a/apps/server/src/modules/tldraw/testing/test-connection.ts b/apps/server/src/modules/tldraw/testing/test-connection.ts new file mode 100644 index 00000000000..638c219ea18 --- /dev/null +++ b/apps/server/src/modules/tldraw/testing/test-connection.ts @@ -0,0 +1,22 @@ +import WebSocket from 'ws'; + +export class TestConnection { + public static getWsUrl = (gatewayPort: number): string => { + const wsUrl = `ws://localhost:${gatewayPort}`; + return wsUrl; + }; + + public static setupWs = async (wsUrl: string, docName?: string): Promise => { + let ws: WebSocket; + if (docName) { + ws = new WebSocket(`${wsUrl}/${docName}`); + } else { + ws = new WebSocket(`${wsUrl}`); + } + await new Promise((resolve) => { + ws.on('open', resolve); + }); + + return ws; + }; +} diff --git a/apps/server/src/modules/tldraw/tldraw-test.module.ts b/apps/server/src/modules/tldraw/tldraw-test.module.ts new file mode 100644 index 00000000000..19c38171b88 --- /dev/null +++ b/apps/server/src/modules/tldraw/tldraw-test.module.ts @@ -0,0 +1,36 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { MongoMemoryDatabaseModule, MongoDatabaseModuleOptions } from '@infra/database'; +import { CoreModule } from '@src/core'; +import { LoggerModule } from '@src/core/logger'; +import { AuthenticationModule } from '@modules/authentication/authentication.module'; +import { AuthorizationModule } from '@modules/authorization'; +import { Course, User } from '@shared/domain'; +import { AuthenticationApiModule } from '../authentication/authentication-api.module'; +import { TldrawWsModule } from './tldraw-ws.module'; +import { TldrawWs } from './controller'; +import { TldrawBoardRepo } from './repo'; +import { TldrawWsService } from './service'; + +const imports = [ + TldrawWsModule, + MongoMemoryDatabaseModule.forRoot({ entities: [User, Course] }), + AuthenticationApiModule, + AuthorizationModule, + AuthenticationModule, + CoreModule, + LoggerModule, +]; +const providers = [TldrawWs, TldrawBoardRepo, TldrawWsService]; +@Module({ + imports, + providers, +}) +export class TldrawTestModule { + static forRoot(options?: MongoDatabaseModuleOptions): DynamicModule { + return { + module: TldrawTestModule, + imports: [...imports, MongoMemoryDatabaseModule.forRoot({ ...options })], + providers, + }; + } +} diff --git a/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts b/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts new file mode 100644 index 00000000000..6e3c5a58479 --- /dev/null +++ b/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts @@ -0,0 +1,25 @@ +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 { TldrawBoardRepo } from './repo'; +import { TldrawWsService } from './service'; +import { config } from './config'; +import { TldrawWs } from './controller'; + +const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))]; +const providers = [TldrawWs, TldrawBoardRepo, TldrawWsService]; +@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 new file mode 100644 index 00000000000..98e91b5b3e6 --- /dev/null +++ b/apps/server/src/modules/tldraw/tldraw-ws.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; +import { CoreModule } from '@src/core'; +import { Logger } from '@src/core/logger'; +import { TldrawBoardRepo } from './repo'; +import { TldrawWsService } from './service'; +import { TldrawWs } from './controller'; +import { config } from './config'; + +@Module({ + imports: [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))], + providers: [Logger, TldrawWs, TldrawWsService, TldrawBoardRepo], +}) +export class TldrawWsModule {} diff --git a/apps/server/src/modules/tldraw/tldraw.module.ts b/apps/server/src/modules/tldraw/tldraw.module.ts new file mode 100644 index 00000000000..fa5ebf59d02 --- /dev/null +++ b/apps/server/src/modules/tldraw/tldraw.module.ts @@ -0,0 +1,43 @@ +import { Module, NotFoundException } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions, DB_PASSWORD, DB_USERNAME, TLDRAW_DB_URL } from '@src/config'; +import { CoreModule } from '@src/core'; +import { Logger } from '@src/core/logger'; +import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; +import { AuthenticationModule } from '@modules/authentication/authentication.module'; +import { RabbitMQWrapperTestModule } from '@infra/rabbitmq'; +import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; +import { AuthorizationModule } from '@modules/authorization'; +import { TldrawDrawing } from './entities'; +import { config } from './config'; +import { TldrawService } from './service/tldraw.service'; +import { TldrawBoardRepo } from './repo'; +import { TldrawController } from './controller/tldraw.controller'; +import { TldrawRepo } from './repo/tldraw.repo'; + +const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { + findOneOrFailHandler: (entityName: string, where: Dictionary | IPrimaryKey) => + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + new NotFoundException(`The requested ${entityName}: ${where} has not been found.`), +}; + +@Module({ + imports: [ + AuthorizationModule, + AuthenticationModule, + CoreModule, + RabbitMQWrapperTestModule, + MikroOrmModule.forRoot({ + ...defaultMikroOrmOptions, + type: 'mongo', + clientUrl: TLDRAW_DB_URL, + password: DB_PASSWORD, + user: DB_USERNAME, + entities: [TldrawDrawing], + }), + ConfigModule.forRoot(createConfigModuleOptions(config)), + ], + providers: [Logger, TldrawService, TldrawBoardRepo, TldrawRepo], + controllers: [TldrawController], +}) +export class TldrawModule {} diff --git a/apps/server/src/modules/tldraw/types/connection-enum.ts b/apps/server/src/modules/tldraw/types/connection-enum.ts new file mode 100644 index 00000000000..6a9a4692e03 --- /dev/null +++ b/apps/server/src/modules/tldraw/types/connection-enum.ts @@ -0,0 +1,9 @@ +export enum WSConnectionState { + CONNECTING = 0, + OPEN = 1, +} + +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 new file mode 100644 index 00000000000..0579e4b8c79 --- /dev/null +++ b/apps/server/src/modules/tldraw/types/index.ts @@ -0,0 +1,3 @@ +export * from './connection-enum'; +export * from './ws-close-code-enum'; +export * from './persistence-type'; diff --git a/apps/server/src/modules/tldraw/types/persistence-type.ts b/apps/server/src/modules/tldraw/types/persistence-type.ts new file mode 100644 index 00000000000..ee8d4510275 --- /dev/null +++ b/apps/server/src/modules/tldraw/types/persistence-type.ts @@ -0,0 +1,6 @@ +import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; + +export type Persitence = { + bindState: (docName: string, ydoc: WsSharedDocDo) => Promise; + writeState: (docName: string, ydoc: WsSharedDocDo) => Promise; +}; diff --git a/apps/server/src/modules/tldraw/types/ws-close-code-enum.ts b/apps/server/src/modules/tldraw/types/ws-close-code-enum.ts new file mode 100644 index 00000000000..274fa99a6ae --- /dev/null +++ b/apps/server/src/modules/tldraw/types/ws-close-code-enum.ts @@ -0,0 +1,3 @@ +export enum WsCloseCodeEnum { + WS_CLIENT_BAD_REQUEST_CODE = 4400, +} diff --git a/apps/server/src/modules/tldraw/utils/index.ts b/apps/server/src/modules/tldraw/utils/index.ts new file mode 100644 index 00000000000..a51b9059bc1 --- /dev/null +++ b/apps/server/src/modules/tldraw/utils/index.ts @@ -0,0 +1 @@ +export * from './ydoc-utils'; diff --git a/apps/server/src/modules/tldraw/utils/ydoc-utils.ts b/apps/server/src/modules/tldraw/utils/ydoc-utils.ts new file mode 100644 index 00000000000..6d0817ecc9d --- /dev/null +++ b/apps/server/src/modules/tldraw/utils/ydoc-utils.ts @@ -0,0 +1,2 @@ +export const calculateDiff = (diff: Uint8Array): number => + diff.reduce((previousValue, currentValue) => previousValue + currentValue, 0); diff --git a/apps/server/src/shared/domain/domainobject/board/card.do.ts b/apps/server/src/shared/domain/domainobject/board/card.do.ts index 62931e418dd..bba64a9dd9b 100644 --- a/apps/server/src/shared/domain/domainobject/board/card.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/card.do.ts @@ -1,3 +1,4 @@ +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; import { BoardComposite, BoardCompositeProps } from './board-composite.do'; import { ExternalToolElement } from './external-tool-element.do'; import { FileElement } from './file-element.do'; @@ -26,6 +27,7 @@ export class Card extends BoardComposite { isAllowedAsChild(domainObject: AnyBoardDo): boolean { const allowed = domainObject instanceof FileElement || + domainObject instanceof DrawingElement || domainObject instanceof LinkElement || domainObject instanceof RichTextElement || domainObject instanceof SubmissionContainerElement || diff --git a/apps/server/src/shared/domain/domainobject/board/content-element.factory.spec.ts b/apps/server/src/shared/domain/domainobject/board/content-element.factory.spec.ts index 89d1c297399..352bbaaa293 100644 --- a/apps/server/src/shared/domain/domainobject/board/content-element.factory.spec.ts +++ b/apps/server/src/shared/domain/domainobject/board/content-element.factory.spec.ts @@ -1,4 +1,5 @@ import { NotImplementedException } from '@nestjs/common'; +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; import { ContentElementFactory } from './content-element.factory'; import { ExternalToolElement } from './external-tool-element.do'; import { FileElement } from './file-element.do'; @@ -30,6 +31,14 @@ describe(ContentElementFactory.name, () => { expect(element).toBeInstanceOf(RichTextElement); }); + it('should return element of DRAWING', () => { + const { contentElementFactory } = setup(); + + const element = contentElementFactory.build(ContentElementType.DRAWING); + + expect(element).toBeInstanceOf(DrawingElement); + }); + it('should return element of SUBMISSION_CONTAINER', () => { const { contentElementFactory } = setup(); diff --git a/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts index 8c34ca54b56..4f71b96bf8f 100644 --- a/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts +++ b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts @@ -2,6 +2,7 @@ import { Injectable, NotImplementedException } from '@nestjs/common'; import { InputFormat } from '@shared/domain/types'; import { ObjectId } from 'bson'; import { ExternalToolElement } from './external-tool-element.do'; +import { DrawingElement } from './drawing-element.do'; import { FileElement } from './file-element.do'; import { LinkElement } from './link-element.do'; import { RichTextElement } from './rich-text-element.do'; @@ -23,6 +24,9 @@ export class ContentElementFactory { case ContentElementType.RICH_TEXT: element = this.buildRichText(); break; + case ContentElementType.DRAWING: + element = this.buildDrawing(); + break; case ContentElementType.SUBMISSION_CONTAINER: element = this.buildSubmissionContainer(); break; @@ -78,6 +82,18 @@ export class ContentElementFactory { return element; } + private buildDrawing() { + const element = new DrawingElement({ + id: new ObjectId().toHexString(), + description: '', + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + return element; + } + private buildSubmissionContainer() { const element = new SubmissionContainerElement({ id: new ObjectId().toHexString(), diff --git a/apps/server/src/shared/domain/domainobject/board/drawing-element.do.spec.ts b/apps/server/src/shared/domain/domainobject/board/drawing-element.do.spec.ts new file mode 100644 index 00000000000..b8876c7c0b1 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/board/drawing-element.do.spec.ts @@ -0,0 +1,37 @@ +import { createMock } from '@golevelup/ts-jest'; +import { drawingElementFactory } from '@shared/testing/factory/domainobject/board/drawing-element.do.factory'; +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; +import { BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; + +describe(DrawingElement.name, () => { + describe('when trying to add a child to a drawing element', () => { + it('should throw an error ', () => { + const drawingElement = drawingElementFactory.build(); + const drawingElementChild = drawingElementFactory.build(); + + expect(() => drawingElement.addChild(drawingElementChild)).toThrow(); + }); + }); + + describe('accept', () => { + it('should call the right visitor method', () => { + const visitor = createMock(); + const drawingElement = drawingElementFactory.build(); + + drawingElement.accept(visitor); + + expect(visitor.visitDrawingElement).toHaveBeenCalledWith(drawingElement); + }); + }); + + describe('acceptAsync', () => { + it('should call the right async visitor method', async () => { + const visitor = createMock(); + const drawingElement = drawingElementFactory.build(); + + await drawingElement.acceptAsync(visitor); + + expect(visitor.visitDrawingElementAsync).toHaveBeenCalledWith(drawingElement); + }); + }); +}); diff --git a/apps/server/src/shared/domain/domainobject/board/drawing-element.do.ts b/apps/server/src/shared/domain/domainobject/board/drawing-element.do.ts new file mode 100644 index 00000000000..e4bf11936e8 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/board/drawing-element.do.ts @@ -0,0 +1,32 @@ +import { BoardComposite, BoardCompositeProps } from './board-composite.do'; +import type { BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; + +export class DrawingElement extends BoardComposite { + get description(): string { + return this.props.description; + } + + set description(value: string) { + this.props.description = value; + } + + isAllowedAsChild(): boolean { + return false; + } + + accept(visitor: BoardCompositeVisitor): void { + visitor.visitDrawingElement(this); + } + + async acceptAsync(visitor: BoardCompositeVisitorAsync): Promise { + await visitor.visitDrawingElementAsync(this); + } +} + +export interface DrawingElementProps extends BoardCompositeProps { + description: string; +} + +export function isDrawingElement(reference: unknown): reference is DrawingElement { + return reference instanceof DrawingElement; +} diff --git a/apps/server/src/shared/domain/domainobject/board/index.ts b/apps/server/src/shared/domain/domainobject/board/index.ts index 9701ba40099..bb82ee91e7c 100644 --- a/apps/server/src/shared/domain/domainobject/board/index.ts +++ b/apps/server/src/shared/domain/domainobject/board/index.ts @@ -3,6 +3,7 @@ export * from './card.do'; export * from './column-board.do'; export * from './column.do'; export * from './content-element.factory'; +export * from './drawing-element.do'; export * from './external-tool-element.do'; export * from './file-element.do'; export * from './link-element.do'; diff --git a/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts b/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts index 614071e658c..14239363aaf 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts @@ -1,4 +1,5 @@ import { ExternalToolElement } from '../external-tool-element.do'; +import { DrawingElement } from '../drawing-element.do'; import { FileElement } from '../file-element.do'; import { LinkElement } from '../link-element.do'; import { RichTextElement } from '../rich-text-element.do'; @@ -6,6 +7,7 @@ import { SubmissionContainerElement } from '../submission-container-element.do'; import type { AnyBoardDo } from './any-board-do'; export type AnyContentElementDo = + | DrawingElement | ExternalToolElement | FileElement | LinkElement @@ -14,6 +16,7 @@ export type AnyContentElementDo = export const isAnyContentElement = (element: AnyBoardDo): element is AnyContentElementDo => { const result = + element instanceof DrawingElement || element instanceof ExternalToolElement || element instanceof FileElement || element instanceof LinkElement || diff --git a/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts b/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts index 3fbd4abdd96..5e2547bbf6b 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts @@ -1,3 +1,4 @@ +import { DrawingElement } from '../drawing-element.do'; import type { Card } from '../card.do'; import type { ColumnBoard } from '../column-board.do'; import type { Column } from '../column.do'; @@ -15,6 +16,7 @@ export interface BoardCompositeVisitor { visitFileElement(fileElement: FileElement): void; visitLinkElement(linkElement: LinkElement): void; visitRichTextElement(richTextElement: RichTextElement): void; + visitDrawingElement(drawingElement: DrawingElement): void; visitSubmissionContainerElement(submissionContainerElement: SubmissionContainerElement): void; visitSubmissionItem(submissionItem: SubmissionItem): void; visitExternalToolElement(externalToolElement: ExternalToolElement): void; @@ -27,6 +29,7 @@ export interface BoardCompositeVisitorAsync { visitFileElementAsync(fileElement: FileElement): Promise; visitLinkElementAsync(linkElement: LinkElement): Promise; visitRichTextElementAsync(richTextElement: RichTextElement): Promise; + visitDrawingElementAsync(drawingElement: DrawingElement): Promise; visitSubmissionContainerElementAsync(submissionContainerElement: SubmissionContainerElement): Promise; visitSubmissionItemAsync(submissionItem: SubmissionItem): Promise; visitExternalToolElementAsync(externalToolElement: ExternalToolElement): Promise; diff --git a/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts b/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts index b8d4e166e25..151e7666c7f 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts @@ -1,5 +1,6 @@ export enum ContentElementType { FILE = 'file', + DRAWING = 'drawing', LINK = 'link', RICH_TEXT = 'richText', SUBMISSION_CONTAINER = 'submissionContainer', diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index a7ed0587f54..eb4b2478379 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -12,6 +12,7 @@ import { CardNode, ColumnBoardNode, ColumnNode, + DrawingElementNode, ExternalToolElementNodeEntity, FileElementNode, LinkElementNode, @@ -62,6 +63,7 @@ export const ALL_ENTITIES = [ FileElementNode, LinkElementNode, RichTextElementNode, + DrawingElementNode, SubmissionContainerElementNode, SubmissionItemNode, ExternalToolElementNodeEntity, diff --git a/apps/server/src/shared/domain/entity/boardnode/drawing-element-node.entity.spec.ts b/apps/server/src/shared/domain/entity/boardnode/drawing-element-node.entity.spec.ts new file mode 100644 index 00000000000..ce868baff25 --- /dev/null +++ b/apps/server/src/shared/domain/entity/boardnode/drawing-element-node.entity.spec.ts @@ -0,0 +1,59 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { DrawingElementNode } from '@shared/domain/entity/boardnode/drawing-element-node.entity'; +import { drawingElementFactory } from '@shared/testing/factory/domainobject/board/drawing-element.do.factory'; +import { BoardDoBuilder, BoardNodeType } from './types'; + +describe(DrawingElementNode.name, () => { + describe('when trying to create a drawing element', () => { + const setup = () => { + const elementProps = { description: '' }; + const builder: DeepMocked = createMock(); + + return { elementProps, builder }; + }; + + it('should create a DrawingElementNode', () => { + const { elementProps } = setup(); + + const element = new DrawingElementNode(elementProps); + + expect(element.type).toEqual(BoardNodeType.DRAWING_ELEMENT); + }); + }); + + describe('useDoBuilder()', () => { + const setup = () => { + const element = new DrawingElementNode({ description: '' }); + const builder: DeepMocked = createMock(); + const elementDo = drawingElementFactory.build(); + + builder.buildDrawingElement.mockReturnValue(elementDo); + + return { element, builder, elementDo }; + }; + + it('should call the specific builder method', () => { + const { element, builder } = setup(); + + element.useDoBuilder(builder); + + expect(builder.buildDrawingElement).toHaveBeenCalledWith(element); + }); + + it('should call the specific builder method', () => { + const { element, builder } = setup(); + + element.useDoBuilder(builder); + + expect(builder.buildDrawingElement).toHaveBeenCalledWith(element); + }); + + it('should return DrawingElementDo', () => { + const { element, builder, elementDo } = setup(); + + const result = element.useDoBuilder(builder); + + expect(result).toEqual(elementDo); + }); + }); +}); diff --git a/apps/server/src/shared/domain/entity/boardnode/drawing-element-node.entity.ts b/apps/server/src/shared/domain/entity/boardnode/drawing-element-node.entity.ts new file mode 100644 index 00000000000..471e2290220 --- /dev/null +++ b/apps/server/src/shared/domain/entity/boardnode/drawing-element-node.entity.ts @@ -0,0 +1,25 @@ +import { Entity, Property } from '@mikro-orm/core'; +import { AnyBoardDo } from '@shared/domain/domainobject'; +import { BoardNode, BoardNodeProps } from './boardnode.entity'; +import { BoardDoBuilder, BoardNodeType } from './types'; + +@Entity({ discriminatorValue: BoardNodeType.DRAWING_ELEMENT }) +export class DrawingElementNode extends BoardNode { + @Property() + description: string; + + constructor(props: DrawingElementNodeProps) { + super(props); + this.type = BoardNodeType.DRAWING_ELEMENT; + this.description = props.description; + } + + useDoBuilder(builder: BoardDoBuilder): AnyBoardDo { + const domainObject = builder.buildDrawingElement(this); + return domainObject; + } +} + +export interface DrawingElementNodeProps extends BoardNodeProps { + description: string; +} diff --git a/apps/server/src/shared/domain/entity/boardnode/index.ts b/apps/server/src/shared/domain/entity/boardnode/index.ts index a3a56e6dfe0..85b74b0adb9 100644 --- a/apps/server/src/shared/domain/entity/boardnode/index.ts +++ b/apps/server/src/shared/domain/entity/boardnode/index.ts @@ -5,6 +5,7 @@ export * from './column-node.entity'; export * from './external-tool-element-node.entity'; export * from './file-element-node.entity'; export * from './link-element-node.entity'; +export * from './drawing-element-node.entity'; export * from './rich-text-element-node.entity'; export * from './submission-container-element-node.entity'; export * from './submission-item-node.entity'; diff --git a/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts b/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts index 1b759a41180..1b61566d442 100644 --- a/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts +++ b/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts @@ -2,6 +2,7 @@ import type { Card, Column, ColumnBoard, + DrawingElement, ExternalToolElement, FileElement, LinkElement, @@ -12,6 +13,7 @@ import type { import type { CardNode } from '../card-node.entity'; import type { ColumnBoardNode } from '../column-board-node.entity'; import type { ColumnNode } from '../column-node.entity'; +import type { DrawingElementNode } from '../drawing-element-node.entity'; import type { ExternalToolElementNodeEntity } from '../external-tool-element-node.entity'; import type { FileElementNode } from '../file-element-node.entity'; import type { LinkElementNode } from '../link-element-node.entity'; @@ -23,6 +25,7 @@ export interface BoardDoBuilder { buildColumnBoard(boardNode: ColumnBoardNode): ColumnBoard; buildColumn(boardNode: ColumnNode): Column; buildCard(boardNode: CardNode): Card; + buildDrawingElement(boardNode: DrawingElementNode): DrawingElement; buildFileElement(boardNode: FileElementNode): FileElement; buildLinkElement(boardNode: LinkElementNode): LinkElement; buildRichTextElement(boardNode: RichTextElementNode): RichTextElement; diff --git a/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts b/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts index 0b25a81b053..f76f5330d5e 100644 --- a/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts +++ b/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts @@ -5,6 +5,7 @@ export enum BoardNodeType { FILE_ELEMENT = 'file-element', LINK_ELEMENT = 'link-element', RICH_TEXT_ELEMENT = 'rich-text-element', + DRAWING_ELEMENT = 'drawing-element', SUBMISSION_CONTAINER_ELEMENT = 'submission-container-element', SUBMISSION_ITEM = 'submission-item', EXTERNAL_TOOL = 'external-tool', diff --git a/apps/server/src/shared/testing/factory/boardnode/drawing-element-node.factory.ts b/apps/server/src/shared/testing/factory/boardnode/drawing-element-node.factory.ts new file mode 100644 index 00000000000..0298bee52b9 --- /dev/null +++ b/apps/server/src/shared/testing/factory/boardnode/drawing-element-node.factory.ts @@ -0,0 +1,12 @@ +/* istanbul ignore file */ +import { DrawingElementNode, DrawingElementNodeProps } from '@shared/domain'; +import { BaseFactory } from '../base.factory'; + +export const drawingElementNodeFactory = BaseFactory.define( + DrawingElementNode, + ({ sequence }) => { + return { + description: `test-description-${sequence}`, + }; + } +); diff --git a/apps/server/src/shared/testing/factory/domainobject/board/drawing-element.do.factory.ts b/apps/server/src/shared/testing/factory/domainobject/board/drawing-element.do.factory.ts new file mode 100644 index 00000000000..526dbfe2869 --- /dev/null +++ b/apps/server/src/shared/testing/factory/domainobject/board/drawing-element.do.factory.ts @@ -0,0 +1,18 @@ +/* istanbul ignore file */ +import { ObjectId } from 'bson'; +import { DrawingElement, DrawingElementProps } from '@shared/domain/domainobject/board/drawing-element.do'; +import { BaseFactory } from '../../base.factory'; + +export const drawingElementFactory = BaseFactory.define( + DrawingElement, + ({ sequence }) => { + return { + id: new ObjectId().toHexString(), + title: `element #${sequence}`, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + description: '', + }; + } +); diff --git a/apps/server/src/shared/testing/factory/domainobject/board/index.ts b/apps/server/src/shared/testing/factory/domainobject/board/index.ts index 802dcf744f3..cff2ebf8833 100644 --- a/apps/server/src/shared/testing/factory/domainobject/board/index.ts +++ b/apps/server/src/shared/testing/factory/domainobject/board/index.ts @@ -1,6 +1,7 @@ export * from './card.do.factory'; export * from './column-board.do.factory'; export * from './column.do.factory'; +export * from './drawing-element.do.factory'; export * from './external-tool-element.do.factory'; export * from './file-element.do.factory'; export * from './link-element.do.factory'; diff --git a/apps/server/src/shared/testing/factory/tldraw.ws.factory.ts b/apps/server/src/shared/testing/factory/tldraw.ws.factory.ts new file mode 100644 index 00000000000..d5059777cef --- /dev/null +++ b/apps/server/src/shared/testing/factory/tldraw.ws.factory.ts @@ -0,0 +1,12 @@ +import { WsSharedDocDo } from '@modules/tldraw/domain/ws-shared-doc.do'; +import WebSocket from 'ws'; + +export class TldrawWsFactory { + public static createWsSharedDocDo(): WsSharedDocDo { + return { conns: new Map(), destroy: () => {} } as WsSharedDocDo; + } + + public static createWebsocket(readyState: number): WebSocket { + return { readyState, close: () => {} } as WebSocket; + } +} diff --git a/config/default.schema.json b/config/default.schema.json index d57355c8c0f..b27ea565dba 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1399,6 +1399,60 @@ "default": { "BASE_URL": "http://localhost:4030" } + }, + "TLDRAW": { + "type": "object", + "description": "Tldraw managing variables.", + "required": ["PING_TIMEOUT", "SOCKET_PORT","GC_ENABLED", "DB_COLLECTION_NAME", "DB_FLUSH_SIZE", "DB_MULTIPLE_COLLECTIONS"], + "properties": { + "SOCKET_PORT": { + "type": "number", + "description": "Web socket port for tldraw" + }, + "PING_TIMEOUT": { + "type": "number", + "description": "Max time for waiting between calls for tldraw" + }, + "GC_ENABLED": { + "type": "boolean", + "description": "If tldraw garbage collector should be enabled" + }, + "DB_COLLECTION_NAME": { + "type": "string", + "description": "Collection name in which tldraw drawing are stored" + }, + "DB_FLUSH_SIZE": { + "type": "integer", + "description": "DB collection flushing size" + }, + "DB_MULTIPLE_COLLECTIONS": { + "type": "boolean", + "description": "DB collection allowing multiple collections for drawing" + } + }, + "default": { + "SOCKET_PORT": 3345, + "PING_TIMEOUT": 10000, + "GC_ENABLED": true, + "DB_COLLECTION_NAME": "drawings", + "DB_FLUSH_SIZE": 400, + "DB_MULTIPLE_COLLECTIONS": false + } + }, + "TLDRAW_DB_URL": { + "type": "string", + "default": "mongodb://127.0.0.1:27017/tldraw", + "description": "DB connection url" + }, + "FEATURE_TLDRAW_ENABLED": { + "type": "boolean", + "default": true, + "description": "Tldraw feature enabled" + }, + "TLDRAW_URI": { + "type": "string", + "default": "http://localhost:3349", + "description": "Address for tldraw management app" } }, "required": [], diff --git a/config/globals.js b/config/globals.js index c9275419bcf..633878d1b3f 100644 --- a/config/globals.js +++ b/config/globals.js @@ -24,12 +24,14 @@ switch (NODE_ENV) { } let defaultDbUrl = null; +let defaultTldrawDbUrl = null; switch (NODE_ENV) { case ENVIRONMENTS.TEST: defaultDbUrl = 'mongodb://127.0.0.1:27017/schulcloud-test'; break; default: defaultDbUrl = 'mongodb://127.0.0.1:27017/schulcloud'; + defaultTldrawDbUrl = 'mongodb://127.0.0.1:27017/tldraw'; } const globals = { @@ -104,6 +106,9 @@ const globals = { // calendar CALENDAR_URI: process.env.CALENDAR_URI, + + // tldraw + TLDRAW_DB_URL: process.env.TLDRAW_DB_URL || defaultTldrawDbUrl, }; // validation ///////////////////////////////////////////////// diff --git a/config/test.json b/config/test.json index c8b82b383ba..cf7e080ce15 100644 --- a/config/test.json +++ b/config/test.json @@ -68,5 +68,13 @@ }, "FEATURE_VIDEOCONFERENCE_ENABLED": true, "VIDEOCONFERENCE_HOST": "https://bigbluebutton.schul-cloud.org/bigbluebutton", - "VIDEOCONFERENCE_SALT": "ThisIsNOTaRealSaltThisIsNOTaRealSaltThisIsNOTaRealSalt1234567890" + "VIDEOCONFERENCE_SALT": "ThisIsNOTaRealSaltThisIsNOTaRealSaltThisIsNOTaRealSalt1234567890", + "TLDRAW": { + "SOCKET_PORT": 3346, + "PING_TIMEOUT": 1, + "GC_ENABLED": true, + "DB_COLLECTION_NAME": "drawings", + "DB_FLUSH_SIZE": 400, + "DB_MULTIPLE_COLLECTIONS": false + } } diff --git a/nest-cli.json b/nest-cli.json index b3fd935b81f..a90498a7327 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -107,6 +107,15 @@ "compilerOptions": { "tsConfigPath": "apps/server/tsconfig.app.json" } + }, + "tldraw": { + "type": "application", + "root": "apps/server", + "entryFile": "apps/tldraw.app", + "sourceRoot": "apps/server/src", + "compilerOptions": { + "tsConfigPath": "apps/server/tsconfig.app.json" + } } } } diff --git a/package-lock.json b/package-lock.json index bf63523c91a..7ef70fd9a77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,9 @@ "@nestjs/microservices": "^10.2.4", "@nestjs/passport": "^10.0.1", "@nestjs/platform-express": "^10.2.4", + "@nestjs/platform-ws": "^10.2.4", "@nestjs/swagger": "^7.1.10", + "@nestjs/websockets": "^10.2.4", "@types/cache-manager-redis-store": "^2.0.1", "@types/connect-redis": "^0.0.19", "@types/gm": "^1.25.1", @@ -132,7 +134,10 @@ "universal-analytics": "^0.5.1", "urlsafe-base64": "^1.0.0", "uuid": "^8.3.0", - "winston": "^3.8.2" + "winston": "^3.8.2", + "y-mongodb-provider": "^0.1.7", + "y-protocols": "^1.0.5", + "yjs": "^13.6.7" }, "devDependencies": { "@aws-sdk/client-s3": "^3.352.0", @@ -581,7 +586,6 @@ "version": "3.352.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.352.0.tgz", "integrity": "sha512-qXqg7V/DpHu8oyEq22LMskCoHYZU6+ds9gaArwc3SjPwQN/UM6CpIUHtTtxevLEYr7nI5iMIPBBrEcoKOJefxg==", - "dev": true, "optional": true, "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", @@ -798,7 +802,6 @@ "version": "3.352.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.352.0.tgz", "integrity": "sha512-395bdedGD0pangBT6dyyrTvtDRxr3lqbi8lfuJR/+7bpMIEJKVhF5D6IAgdjRDpASDRHUPhHuWzR3Qa9RHAcNA==", - "dev": true, "optional": true, "dependencies": { "@aws-sdk/client-cognito-identity": "3.352.0", @@ -982,7 +985,6 @@ "version": "3.352.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.352.0.tgz", "integrity": "sha512-hV6NO7+xzf3CPEsKZRsYflR05eNMvgVvOXFgQnOucUc85Kxt2XTSoH/HFtkolXDbxjA2Hku1pdaRG7qBzbiJHg==", - "dev": true, "optional": true, "dependencies": { "@aws-sdk/client-cognito-identity": "3.352.0", @@ -4462,6 +4464,14 @@ "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0" } }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.0.tgz", + "integrity": "sha512-Xfijy7HvfzzqiOAhAepF4SGN5e9leLkMvg/OPOF97XemjfVCYN/oWa75wnkc6mltMSTwY+XlbhWgUOJmkFspSw==", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@nestjs/axios": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.0.0.tgz", @@ -4941,6 +4951,44 @@ "@nestjs/core": "^10.0.0" } }, + "node_modules/@nestjs/platform-ws": { + "version": "10.2.7", + "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.2.7.tgz", + "integrity": "sha512-4H4AeCQgM29Dju+zQb70Jt0JgWhQssOB8mh9n9icsSJ4B/joa+X7OiBBSjn72HZelj0tvX1gal6PaAhEaOdmGQ==", + "dependencies": { + "tslib": "2.6.2", + "ws": "8.14.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/websockets": "^10.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/platform-ws/node_modules/ws": { + "version": "8.14.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", + "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@nestjs/schematics": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.0.2.tgz", @@ -5101,6 +5149,28 @@ } } }, + "node_modules/@nestjs/websockets": { + "version": "10.2.7", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.2.7.tgz", + "integrity": "sha512-NKJMubkwpUBsudbiyjuLZDT/W68K+fS/pe3vG5Ur8QoPn+fkI9SFCiQw27Cv4K0qVX2eGJ41yNmVfu61zGa4CQ==", + "dependencies": { + "iterare": "1.2.1", + "object-hash": "3.0.0", + "tslib": "2.6.2" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/platform-socket.io": "^10.0.0", + "reflect-metadata": "^0.1.12", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/platform-socket.io": { + "optional": true + } + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -13634,6 +13704,15 @@ "ws": "*" } }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -16159,6 +16238,25 @@ "node": ">= 0.8.0" } }, + "node_modules/lib0": { + "version": "0.2.87", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.87.tgz", + "integrity": "sha512-TbB63XJixvNToW2IHWAFsCJj9tVnajmwjE14p69i51Rx8byOQd2IP4ourE8v4d7vhyO++nVm1sQk3ePslfbucg==", + "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", "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.24.tgz", @@ -16704,8 +16802,7 @@ "node_modules/memory-pager": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "optional": true + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" }, "node_modules/memory-stream": { "version": "0.0.3", @@ -18789,6 +18886,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", @@ -22338,7 +22443,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", "integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=", - "optional": true, "dependencies": { "memory-pager": "^1.0.2" } @@ -25008,6 +25112,90 @@ "node": ">=0.4" } }, + "node_modules/y-mongodb-provider": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/y-mongodb-provider/-/y-mongodb-provider-0.1.8.tgz", + "integrity": "sha512-yV+rtS9nBEMqb6fG6sqyWNpMGzmTYe7hPiwWwWyrzK4frjMnkrQvJvyUiWjZI7eFFSKYzxYHucGEFA0j3QJEgA==", + "dependencies": { + "lib0": "^0.2.85", + "mongodb": "^6.1.0" + }, + "peerDependencies": { + "yjs": "^13.6.8" + } + }, + "node_modules/y-mongodb-provider/node_modules/bson": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.1.0.tgz", + "integrity": "sha512-yiQ3KxvpVoRpx1oD1uPz4Jit9tAVTJgjdmjDKtUErkOoL9VNoF8Dd58qtAOL5E40exx2jvAT9sqdRSK/r+SHlA==", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/y-mongodb-provider/node_modules/mongodb": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.1.0.tgz", + "integrity": "sha512-AvzNY0zMkpothZ5mJAaIo2bGDjlJQqqAbn9fvtVgwIIUPEfdrqGxqNjjbuKyrgQxg2EvCmfWdjq+4uj96c0YPw==", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.0", + "bson": "^6.1.0", + "mongodb-connection-string-url": "^2.6.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/y-protocols": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", + "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", + "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": "3.2.2", "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.2.tgz", @@ -25196,6 +25384,22 @@ "buffer-crc32": "~0.2.3" } }, + "node_modules/yjs": { + "version": "13.6.8", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.8.tgz", + "integrity": "sha512-ZPq0hpJQb6f59B++Ngg4cKexDJTvfOgeiv0sBc4sUm8CaBWH7OQC4kcCgrqbjJ/B2+6vO49exvTmYfdlPtcjbg==", + "dependencies": { + "lib0": "^0.2.74" + }, + "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", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", @@ -25543,7 +25747,6 @@ "version": "3.352.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.352.0.tgz", "integrity": "sha512-qXqg7V/DpHu8oyEq22LMskCoHYZU6+ds9gaArwc3SjPwQN/UM6CpIUHtTtxevLEYr7nI5iMIPBBrEcoKOJefxg==", - "dev": true, "optional": true, "requires": { "@aws-crypto/sha256-browser": "3.0.0", @@ -25745,7 +25948,6 @@ "version": "3.352.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.352.0.tgz", "integrity": "sha512-395bdedGD0pangBT6dyyrTvtDRxr3lqbi8lfuJR/+7bpMIEJKVhF5D6IAgdjRDpASDRHUPhHuWzR3Qa9RHAcNA==", - "dev": true, "optional": true, "requires": { "@aws-sdk/client-cognito-identity": "3.352.0", @@ -25901,7 +26103,6 @@ "version": "3.352.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.352.0.tgz", "integrity": "sha512-hV6NO7+xzf3CPEsKZRsYflR05eNMvgVvOXFgQnOucUc85Kxt2XTSoH/HFtkolXDbxjA2Hku1pdaRG7qBzbiJHg==", - "dev": true, "optional": true, "requires": { "@aws-sdk/client-cognito-identity": "3.352.0", @@ -28429,6 +28630,14 @@ "integrity": "sha512-TrCdPsM7DApxrK3avBbijT6/6Er4TZhtiQ+qlMqtqva13vMCG4HiF2vIWGrKJbFukkLRuhOfZlES+KZ9Y1Lx2A==", "requires": {} }, + "@mongodb-js/saslprep": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.0.tgz", + "integrity": "sha512-Xfijy7HvfzzqiOAhAepF4SGN5e9leLkMvg/OPOF97XemjfVCYN/oWa75wnkc6mltMSTwY+XlbhWgUOJmkFspSw==", + "requires": { + "sparse-bitfield": "^3.0.3" + } + }, "@nestjs/axios": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.0.0.tgz", @@ -28695,6 +28904,23 @@ "tslib": "2.6.2" } }, + "@nestjs/platform-ws": { + "version": "10.2.7", + "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.2.7.tgz", + "integrity": "sha512-4H4AeCQgM29Dju+zQb70Jt0JgWhQssOB8mh9n9icsSJ4B/joa+X7OiBBSjn72HZelj0tvX1gal6PaAhEaOdmGQ==", + "requires": { + "tslib": "2.6.2", + "ws": "8.14.2" + }, + "dependencies": { + "ws": { + "version": "8.14.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", + "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", + "requires": {} + } + } + }, "@nestjs/schematics": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.0.2.tgz", @@ -28792,6 +29018,16 @@ "tslib": "2.6.2" } }, + "@nestjs/websockets": { + "version": "10.2.7", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.2.7.tgz", + "integrity": "sha512-NKJMubkwpUBsudbiyjuLZDT/W68K+fS/pe3vG5Ur8QoPn+fkI9SFCiQw27Cv4K0qVX2eGJ41yNmVfu61zGa4CQ==", + "requires": { + "iterare": "1.2.1", + "object-hash": "3.0.0", + "tslib": "2.6.2" + } + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -35293,6 +35529,11 @@ "peer": true, "requires": {} }, + "isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==" + }, "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -37247,6 +37488,14 @@ "type-check": "~0.4.0" } }, + "lib0": { + "version": "0.2.87", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.87.tgz", + "integrity": "sha512-TbB63XJixvNToW2IHWAFsCJj9tVnajmwjE14p69i51Rx8byOQd2IP4ourE8v4d7vhyO++nVm1sQk3ePslfbucg==", + "requires": { + "isomorphic.js": "^0.2.4" + } + }, "libphonenumber-js": { "version": "1.10.24", "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.24.tgz", @@ -37698,8 +37947,7 @@ "memory-pager": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "optional": true + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" }, "memory-stream": { "version": "0.0.3", @@ -39345,6 +39593,11 @@ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, + "object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==" + }, "object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", @@ -42007,7 +42260,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", "integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=", - "optional": true, "requires": { "memory-pager": "^1.0.2" } @@ -44048,6 +44300,40 @@ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" }, + "y-mongodb-provider": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/y-mongodb-provider/-/y-mongodb-provider-0.1.8.tgz", + "integrity": "sha512-yV+rtS9nBEMqb6fG6sqyWNpMGzmTYe7hPiwWwWyrzK4frjMnkrQvJvyUiWjZI7eFFSKYzxYHucGEFA0j3QJEgA==", + "requires": { + "lib0": "^0.2.85", + "mongodb": "^6.1.0" + }, + "dependencies": { + "bson": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.1.0.tgz", + "integrity": "sha512-yiQ3KxvpVoRpx1oD1uPz4Jit9tAVTJgjdmjDKtUErkOoL9VNoF8Dd58qtAOL5E40exx2jvAT9sqdRSK/r+SHlA==" + }, + "mongodb": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.1.0.tgz", + "integrity": "sha512-AvzNY0zMkpothZ5mJAaIo2bGDjlJQqqAbn9fvtVgwIIUPEfdrqGxqNjjbuKyrgQxg2EvCmfWdjq+4uj96c0YPw==", + "requires": { + "@mongodb-js/saslprep": "^1.1.0", + "bson": "^6.1.0", + "mongodb-connection-string-url": "^2.6.0" + } + } + } + }, + "y-protocols": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", + "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", + "requires": { + "lib0": "^0.2.85" + } + }, "y18n": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.2.tgz", @@ -44198,6 +44484,14 @@ "buffer-crc32": "~0.2.3" } }, + "yjs": { + "version": "13.6.8", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.8.tgz", + "integrity": "sha512-ZPq0hpJQb6f59B++Ngg4cKexDJTvfOgeiv0sBc4sUm8CaBWH7OQC4kcCgrqbjJ/B2+6vO49exvTmYfdlPtcjbg==", + "requires": { + "lib0": "^0.2.74" + } + }, "yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index 6e179f67264..854cb849b0e 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,9 @@ "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 --", @@ -121,7 +124,9 @@ "@nestjs/microservices": "^10.2.4", "@nestjs/passport": "^10.0.1", "@nestjs/platform-express": "^10.2.4", + "@nestjs/platform-ws": "^10.2.4", "@nestjs/swagger": "^7.1.10", + "@nestjs/websockets": "^10.2.4", "@types/cache-manager-redis-store": "^2.0.1", "@types/connect-redis": "^0.0.19", "@types/gm": "^1.25.1", @@ -221,7 +226,10 @@ "universal-analytics": "^0.5.1", "urlsafe-base64": "^1.0.0", "uuid": "^8.3.0", - "winston": "^3.8.2" + "winston": "^3.8.2", + "y-mongodb-provider": "^0.1.7", + "y-protocols": "^1.0.5", + "yjs": "^13.6.7" }, "devDependencies": { "@aws-sdk/client-s3": "^3.352.0", diff --git a/src/services/config/publicAppConfigService.js b/src/services/config/publicAppConfigService.js index 62615f0efb1..018722039b5 100644 --- a/src/services/config/publicAppConfigService.js +++ b/src/services/config/publicAppConfigService.js @@ -63,6 +63,7 @@ const exposedVars = [ 'FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION', 'FEATURE_CTL_CONTEXT_CONFIGURATION_ENABLED', 'FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED', + 'FEATURE_TLDRAW_ENABLED', ]; /**