diff --git a/apps/server/src/modules/board/board.module.ts b/apps/server/src/modules/board/board.module.ts index a002766b56d..fb04364b6c3 100644 --- a/apps/server/src/modules/board/board.module.ts +++ b/apps/server/src/modules/board/board.module.ts @@ -14,6 +14,7 @@ import { ColumnBoardService, ColumnService, ContentElementService, + OpenGraphProxyService, SubmissionItemService, } from './service'; import { BoardDoCopyService, SchoolSpecificFileCopyServiceFactory } from './service/board-do-copy-service'; @@ -37,6 +38,7 @@ import { ColumnBoardCopyService } from './service/column-board-copy.service'; BoardDoCopyService, ColumnBoardCopyService, SchoolSpecificFileCopyServiceFactory, + OpenGraphProxyService, ], exports: [ BoardDoAuthorizableService, diff --git a/apps/server/src/modules/board/controller/api-test/card-create.api.spec.ts b/apps/server/src/modules/board/controller/api-test/card-create.api.spec.ts index beb31d4dd5c..a108d352759 100644 --- a/apps/server/src/modules/board/controller/api-test/card-create.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/card-create.api.spec.ts @@ -87,7 +87,7 @@ describe(`card create (api)`, () => { em.clear(); const createCardBodyParams = { - requiredEmptyElements: [ContentElementType.RICH_TEXT, ContentElementType.FILE], + requiredEmptyElements: [ContentElementType.RICH_TEXT, ContentElementType.FILE, ContentElementType.LINK], }; return { user, columnBoardNode, columnNode, createCardBodyParams }; @@ -111,7 +111,7 @@ describe(`card create (api)`, () => { expect(result.id).toBeDefined(); }); - it('created card should contain empty text and file elements', async () => { + it('created card should contain empty text, file and link elements', async () => { const { user, columnNode, createCardBodyParams } = await setup(); currentUser = mapUserToCurrentUser(user); @@ -129,6 +129,12 @@ describe(`card create (api)`, () => { alternativeText: '', }, }, + { + type: 'link', + content: { + url: '', + }, + }, ]; const { result } = await api.post(columnNode.id, createCardBodyParams); @@ -136,6 +142,7 @@ describe(`card create (api)`, () => { expect(elements[0]).toMatchObject(expectedEmptyElements[0]); expect(elements[1]).toMatchObject(expectedEmptyElements[1]); + expect(elements[2]).toMatchObject(expectedEmptyElements[2]); }); it('should return status 400 as the content element is unknown', async () => { const { user, columnNode } = await setup(); diff --git a/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts b/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts index 16ae21dee78..6a292ffa93d 100644 --- a/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts @@ -98,7 +98,7 @@ describe(`content element update content (api)`, () => { }; }; - it('should return status 204', async () => { + it('should return status 201', async () => { const { loggedInClient, richTextElement } = await setup(); const response = await loggedInClient.patch(`${richTextElement.id}/content`, { @@ -108,7 +108,7 @@ describe(`content element update content (api)`, () => { }, }); - expect(response.statusCode).toEqual(204); + expect(response.statusCode).toEqual(201); }); it('should actually change content of the element', async () => { @@ -167,7 +167,7 @@ describe(`content element update content (api)`, () => { expect(result.alternativeText).toEqual('rich text 1 some more text'); }); - it('should return status 204 (nothing changed) without dueDate parameter for submission container element', async () => { + it('should return status 201', async () => { const { loggedInClient, submissionContainerElement } = await setup(); const response = await loggedInClient.patch(`${submissionContainerElement.id}/content`, { data: { @@ -176,7 +176,7 @@ describe(`content element update content (api)`, () => { }, }); - expect(response.statusCode).toEqual(204); + expect(response.statusCode).toEqual(201); }); it('should not change dueDate when not proviced in submission container element without dueDate', async () => { diff --git a/apps/server/src/modules/board/controller/card.controller.ts b/apps/server/src/modules/board/controller/card.controller.ts index e76bdbe088c..38a979dbf1e 100644 --- a/apps/server/src/modules/board/controller/card.controller.ts +++ b/apps/server/src/modules/board/controller/card.controller.ts @@ -25,6 +25,7 @@ import { CreateContentElementBodyParams, ExternalToolElementResponse, FileElementResponse, + LinkElementResponse, MoveCardBodyParams, RenameBodyParams, RichTextElementResponse, @@ -116,19 +117,21 @@ export class CardController { @ApiOperation({ summary: 'Create a new element on a card.' }) @ApiExtraModels( - RichTextElementResponse, + ExternalToolElementResponse, FileElementResponse, - SubmissionContainerElementResponse, - ExternalToolElementResponse + LinkElementResponse, + RichTextElementResponse, + SubmissionContainerElementResponse ) @ApiResponse({ status: 201, schema: { oneOf: [ - { $ref: getSchemaPath(RichTextElementResponse) }, + { $ref: getSchemaPath(ExternalToolElementResponse) }, { $ref: getSchemaPath(FileElementResponse) }, + { $ref: getSchemaPath(LinkElementResponse) }, + { $ref: getSchemaPath(RichTextElementResponse) }, { $ref: getSchemaPath(SubmissionContainerElementResponse) }, - { $ref: getSchemaPath(ExternalToolElementResponse) }, ], }, }) @@ -137,7 +140,7 @@ export class CardController { @ApiResponse({ status: 404, type: NotFoundException }) @Post(':cardId/elements') async createElement( - @Param() urlParams: CardUrlParams, // TODO add type-property ? + @Param() urlParams: CardUrlParams, @Body() bodyParams: CreateContentElementBodyParams, @CurrentUser() currentUser: ICurrentUser ): Promise { diff --git a/apps/server/src/modules/board/controller/dto/card/card.response.ts b/apps/server/src/modules/board/controller/dto/card/card.response.ts index 44ee426fb6b..3577fcbc2a1 100644 --- a/apps/server/src/modules/board/controller/dto/card/card.response.ts +++ b/apps/server/src/modules/board/controller/dto/card/card.response.ts @@ -1,11 +1,23 @@ import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; import { DecodeHtmlEntities } from '@shared/controller'; -import { AnyContentElementResponse, FileElementResponse, SubmissionContainerElementResponse } from '../element'; -import { RichTextElementResponse } from '../element/rich-text-element.response'; +import { + AnyContentElementResponse, + ExternalToolElementResponse, + FileElementResponse, + LinkElementResponse, + RichTextElementResponse, + SubmissionContainerElementResponse, +} from '../element'; import { TimestampsResponse } from '../timestamps.response'; import { VisibilitySettingsResponse } from './visibility-settings.response'; -@ApiExtraModels(RichTextElementResponse) +@ApiExtraModels( + ExternalToolElementResponse, + FileElementResponse, + LinkElementResponse, + RichTextElementResponse, + SubmissionContainerElementResponse +) export class CardResponse { constructor({ id, title, height, elements, visibilitySettings, timestamps }: CardResponse) { this.id = id; @@ -32,8 +44,10 @@ export class CardResponse { type: 'array', items: { oneOf: [ - { $ref: getSchemaPath(RichTextElementResponse) }, + { $ref: getSchemaPath(ExternalToolElementResponse) }, { $ref: getSchemaPath(FileElementResponse) }, + { $ref: getSchemaPath(LinkElementResponse) }, + { $ref: getSchemaPath(RichTextElementResponse) }, { $ref: getSchemaPath(SubmissionContainerElementResponse) }, ], }, 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 1382b75b3f5..18415d172fa 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,10 +1,12 @@ import { ExternalToolElementResponse } from './external-tool-element.response'; import { FileElementResponse } from './file-element.response'; +import { LinkElementResponse } from './link-element.response'; import { RichTextElementResponse } from './rich-text-element.response'; import { SubmissionContainerElementResponse } from './submission-container-element.response'; export type AnyContentElementResponse = | FileElementResponse + | LinkElementResponse | RichTextElementResponse | SubmissionContainerElementResponse | ExternalToolElementResponse; 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 b1ef77f8ec0..6787c007c1b 100644 --- a/apps/server/src/modules/board/controller/dto/element/index.ts +++ b/apps/server/src/modules/board/controller/dto/element/index.ts @@ -1,7 +1,8 @@ export * from './any-content-element.response'; export * from './create-content-element.body.params'; -export * from './update-element-content.body.params'; +export * from './external-tool-element.response'; export * from './file-element.response'; +export * from './link-element.response'; export * from './rich-text-element.response'; export * from './submission-container-element.response'; -export * from './external-tool-element.response'; +export * from './update-element-content.body.params'; diff --git a/apps/server/src/modules/board/controller/dto/element/link-element.response.ts b/apps/server/src/modules/board/controller/dto/element/link-element.response.ts new file mode 100644 index 00000000000..d6c4a7e7080 --- /dev/null +++ b/apps/server/src/modules/board/controller/dto/element/link-element.response.ts @@ -0,0 +1,45 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ContentElementType } from '@shared/domain'; +import { TimestampsResponse } from '../timestamps.response'; + +export class LinkElementContent { + constructor({ url, title, description, imageUrl }: LinkElementContent) { + this.url = url; + this.title = title; + this.description = description; + this.imageUrl = imageUrl; + } + + @ApiProperty() + url: string; + + @ApiProperty() + title: string; + + @ApiPropertyOptional() + description?: string; + + @ApiPropertyOptional() + imageUrl?: string; +} + +export class LinkElementResponse { + constructor({ id, content, timestamps, type }: LinkElementResponse) { + this.id = id; + this.content = content; + this.timestamps = timestamps; + this.type = type; + } + + @ApiProperty({ pattern: '[a-f0-9]{24}' }) + id: string; + + @ApiProperty({ enum: ContentElementType, enumName: 'ContentElementType' }) + type: ContentElementType.LINK; + + @ApiProperty() + content: LinkElementContent; + + @ApiProperty() + timestamps: TimestampsResponse; +} 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 208ec7d1d2d..5eb0f239c1f 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 @@ -31,6 +31,20 @@ export class FileElementContentBody extends ElementContentBody { @ApiProperty() content!: FileContentBody; } +export class LinkContentBody { + @IsString() + @ApiProperty({}) + url!: string; +} + +export class LinkElementContentBody extends ElementContentBody { + @ApiProperty({ type: ContentElementType.LINK }) + type!: ContentElementType.LINK; + + @ValidateNested() + @ApiProperty({}) + content!: LinkContentBody; +} export class RichTextContentBody { @IsString() @@ -89,6 +103,7 @@ export class ExternalToolElementContentBody extends ElementContentBody { export type AnyElementContentBody = | FileContentBody + | LinkContentBody | RichTextContentBody | SubmissionContainerContentBody | ExternalToolContentBody; @@ -100,6 +115,7 @@ export class UpdateElementContentBodyParams { property: 'type', subTypes: [ { value: FileElementContentBody, name: ContentElementType.FILE }, + { value: LinkElementContentBody, name: ContentElementType.LINK }, { value: RichTextElementContentBody, name: ContentElementType.RICH_TEXT }, { value: SubmissionContainerElementContentBody, name: ContentElementType.SUBMISSION_CONTAINER }, { value: ExternalToolElementContentBody, name: ContentElementType.EXTERNAL_TOOL }, @@ -110,6 +126,7 @@ export class UpdateElementContentBodyParams { @ApiProperty({ oneOf: [ { $ref: getSchemaPath(FileElementContentBody) }, + { $ref: getSchemaPath(LinkElementContentBody) }, { $ref: getSchemaPath(RichTextElementContentBody) }, { $ref: getSchemaPath(SubmissionContainerElementContentBody) }, { $ref: getSchemaPath(ExternalToolElementContentBody) }, @@ -117,6 +134,7 @@ export class UpdateElementContentBodyParams { }) data!: | FileElementContentBody + | LinkElementContentBody | RichTextElementContentBody | SubmissionContainerElementContentBody | ExternalToolElementContentBody; diff --git a/apps/server/src/modules/board/controller/element.controller.ts b/apps/server/src/modules/board/controller/element.controller.ts index 361e59bf6b6..2dacd2cf539 100644 --- a/apps/server/src/modules/board/controller/element.controller.ts +++ b/apps/server/src/modules/board/controller/element.controller.ts @@ -10,24 +10,31 @@ import { Post, Put, } from '@nestjs/common'; -import { ApiBody, ApiExtraModels, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiBody, ApiExtraModels, ApiOperation, ApiResponse, ApiTags, getSchemaPath } from '@nestjs/swagger'; import { ApiValidationError } from '@shared/common'; import { ICurrentUser } from '@src/modules/authentication'; import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator'; import { CardUc } from '../uc'; import { ElementUc } from '../uc/element.uc'; import { + AnyContentElementResponse, ContentElementUrlParams, CreateSubmissionItemBodyParams, ExternalToolElementContentBody, + ExternalToolElementResponse, FileElementContentBody, + FileElementResponse, + LinkElementContentBody, + LinkElementResponse, MoveContentElementBody, RichTextElementContentBody, + RichTextElementResponse, SubmissionContainerElementContentBody, + SubmissionContainerElementResponse, SubmissionItemResponse, UpdateElementContentBodyParams, } from './dto'; -import { SubmissionItemResponseMapper } from './mapper'; +import { ContentElementResponseFactory, SubmissionItemResponseMapper } from './mapper'; @ApiTags('Board Element') @Authenticate('jwt') @@ -60,20 +67,38 @@ export class ElementController { FileElementContentBody, RichTextElementContentBody, SubmissionContainerElementContentBody, - ExternalToolElementContentBody + ExternalToolElementContentBody, + LinkElementContentBody ) - @ApiResponse({ status: 204 }) + @ApiResponse({ + status: 201, + schema: { + oneOf: [ + { $ref: getSchemaPath(ExternalToolElementResponse) }, + { $ref: getSchemaPath(FileElementResponse) }, + { $ref: getSchemaPath(LinkElementResponse) }, + { $ref: getSchemaPath(RichTextElementResponse) }, + { $ref: getSchemaPath(SubmissionContainerElementResponse) }, + ], + }, + }) @ApiResponse({ status: 400, type: ApiValidationError }) @ApiResponse({ status: 403, type: ForbiddenException }) @ApiResponse({ status: 404, type: NotFoundException }) - @HttpCode(204) + @HttpCode(201) @Patch(':contentElementId/content') async updateElement( @Param() urlParams: ContentElementUrlParams, @Body() bodyParams: UpdateElementContentBodyParams, @CurrentUser() currentUser: ICurrentUser - ): Promise { - await this.elementUc.updateElementContent(currentUser.userId, urlParams.contentElementId, bodyParams.data.content); + ): Promise { + const element = await this.elementUc.updateElementContent( + currentUser.userId, + urlParams.contentElementId, + bodyParams.data.content + ); + const response = ContentElementResponseFactory.mapToResponse(element); + return response; } @ApiOperation({ summary: 'Delete a single content element.' }) 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 f813e44ae7a..2b61e273185 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,27 +1,36 @@ import { NotImplementedException } from '@nestjs/common'; -import { fileElementFactory, richTextElementFactory, submissionContainerElementFactory } from '@shared/testing'; -import { FileElementResponse, RichTextElementResponse, SubmissionContainerElementResponse } from '../dto'; +import { + fileElementFactory, + linkElementFactory, + richTextElementFactory, + submissionContainerElementFactory, +} from '@shared/testing'; +import { + FileElementResponse, + LinkElementResponse, + RichTextElementResponse, + SubmissionContainerElementResponse, +} from '../dto'; import { ContentElementResponseFactory } from './content-element-response.factory'; describe(ContentElementResponseFactory.name, () => { - const setup = () => { - const fileElement = fileElementFactory.build(); - const richTextElement = richTextElementFactory.build(); - const submissionContainerElement = submissionContainerElementFactory.build(); - - return { fileElement, richTextElement, submissionContainerElement }; - }; - it('should return instance of FileElementResponse', () => { - const { fileElement } = setup(); + const fileElement = fileElementFactory.build(); const result = ContentElementResponseFactory.mapToResponse(fileElement); expect(result).toBeInstanceOf(FileElementResponse); }); + it('should return instance of LinkElementResponse', () => { + const linkElement = linkElementFactory.build(); + const result = ContentElementResponseFactory.mapToResponse(linkElement); + + expect(result).toBeInstanceOf(LinkElementResponse); + }); + it('should return instance of RichTextElementResponse', () => { - const { richTextElement } = setup(); + const richTextElement = richTextElementFactory.build(); const result = ContentElementResponseFactory.mapToResponse(richTextElement); @@ -29,7 +38,7 @@ describe(ContentElementResponseFactory.name, () => { }); it('should return instance of SubmissionContainerElementResponse', () => { - const { submissionContainerElement } = setup(); + const submissionContainerElement = submissionContainerElementFactory.build(); const result = ContentElementResponseFactory.mapToResponse(submissionContainerElement); 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 3ccf11b1bf2..bda46e4b73f 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 @@ -4,12 +4,14 @@ import { AnyContentElementResponse } from '../dto'; import { BaseResponseMapper } from './base-mapper.interface'; import { ExternalToolElementResponseMapper } from './external-tool-element-response.mapper'; import { FileElementResponseMapper } from './file-element-response.mapper'; +import { LinkElementResponseMapper } from './link-element-response.mapper'; import { RichTextElementResponseMapper } from './rich-text-element-response.mapper'; import { SubmissionContainerElementResponseMapper } from './submission-container-element-response.mapper'; export class ContentElementResponseFactory { private static mappers: BaseResponseMapper[] = [ FileElementResponseMapper.getInstance(), + LinkElementResponseMapper.getInstance(), RichTextElementResponseMapper.getInstance(), SubmissionContainerElementResponseMapper.getInstance(), ExternalToolElementResponseMapper.getInstance(), diff --git a/apps/server/src/modules/board/controller/mapper/index.ts b/apps/server/src/modules/board/controller/mapper/index.ts index 116692df5a4..a24a905ae3f 100644 --- a/apps/server/src/modules/board/controller/mapper/index.ts +++ b/apps/server/src/modules/board/controller/mapper/index.ts @@ -2,7 +2,9 @@ export * from './board-response.mapper'; export * from './card-response.mapper'; export * from './column-response.mapper'; export * from './content-element-response.factory'; +export * from './external-tool-element-response.mapper'; +export * from './file-element-response.mapper'; +export * from './link-element-response.mapper'; export * from './rich-text-element-response.mapper'; export * from './submission-container-element-response.mapper'; export * from './submission-item-response.mapper'; -export * from './external-tool-element-response.mapper'; diff --git a/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts new file mode 100644 index 00000000000..e6a31b2c07a --- /dev/null +++ b/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts @@ -0,0 +1,35 @@ +import { ContentElementType, LinkElement } from '@shared/domain'; +import { LinkElementContent, LinkElementResponse, TimestampsResponse } from '../dto'; +import { BaseResponseMapper } from './base-mapper.interface'; + +export class LinkElementResponseMapper implements BaseResponseMapper { + private static instance: LinkElementResponseMapper; + + public static getInstance(): LinkElementResponseMapper { + if (!LinkElementResponseMapper.instance) { + LinkElementResponseMapper.instance = new LinkElementResponseMapper(); + } + + return LinkElementResponseMapper.instance; + } + + mapToResponse(element: LinkElement): LinkElementResponse { + const result = new LinkElementResponse({ + id: element.id, + timestamps: new TimestampsResponse({ lastUpdatedAt: element.updatedAt, createdAt: element.createdAt }), + type: ContentElementType.LINK, + content: new LinkElementContent({ + url: element.url, + title: element.title, + description: element.description, + imageUrl: element.imageUrl, + }), + }); + + return result; + } + + canMap(element: LinkElement): boolean { + return element instanceof LinkElement; + } +} 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 d640d3f6330..8bbc859fa17 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 @@ -1,10 +1,11 @@ -import { BoardNodeType, ExternalToolElement } from '@shared/domain'; +import { BoardNodeType, ExternalToolElement, LinkElement } from '@shared/domain'; import { cardNodeFactory, columnBoardNodeFactory, columnNodeFactory, externalToolElementNodeFactory, fileElementNodeFactory, + linkElementNodeFactory, richTextElementNodeFactory, setupEntities, submissionContainerElementNodeFactory, @@ -195,7 +196,7 @@ describe(BoardDoBuilderImpl.name, () => { expect(domainObject.constructor.name).toBe(ExternalToolElement.name); }); - it('should throw error if submissionContainerElement is not a leaf', () => { + it('should throw error if externalToolElement is not a leaf', () => { const externalToolElementNode = externalToolElementNodeFactory.buildWithId(); const columnNode = columnNodeFactory.buildWithId({ parent: externalToolElementNode }); @@ -205,6 +206,25 @@ describe(BoardDoBuilderImpl.name, () => { }); }); + describe('when building a link element', () => { + it('should work without descendants', () => { + const linkElementNode = linkElementNodeFactory.buildWithId(); + + const domainObject = new BoardDoBuilderImpl().buildLinkElement(linkElementNode); + + expect(domainObject.constructor.name).toBe(LinkElement.name); + }); + + it('should throw error if linkElement is not a leaf', () => { + const linkElementNode = linkElementNodeFactory.buildWithId(); + const columnNode = columnNodeFactory.buildWithId({ parent: linkElementNode }); + + expect(() => { + new BoardDoBuilderImpl([columnNode]).buildLinkElement(linkElementNode); + }).toThrowError(); + }); + }); + describe('ensure board node types', () => { it('should do nothing if type is correct', () => { const card = cardNodeFactory.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 0b1b2b59cb0..18b0583daa1 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 @@ -7,6 +7,7 @@ import type { ColumnNode, ExternalToolElementNodeEntity, FileElementNode, + LinkElementNode, RichTextElementNode, SubmissionContainerElementNode, SubmissionItemNode, @@ -19,6 +20,7 @@ import { ColumnBoard, ExternalToolElement, FileElement, + LinkElement, RichTextElement, SubmissionContainerElement, SubmissionItem, @@ -73,12 +75,15 @@ export class BoardDoBuilderImpl implements BoardDoBuilder { public buildCard(boardNode: CardNode): Card { this.ensureBoardNodeType(this.getChildren(boardNode), [ BoardNodeType.FILE_ELEMENT, + BoardNodeType.LINK_ELEMENT, BoardNodeType.RICH_TEXT_ELEMENT, BoardNodeType.SUBMISSION_CONTAINER_ELEMENT, BoardNodeType.EXTERNAL_TOOL, ]); - const elements = this.buildChildren(boardNode); + const elements = this.buildChildren< + ExternalToolElement | FileElement | LinkElement | RichTextElement | SubmissionContainerElement + >(boardNode); const card = new Card({ id: boardNode.id, @@ -105,6 +110,21 @@ export class BoardDoBuilderImpl implements BoardDoBuilder { return element; } + public buildLinkElement(boardNode: LinkElementNode): LinkElement { + this.ensureLeafNode(boardNode); + + const element = new LinkElement({ + id: boardNode.id, + url: boardNode.url, + title: boardNode.title, + imageUrl: boardNode.imageUrl, + children: [], + createdAt: boardNode.createdAt, + updatedAt: boardNode.updatedAt, + }); + return element; + } + public buildRichTextElement(boardNode: RichTextElementNode): RichTextElement { this.ensureLeafNode(boardNode); 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 75f0e9e2e99..d94e7ae5557 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 @@ -7,6 +7,7 @@ import { columnFactory, externalToolElementFactory, fileElementFactory, + linkElementFactory, setupEntities, submissionContainerElementFactory, submissionItemFactory, @@ -145,6 +146,26 @@ describe(RecursiveDeleteVisitor.name, () => { }); }); + describe('visitLinkElementAsync', () => { + const setup = () => { + const childLinkElement = linkElementFactory.build(); + const linkElement = linkElementFactory.build({ + children: [childLinkElement], + }); + + return { linkElement, childLinkElement }; + }; + + it('should call entity remove', async () => { + const { linkElement, childLinkElement } = setup(); + + await service.visitLinkElementAsync(linkElement); + + expect(em.remove).toHaveBeenCalledWith(em.getReference(linkElement.constructor, linkElement.id)); + expect(em.remove).toHaveBeenCalledWith(em.getReference(childLinkElement.constructor, childLinkElement.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 aaed699c26c..c2177e5dd1c 100644 --- a/apps/server/src/modules/board/repo/recursive-delete.vistor.ts +++ b/apps/server/src/modules/board/repo/recursive-delete.vistor.ts @@ -13,6 +13,7 @@ import { SubmissionContainerElement, SubmissionItem, } from '@shared/domain'; +import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; import { FilesStorageClientAdapterService } from '@src/modules/files-storage-client'; @Injectable() @@ -44,6 +45,12 @@ export class RecursiveDeleteVisitor implements BoardCompositeVisitorAsync { await this.visitChildrenAsync(fileElement); } + async visitLinkElementAsync(linkElement: LinkElement): Promise { + this.deleteNode(linkElement); + + await this.visitChildrenAsync(linkElement); + } + async visitRichTextElementAsync(richTextElement: RichTextElement): Promise { this.deleteNode(richTextElement); await this.visitChildrenAsync(richTextElement); 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 088b6f7f54c..3fd95c18525 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 @@ -7,6 +7,7 @@ import { ColumnNode, ExternalToolElementNodeEntity, FileElementNode, + LinkElementNode, RichTextElementNode, SubmissionContainerElementNode, SubmissionItemNode, @@ -19,6 +20,7 @@ import { contextExternalToolEntityFactory, externalToolElementFactory, fileElementFactory, + linkElementFactory, richTextElementFactory, setupEntities, submissionContainerElementFactory, @@ -137,6 +139,22 @@ describe(RecursiveSaveVisitor.name, () => { }); }); + describe('when visiting a link element composite', () => { + it('should create or update the node', () => { + const linkElement = linkElementFactory.build(); + jest.spyOn(visitor, 'createOrUpdateBoardNode'); + + visitor.visitLinkElement(linkElement); + + const expectedNode: Partial = { + id: linkElement.id, + type: BoardNodeType.LINK_ELEMENT, + url: linkElement.url, + }; + expect(visitor.createOrUpdateBoardNode).toHaveBeenCalledWith(expect.objectContaining(expectedNode)); + }); + }); + describe('when visiting a rich text element composite', () => { it('should create or update the node', () => { const richTextElement = richTextElementFactory.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 d35b80a93aa..7b2c7901605 100644 --- a/apps/server/src/modules/board/repo/recursive-save.visitor.ts +++ b/apps/server/src/modules/board/repo/recursive-save.visitor.ts @@ -22,6 +22,8 @@ import { SubmissionItem, SubmissionItemNode, } from '@shared/domain'; +import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; +import { LinkElementNode } from '@shared/domain/entity/boardnode/link-element-node.entity'; import { ContextExternalToolEntity } from '@src/modules/tool/context-external-tool/entity'; import { BoardNodeRepo } from './board-node.repo'; @@ -108,6 +110,22 @@ export class RecursiveSaveVisitor implements BoardCompositeVisitor { this.visitChildren(fileElement, boardNode); } + visitLinkElement(linkElement: LinkElement): void { + const parentData = this.parentsMap.get(linkElement.id); + + const boardNode = new LinkElementNode({ + id: linkElement.id, + url: linkElement.url, + title: linkElement.title, + imageUrl: linkElement.imageUrl, + parent: parentData?.boardNode, + position: parentData?.position, + }); + + this.createOrUpdateBoardNode(boardNode); + this.visitChildren(linkElement, boardNode); + } + visitRichTextElement(richTextElement: RichTextElement): void { const parentData = this.parentsMap.get(richTextElement.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 ba3643e6051..4b5393854d2 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 @@ -11,8 +11,10 @@ import { isColumnBoard, isExternalToolElement, isFileElement, + isLinkElement, isRichTextElement, isSubmissionContainerElement, + LinkElement, RichTextElement, SubmissionContainerElement, } from '@shared/domain'; @@ -23,6 +25,7 @@ import { columnFactory, externalToolElementFactory, fileElementFactory, + linkElementFactory, richTextElementFactory, setupEntities, submissionContainerElementFactory, @@ -706,4 +709,53 @@ describe('recursive board copy visitor', () => { expect(result.type).toEqual(CopyElementType.EXTERNAL_TOOL_ELEMENT); }); }); + + describe('when copying a link element', () => { + const setup = () => { + const original = linkElementFactory.build(); + + return { original, ...setupfileCopyService() }; + }; + + const getLinkElementFromStatus = (status: CopyStatus): LinkElement => { + const copy = status.copyEntity; + + expect(isLinkElement(copy)).toEqual(true); + + return copy as LinkElement; + }; + + it('should return a link element as copy', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(isLinkElement(result.copyEntity)).toEqual(true); + }); + + it('should create new id', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getLinkElementFromStatus(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 be of type LinkElement', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.type).toEqual(CopyElementType.LINK_ELEMENT); + }); + }); }); 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 4d1bf55f5ae..7b17194c15f 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 @@ -11,6 +11,7 @@ import { SubmissionContainerElement, SubmissionItem, } from '@shared/domain'; +import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; import { FileRecordParentType } from '@shared/infra/rabbitmq'; import { CopyElementType, CopyStatus, CopyStatusEnum } from '@src/modules/copy-helper'; import { ObjectId } from 'bson'; @@ -122,6 +123,26 @@ export class RecursiveCopyVisitor implements BoardCompositeVisitorAsync { this.copyMap.set(original.id, copy); } + async visitLinkElementAsync(original: LinkElement): Promise { + const copy = new LinkElement({ + id: new ObjectId().toHexString(), + url: original.url, + title: original.title, + imageUrl: original.imageUrl, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + this.resultMap.set(original.id, { + copyEntity: copy, + type: CopyElementType.LINK_ELEMENT, + status: CopyStatusEnum.SUCCESS, + }); + this.copyMap.set(original.id, copy); + + return Promise.resolve(); + } + async visitRichTextElementAsync(original: RichTextElement): Promise { const copy = new RichTextElement({ id: new ObjectId().toHexString(), diff --git a/apps/server/src/modules/board/service/card.service.spec.ts b/apps/server/src/modules/board/service/card.service.spec.ts index 6a9a8f1d944..eb155793412 100644 --- a/apps/server/src/modules/board/service/card.service.spec.ts +++ b/apps/server/src/modules/board/service/card.service.spec.ts @@ -88,7 +88,8 @@ describe(CardService.name, () => { }; it('should call the card repository', async () => { - const { cardIds } = setup(); + const { cards, cardIds } = setup(); + boardDoRepo.findByIds.mockResolvedValueOnce(cards); await service.findByIds(cardIds); diff --git a/apps/server/src/modules/board/service/card.service.ts b/apps/server/src/modules/board/service/card.service.ts index 3ef34806397..b7fee25c94f 100644 --- a/apps/server/src/modules/board/service/card.service.ts +++ b/apps/server/src/modules/board/service/card.service.ts @@ -14,16 +14,16 @@ export class CardService { ) {} async findById(cardId: EntityId): Promise { - const card = await this.boardDoRepo.findByClassAndId(Card, cardId); - return card; + return this.boardDoRepo.findByClassAndId(Card, cardId); } async findByIds(cardIds: EntityId[]): Promise { const cards = await this.boardDoRepo.findByIds(cardIds); - if (cards.every((card) => card instanceof Card)) { - return cards as Card[]; + if (cards.some((card) => !(card instanceof Card))) { + throw new NotFoundException('some ids do not belong to a card'); } - throw new NotFoundException('some ids do not belong to a card'); + + return cards as Card[]; } async create(parent: Column, requiredEmptyElements?: ContentElementType[]): Promise { 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 b96a6ca9a41..8a8368fce2b 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 @@ -6,12 +6,14 @@ import { columnFactory, externalToolElementFactory, fileElementFactory, + linkElementFactory, richTextElementFactory, submissionContainerElementFactory, submissionItemFactory, } from '@shared/testing'; import { ExternalToolContentBody, FileContentBody, RichTextContentBody } from '../controller/dto'; import { ContentElementUpdateVisitor } from './content-element-update.visitor'; +import { OpenGraphProxyService } from './open-graph-proxy.service'; describe(ContentElementUpdateVisitor.name, () => { describe('when visiting an unsupported component', () => { @@ -23,36 +25,37 @@ describe(ContentElementUpdateVisitor.name, () => { content.text = 'a text'; content.inputFormat = InputFormat.RICH_TEXT_CK5; const submissionItem = submissionItemFactory.build(); - const updater = new ContentElementUpdateVisitor(content); + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); return { board, column, card, submissionItem, updater }; }; describe('when component is a column board', () => { - it('should throw an error', () => { + it('should throw an error', async () => { const { board, updater } = setup(); - expect(() => updater.visitColumnBoard(board)).toThrow(); + await expect(updater.visitColumnBoardAsync(board)).rejects.toThrow(); }); }); describe('when component is a column', () => { - it('should throw an error', () => { + it('should throw an error', async () => { const { column, updater } = setup(); - expect(() => updater.visitColumn(column)).toThrow(); + await expect(() => updater.visitColumnAsync(column)).rejects.toThrow(); }); }); describe('when component is a card', () => { - it('should throw an error', () => { + it('should throw an error', async () => { const { card, updater } = setup(); - expect(() => updater.visitCard(card)).toThrow(); + await expect(() => updater.visitCardAsync(card)).rejects.toThrow(); }); }); describe('when component is a submission-item', () => { - it('should throw an error', () => { + it('should throw an error', async () => { const { submissionItem, updater } = setup(); - expect(() => updater.visitSubmissionItem(submissionItem)).toThrow(); + await expect(() => updater.visitSubmissionItemAsync(submissionItem)).rejects.toThrow(); }); }); }); @@ -63,15 +66,34 @@ describe(ContentElementUpdateVisitor.name, () => { const content = new RichTextContentBody(); content.text = 'a text'; content.inputFormat = InputFormat.RICH_TEXT_CK5; - const updater = new ContentElementUpdateVisitor(content); + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); return { fileElement, updater }; }; - it('should throw an error', () => { + it('should throw an error', async () => { const { fileElement, updater } = setup(); - expect(() => updater.visitFileElement(fileElement)).toThrow(); + await expect(() => updater.visitFileElementAsync(fileElement)).rejects.toThrow(); + }); + }); + + describe('when visiting a link element using the wrong content', () => { + const setup = () => { + const linkElement = linkElementFactory.build(); + const content = new FileContentBody(); + content.caption = 'a caption'; + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); + + return { linkElement, updater }; + }; + + it('should throw an error', async () => { + const { linkElement, updater } = setup(); + + await expect(() => updater.visitLinkElementAsync(linkElement)).rejects.toThrow(); }); }); @@ -80,15 +102,16 @@ describe(ContentElementUpdateVisitor.name, () => { const richTextElement = richTextElementFactory.build(); const content = new FileContentBody(); content.caption = 'a caption'; - const updater = new ContentElementUpdateVisitor(content); + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); return { richTextElement, updater }; }; - it('should throw an error', () => { + it('should throw an error', async () => { const { richTextElement, updater } = setup(); - expect(() => updater.visitRichTextElement(richTextElement)).toThrow(); + await expect(() => updater.visitRichTextElementAsync(richTextElement)).rejects.toThrow(); }); }); @@ -98,15 +121,16 @@ describe(ContentElementUpdateVisitor.name, () => { const content = new RichTextContentBody(); content.text = 'a text'; content.inputFormat = InputFormat.RICH_TEXT_CK5; - const updater = new ContentElementUpdateVisitor(content); + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); return { submissionContainerElement, updater }; }; - it('should throw an error', () => { + it('should throw an error', async () => { const { submissionContainerElement, updater } = setup(); - expect(() => updater.visitSubmissionContainerElement(submissionContainerElement)).toThrow(); + await expect(() => updater.visitSubmissionContainerElementAsync(submissionContainerElement)).rejects.toThrow(); }); }); @@ -116,15 +140,16 @@ describe(ContentElementUpdateVisitor.name, () => { const externalToolElement = externalToolElementFactory.build({ contextExternalToolId: undefined }); const content = new ExternalToolContentBody(); content.contextExternalToolId = new ObjectId().toHexString(); - const updater = new ContentElementUpdateVisitor(content); + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); return { externalToolElement, updater, content }; }; - it('should update the content', () => { + it('should update the content', async () => { const { externalToolElement, updater, content } = setup(); - updater.visitExternalToolElement(externalToolElement); + await updater.visitExternalToolElementAsync(externalToolElement); expect(externalToolElement.contextExternalToolId).toEqual(content.contextExternalToolId); }); @@ -136,15 +161,16 @@ describe(ContentElementUpdateVisitor.name, () => { const content = new RichTextContentBody(); content.text = 'a text'; content.inputFormat = InputFormat.RICH_TEXT_CK5; - const updater = new ContentElementUpdateVisitor(content); + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); return { externalToolElement, updater }; }; - it('should throw an error', () => { + it('should throw an error', async () => { const { externalToolElement, updater } = setup(); - expect(() => updater.visitExternalToolElement(externalToolElement)).toThrow(); + await expect(() => updater.visitExternalToolElementAsync(externalToolElement)).rejects.toThrow(); }); }); @@ -152,15 +178,16 @@ describe(ContentElementUpdateVisitor.name, () => { const setup = () => { const externalToolElement = externalToolElementFactory.build(); const content = new ExternalToolContentBody(); - const updater = new ContentElementUpdateVisitor(content); + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); return { externalToolElement, updater }; }; - it('should throw an error', () => { + it('should throw an error', async () => { const { externalToolElement, updater } = setup(); - expect(() => updater.visitExternalToolElement(externalToolElement)).toThrow(); + await expect(() => updater.visitExternalToolElementAsync(externalToolElement)).rejects.toThrow(); }); }); }); 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 d5d950890d6..0f75bcaee2d 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 @@ -1,7 +1,8 @@ +import { Injectable } from '@nestjs/common'; import { sanitizeRichText } from '@shared/controller'; import { AnyBoardDo, - BoardCompositeVisitor, + BoardCompositeVisitorAsync, Card, Column, ColumnBoard, @@ -12,74 +13,94 @@ import { SubmissionContainerElement, SubmissionItem, } from '@shared/domain'; +import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; import { AnyElementContentBody, ExternalToolContentBody, FileContentBody, + LinkContentBody, RichTextContentBody, SubmissionContainerContentBody, } from '../controller/dto'; +import { OpenGraphProxyService } from './open-graph-proxy.service'; -export class ContentElementUpdateVisitor implements BoardCompositeVisitor { +@Injectable() +export class ContentElementUpdateVisitor implements BoardCompositeVisitorAsync { private readonly content: AnyElementContentBody; - constructor(content: AnyElementContentBody) { + constructor(content: AnyElementContentBody, private readonly openGraphProxyService: OpenGraphProxyService) { this.content = content; } - visitColumnBoard(columnBoard: ColumnBoard): void { - this.throwNotHandled(columnBoard); + async visitColumnBoardAsync(columnBoard: ColumnBoard): Promise { + return this.rejectNotHandled(columnBoard); } - visitColumn(column: Column): void { - this.throwNotHandled(column); + async visitColumnAsync(column: Column): Promise { + return this.rejectNotHandled(column); } - visitCard(card: Card): void { - this.throwNotHandled(card); + async visitCardAsync(card: Card): Promise { + return this.rejectNotHandled(card); } - visitFileElement(fileElement: FileElement): void { + async visitFileElementAsync(fileElement: FileElement): Promise { if (this.content instanceof FileContentBody) { fileElement.caption = sanitizeRichText(this.content.caption, InputFormat.PLAIN_TEXT); fileElement.alternativeText = sanitizeRichText(this.content.alternativeText, InputFormat.PLAIN_TEXT); - } else { - this.throwNotHandled(fileElement); + return Promise.resolve(); } + return this.rejectNotHandled(fileElement); } - visitRichTextElement(richTextElement: RichTextElement): void { + async visitLinkElementAsync(linkElement: LinkElement): Promise { + if (this.content instanceof LinkContentBody) { + const urlWithProtocol = /:\/\//.test(this.content.url) ? this.content.url : `https://${this.content.url}`; + linkElement.url = new URL(urlWithProtocol).toString(); + const openGraphData = await this.openGraphProxyService.fetchOpenGraphData(linkElement.url); + linkElement.title = openGraphData.title; + linkElement.description = openGraphData.description; + if (openGraphData.image) { + linkElement.imageUrl = openGraphData.image.url; + } + return Promise.resolve(); + } + return this.rejectNotHandled(linkElement); + } + + async visitRichTextElementAsync(richTextElement: RichTextElement): Promise { if (this.content instanceof RichTextContentBody) { richTextElement.text = sanitizeRichText(this.content.text, this.content.inputFormat); richTextElement.inputFormat = this.content.inputFormat; - } else { - this.throwNotHandled(richTextElement); + return Promise.resolve(); } + return this.rejectNotHandled(richTextElement); } - visitSubmissionContainerElement(submissionContainerElement: SubmissionContainerElement): void { + async visitSubmissionContainerElementAsync(submissionContainerElement: SubmissionContainerElement): Promise { if (this.content instanceof SubmissionContainerContentBody) { - if (this.content.dueDate === undefined) return; - submissionContainerElement.dueDate = this.content.dueDate; - } else { - this.throwNotHandled(submissionContainerElement); + if (this.content.dueDate !== undefined) { + submissionContainerElement.dueDate = this.content.dueDate; + } + return Promise.resolve(); } + return this.rejectNotHandled(submissionContainerElement); } - visitSubmissionItem(submission: SubmissionItem): void { - this.throwNotHandled(submission); + async visitSubmissionItemAsync(submission: SubmissionItem): Promise { + return this.rejectNotHandled(submission); } - visitExternalToolElement(externalToolElement: ExternalToolElement): void { + async visitExternalToolElementAsync(externalToolElement: ExternalToolElement): Promise { if (this.content instanceof ExternalToolContentBody && this.content.contextExternalToolId !== undefined) { // Updates should not remove an existing reference to a tool, to prevent orphan tool instances externalToolElement.contextExternalToolId = this.content.contextExternalToolId; - } else { - this.throwNotHandled(externalToolElement); + return Promise.resolve(); } + return this.rejectNotHandled(externalToolElement); } - private throwNotHandled(component: AnyBoardDo) { - throw new Error(`Cannot update element of type: '${component.constructor.name}'`); + private rejectNotHandled(component: AnyBoardDo): Promise { + return Promise.reject(new Error(`Cannot update element of type: '${component.constructor.name}'`)); } } 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 1d41925dfab..b1326450089 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,18 +1,32 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { ContentElementFactory, ContentElementType, FileElement, InputFormat, RichTextElement } from '@shared/domain'; +import { + ContentElementFactory, + ContentElementType, + FileElement, + InputFormat, + RichTextElement, + SubmissionContainerElement, +} from '@shared/domain'; import { setupEntities } from '@shared/testing'; import { cardFactory, fileElementFactory, + linkElementFactory, richTextElementFactory, submissionContainerElementFactory, } from '@shared/testing/factory/domainobject'; -import { FileContentBody, RichTextContentBody, SubmissionContainerContentBody } from '../controller/dto'; +import { + FileContentBody, + LinkContentBody, + RichTextContentBody, + SubmissionContainerContentBody, +} from '../controller/dto'; import { BoardDoRepo } from '../repo'; import { BoardDoService } from './board-do.service'; import { ContentElementService } from './content-element.service'; +import { OpenGraphProxyService } from './open-graph-proxy.service'; describe(ContentElementService.name, () => { let module: TestingModule; @@ -20,6 +34,7 @@ describe(ContentElementService.name, () => { let boardDoRepo: DeepMocked; let boardDoService: DeepMocked; let contentElementFactory: DeepMocked; + let openGraphProxyService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -37,6 +52,10 @@ describe(ContentElementService.name, () => { provide: ContentElementFactory, useValue: createMock(), }, + { + provide: OpenGraphProxyService, + useValue: createMock(), + }, ], }).compile(); @@ -44,6 +63,7 @@ describe(ContentElementService.name, () => { boardDoRepo = module.get(BoardDoRepo); boardDoService = module.get(BoardDoService); contentElementFactory = module.get(ContentElementFactory); + openGraphProxyService = module.get(OpenGraphProxyService); await setupEntities(); }); @@ -229,6 +249,44 @@ describe(ContentElementService.name, () => { }); }); + describe('when element is a link element', () => { + const setup = () => { + const linkElement = linkElementFactory.build(); + + const content = new LinkContentBody(); + content.url = 'https://www.medium.com/great-article'; + const card = cardFactory.build(); + boardDoRepo.findParentOfId.mockResolvedValue(card); + + const imageResponse = { + title: 'Webpage-title', + description: '', + url: linkElement.url, + image: { url: 'https://my-open-graph-proxy.scvs.de/image/adefcb12ed3a' }, + }; + + openGraphProxyService.fetchOpenGraphData.mockResolvedValueOnce(imageResponse); + + return { linkElement, content, card, imageResponse }; + }; + + it('should persist the element', async () => { + const { linkElement, content, card } = setup(); + + await service.update(linkElement, content); + + expect(boardDoRepo.save).toHaveBeenCalledWith(linkElement, card); + }); + + it('should call open graph service', async () => { + const { linkElement, content, card } = setup(); + + await service.update(linkElement, content); + + expect(boardDoRepo.save).toHaveBeenCalledWith(linkElement, card); + }); + }); + describe('when element is a submission container element', () => { const setup = () => { const submissionContainerElement = submissionContainerElementFactory.build(); @@ -245,17 +303,17 @@ describe(ContentElementService.name, () => { it('should update the element', async () => { const { submissionContainerElement, content } = setup(); - await service.update(submissionContainerElement, content); + const element = (await service.update(submissionContainerElement, content)) as SubmissionContainerElement; - expect(submissionContainerElement.dueDate).toEqual(content.dueDate); + expect(element.dueDate).toEqual(content.dueDate); }); it('should persist the element', async () => { const { submissionContainerElement, content, card } = setup(); - await service.update(submissionContainerElement, content); + const element = await service.update(submissionContainerElement, content); - expect(boardDoRepo.save).toHaveBeenCalledWith(submissionContainerElement, card); + expect(boardDoRepo.save).toHaveBeenCalledWith(element, card); }); }); }); diff --git a/apps/server/src/modules/board/service/content-element.service.ts b/apps/server/src/modules/board/service/content-element.service.ts index 2a55ff17a08..a7c957173f3 100644 --- a/apps/server/src/modules/board/service/content-element.service.ts +++ b/apps/server/src/modules/board/service/content-element.service.ts @@ -11,13 +11,15 @@ import { AnyElementContentBody } from '../controller/dto'; import { BoardDoRepo } from '../repo'; import { BoardDoService } from './board-do.service'; import { ContentElementUpdateVisitor } from './content-element-update.visitor'; +import { OpenGraphProxyService } from './open-graph-proxy.service'; @Injectable() export class ContentElementService { constructor( private readonly boardDoRepo: BoardDoRepo, private readonly boardDoService: BoardDoService, - private readonly contentElementFactory: ContentElementFactory + private readonly contentElementFactory: ContentElementFactory, + private readonly openGraphProxyService: OpenGraphProxyService ) {} async findById(elementId: EntityId): Promise { @@ -45,12 +47,14 @@ export class ContentElementService { await this.boardDoService.move(element, targetCard, targetPosition); } - async update(element: AnyContentElementDo, content: AnyElementContentBody): Promise { - const updater = new ContentElementUpdateVisitor(content); - element.accept(updater); + async update(element: AnyContentElementDo, content: AnyElementContentBody): Promise { + const updater = new ContentElementUpdateVisitor(content, this.openGraphProxyService); + await element.acceptAsync(updater); const parent = await this.boardDoRepo.findParentOfId(element.id); await this.boardDoRepo.save(element, parent); + + return element; } } diff --git a/apps/server/src/modules/board/service/index.ts b/apps/server/src/modules/board/service/index.ts index 8ff2787f35d..ac9c686d4b4 100644 --- a/apps/server/src/modules/board/service/index.ts +++ b/apps/server/src/modules/board/service/index.ts @@ -4,4 +4,5 @@ export * from './card.service'; export * from './column-board.service'; export * from './column.service'; export * from './content-element.service'; +export * from './open-graph-proxy.service'; export * from './submission-item.service'; diff --git a/apps/server/src/modules/board/service/open-graph-proxy.service.spec.ts b/apps/server/src/modules/board/service/open-graph-proxy.service.spec.ts new file mode 100644 index 00000000000..debe76cdeba --- /dev/null +++ b/apps/server/src/modules/board/service/open-graph-proxy.service.spec.ts @@ -0,0 +1,91 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { setupEntities } from '@shared/testing'; +import { ImageObject } from 'open-graph-scraper/dist/lib/types'; +import { OpenGraphProxyService } from './open-graph-proxy.service'; + +let ogsResponseMock = {}; +jest.mock( + 'open-graph-scraper', + () => () => + Promise.resolve({ + error: false, + html: '', + response: {}, + result: ogsResponseMock, + }) +); + +describe(OpenGraphProxyService.name, () => { + let module: TestingModule; + let service: OpenGraphProxyService; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [OpenGraphProxyService], + }).compile(); + + service = module.get(OpenGraphProxyService); + + await setupEntities(); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('create', () => { + it('should return also the original url', async () => { + const url = 'https://de.wikipedia.org'; + + const result = await service.fetchOpenGraphData(url); + + expect(result).toEqual(expect.objectContaining({ url })); + }); + + it('should thrown an error if url is an empty string', async () => { + const url = ''; + + await expect(service.fetchOpenGraphData(url)).rejects.toThrow(); + }); + + it('should return ogTitle as title', async () => { + const ogTitle = 'My Title'; + const url = 'https://de.wikipedia.org'; + ogsResponseMock = { ogTitle }; + + const result = await service.fetchOpenGraphData(url); + + expect(result).toEqual(expect.objectContaining({ title: ogTitle })); + }); + + it('should return ogImage as title', async () => { + const ogImage: ImageObject[] = [ + { + width: 800, + type: 'jpeg', + url: 'big-image.jpg', + }, + { + width: 500, + type: 'jpeg', + url: 'medium-image.jpg', + }, + { + width: 300, + type: 'jpeg', + url: 'small-image.jpg', + }, + ]; + const url = 'https://de.wikipedia.org'; + ogsResponseMock = { ogImage }; + + const result = await service.fetchOpenGraphData(url); + + expect(result).toEqual(expect.objectContaining({ image: ogImage[1] })); + }); + }); +}); diff --git a/apps/server/src/modules/board/service/open-graph-proxy.service.ts b/apps/server/src/modules/board/service/open-graph-proxy.service.ts new file mode 100644 index 00000000000..2b54d75ee82 --- /dev/null +++ b/apps/server/src/modules/board/service/open-graph-proxy.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import ogs from 'open-graph-scraper'; +import { ImageObject } from 'open-graph-scraper/dist/lib/types'; + +type OpenGraphData = { + title: string; + description: string; + url: string; + image?: ImageObject; +}; + +@Injectable() +export class OpenGraphProxyService { + async fetchOpenGraphData(url: string): Promise { + if (url.length === 0) { + throw new Error(`OpenGraphProxyService requires a valid URL. Given URL: ${url}`); + } + + const data = await ogs({ url }); + // WIP: add nice debug logging for available openGraphData?!? + + const title = data.result.ogTitle ?? ''; + const description = data.result.ogDescription ?? ''; + const image = data.result.ogImage ? this.pickImage(data.result.ogImage) : undefined; + + return { + title, + description, + image, + url, + }; + } + + private pickImage(images: ImageObject[], minWidth = 400): ImageObject | undefined { + const sortedImages = [...images]; + sortedImages.sort((a, b) => (a.width && b.width ? Number(a.width) - Number(b.width) : 0)); + const smallestBigEnoughImage = sortedImages.find((i) => i.width && i.width >= minWidth); + const fallbackImage = images[0] && images[0].width === undefined ? images[0] : undefined; + return smallestBigEnoughImage ?? fallbackImage; + } +} diff --git a/apps/server/src/modules/board/uc/element.uc.ts b/apps/server/src/modules/board/uc/element.uc.ts index b66ce1bd247..e5dc039168c 100644 --- a/apps/server/src/modules/board/uc/element.uc.ts +++ b/apps/server/src/modules/board/uc/element.uc.ts @@ -28,10 +28,12 @@ export class ElementUc { } async updateElementContent(userId: EntityId, elementId: EntityId, content: AnyElementContentBody) { - const element = await this.elementService.findById(elementId); + let element = await this.elementService.findById(elementId); await this.checkPermission(userId, element, Action.write); - await this.elementService.update(element, content); + + element = await this.elementService.update(element, content); + return element; } async createSubmissionItem( 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 77f0f80e4cc..fff1f0da795 100644 --- a/apps/server/src/modules/copy-helper/types/copy.types.ts +++ b/apps/server/src/modules/copy-helper/types/copy.types.ts @@ -34,6 +34,7 @@ export enum CopyElementType { 'LESSON_CONTENT_TEXT' = 'LESSON_CONTENT_TEXT', 'LERNSTORE_MATERIAL' = 'LERNSTORE_MATERIAL', 'LERNSTORE_MATERIAL_GROUP' = 'LERNSTORE_MATERIAL_GROUP', + 'LINK_ELEMENT' = 'LINK_ELEMENT', 'LTITOOL_GROUP' = 'LTITOOL_GROUP', 'METADATA' = 'METADATA', 'RICHTEXT_ELEMENT' = 'RICHTEXT_ELEMENT', 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 652d30ff027..62931e418dd 100644 --- a/apps/server/src/shared/domain/domainobject/board/card.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/card.do.ts @@ -1,6 +1,7 @@ import { BoardComposite, BoardCompositeProps } from './board-composite.do'; import { ExternalToolElement } from './external-tool-element.do'; import { FileElement } from './file-element.do'; +import { LinkElement } from './link-element.do'; import { RichTextElement } from './rich-text-element.do'; import { SubmissionContainerElement } from './submission-container-element.do'; import type { AnyBoardDo, BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; @@ -25,6 +26,7 @@ export class Card extends BoardComposite { isAllowedAsChild(domainObject: AnyBoardDo): boolean { const allowed = domainObject instanceof FileElement || + domainObject instanceof LinkElement || domainObject instanceof RichTextElement || domainObject instanceof SubmissionContainerElement || domainObject instanceof ExternalToolElement; 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 ff8966aa77f..8c34ca54b56 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 @@ -3,6 +3,7 @@ import { InputFormat } from '@shared/domain/types'; import { ObjectId } from 'bson'; import { ExternalToolElement } from './external-tool-element.do'; import { FileElement } from './file-element.do'; +import { LinkElement } from './link-element.do'; import { RichTextElement } from './rich-text-element.do'; import { SubmissionContainerElement } from './submission-container-element.do'; import { AnyContentElementDo, ContentElementType } from './types'; @@ -16,6 +17,9 @@ export class ContentElementFactory { case ContentElementType.FILE: element = this.buildFile(); break; + case ContentElementType.LINK: + element = this.buildLink(); + break; case ContentElementType.RICH_TEXT: element = this.buildRichText(); break; @@ -49,6 +53,18 @@ export class ContentElementFactory { return element; } + private buildLink() { + const element = new LinkElement({ + id: new ObjectId().toHexString(), + url: '', + title: '', + createdAt: new Date(), + updatedAt: new Date(), + }); + + return element; + } + private buildRichText() { const element = new RichTextElement({ id: new ObjectId().toHexString(), diff --git a/apps/server/src/shared/domain/domainobject/board/index.ts b/apps/server/src/shared/domain/domainobject/board/index.ts index 86f4d2639c3..9701ba40099 100644 --- a/apps/server/src/shared/domain/domainobject/board/index.ts +++ b/apps/server/src/shared/domain/domainobject/board/index.ts @@ -3,10 +3,11 @@ export * from './card.do'; export * from './column-board.do'; export * from './column.do'; export * from './content-element.factory'; +export * from './external-tool-element.do'; export * from './file-element.do'; +export * from './link-element.do'; export * from './rich-text-element.do'; export * from './submission-container-element.do'; export * from './submission-item.do'; export * from './submission-item.factory'; -export * from './external-tool-element.do'; export * from './types'; diff --git a/apps/server/src/shared/domain/domainobject/board/link-element.do.spec.ts b/apps/server/src/shared/domain/domainobject/board/link-element.do.spec.ts new file mode 100644 index 00000000000..4a044e9be58 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/board/link-element.do.spec.ts @@ -0,0 +1,37 @@ +import { createMock } from '@golevelup/ts-jest'; +import { linkElementFactory } from '@shared/testing'; +import { LinkElement } from './link-element.do'; +import { BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; + +describe(LinkElement.name, () => { + describe('when trying to add a child to a link element', () => { + it('should throw an error ', () => { + const linkElement = linkElementFactory.build(); + const linkElementFactoryChild = linkElementFactory.build(); + + expect(() => linkElement.addChild(linkElementFactoryChild)).toThrow(); + }); + }); + + describe('accept', () => { + it('should call the right visitor method', () => { + const visitor = createMock(); + const linkElement = linkElementFactory.build(); + + linkElement.accept(visitor); + + expect(visitor.visitLinkElement).toHaveBeenCalledWith(linkElement); + }); + }); + + describe('acceptAsync', () => { + it('should call the right async visitor method', async () => { + const visitor = createMock(); + const linkElement = linkElementFactory.build(); + + await linkElement.acceptAsync(visitor); + + expect(visitor.visitLinkElementAsync).toHaveBeenCalledWith(linkElement); + }); + }); +}); diff --git a/apps/server/src/shared/domain/domainobject/board/link-element.do.ts b/apps/server/src/shared/domain/domainobject/board/link-element.do.ts new file mode 100644 index 00000000000..7b38cbd938e --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/board/link-element.do.ts @@ -0,0 +1,59 @@ +import { BoardComposite, BoardCompositeProps } from './board-composite.do'; +import type { BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; + +export class LinkElement extends BoardComposite { + get url(): string { + return this.props.url ?? ''; + } + + set url(value: string) { + this.props.url = value; + } + + get title(): string { + return this.props.title ?? ''; + } + + set title(value: string) { + this.props.title = value; + } + + get description(): string { + return this.props.description ?? ''; + } + + set description(value: string) { + this.props.description = value ?? ''; + } + + get imageUrl(): string { + return this.props.imageUrl ?? ''; + } + + set imageUrl(value: string) { + this.props.imageUrl = value; + } + + isAllowedAsChild(): boolean { + return false; + } + + accept(visitor: BoardCompositeVisitor): void { + visitor.visitLinkElement(this); + } + + async acceptAsync(visitor: BoardCompositeVisitorAsync): Promise { + await visitor.visitLinkElementAsync(this); + } +} + +export interface LinkElementProps extends BoardCompositeProps { + url: string; + title: string; + description?: string; + imageUrl?: string; +} + +export function isLinkElement(reference: unknown): reference is LinkElement { + return reference instanceof LinkElement; +} 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 d6ccfbd56ca..614071e658c 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,17 +1,24 @@ import { ExternalToolElement } from '../external-tool-element.do'; import { FileElement } from '../file-element.do'; +import { LinkElement } from '../link-element.do'; import { RichTextElement } from '../rich-text-element.do'; import { SubmissionContainerElement } from '../submission-container-element.do'; import type { AnyBoardDo } from './any-board-do'; -export type AnyContentElementDo = FileElement | RichTextElement | SubmissionContainerElement | ExternalToolElement; +export type AnyContentElementDo = + | ExternalToolElement + | FileElement + | LinkElement + | RichTextElement + | SubmissionContainerElement; export const isAnyContentElement = (element: AnyBoardDo): element is AnyContentElementDo => { const result = + element instanceof ExternalToolElement || element instanceof FileElement || + element instanceof LinkElement || element instanceof RichTextElement || - element instanceof SubmissionContainerElement || - element instanceof ExternalToolElement; + element instanceof SubmissionContainerElement; return result; }; 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 38e16fc8e5f..3fbd4abdd96 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,17 +1,19 @@ import type { Card } from '../card.do'; import type { ColumnBoard } from '../column-board.do'; import type { Column } from '../column.do'; -import { ExternalToolElement } from '../external-tool-element.do'; +import type { ExternalToolElement } from '../external-tool-element.do'; import type { FileElement } from '../file-element.do'; -import { RichTextElement } from '../rich-text-element.do'; -import { SubmissionContainerElement } from '../submission-container-element.do'; -import { SubmissionItem } from '../submission-item.do'; +import type { LinkElement } from '../link-element.do'; +import type { RichTextElement } from '../rich-text-element.do'; +import type { SubmissionContainerElement } from '../submission-container-element.do'; +import type { SubmissionItem } from '../submission-item.do'; export interface BoardCompositeVisitor { visitColumnBoard(columnBoard: ColumnBoard): void; visitColumn(column: Column): void; visitCard(card: Card): void; visitFileElement(fileElement: FileElement): void; + visitLinkElement(linkElement: LinkElement): void; visitRichTextElement(richTextElement: RichTextElement): void; visitSubmissionContainerElement(submissionContainerElement: SubmissionContainerElement): void; visitSubmissionItem(submissionItem: SubmissionItem): void; @@ -23,6 +25,7 @@ export interface BoardCompositeVisitorAsync { visitColumnAsync(column: Column): Promise; visitCardAsync(card: Card): Promise; visitFileElementAsync(fileElement: FileElement): Promise; + visitLinkElementAsync(linkElement: LinkElement): Promise; visitRichTextElementAsync(richTextElement: RichTextElement): Promise; visitSubmissionContainerElementAsync(submissionContainerElement: SubmissionContainerElement): Promise; visitSubmissionItemAsync(submissionItem: SubmissionItem): 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 4c6ce7269bd..b8d4e166e25 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', + LINK = 'link', RICH_TEXT = 'richText', SUBMISSION_CONTAINER = 'submissionContainer', EXTERNAL_TOOL = 'externalTool', diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index 406fda13bf4..6cbb3bf9810 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -13,6 +13,7 @@ import { ColumnNode, ExternalToolElementNodeEntity, FileElementNode, + LinkElementNode, RichTextElementNode, SubmissionContainerElementNode, SubmissionItemNode, @@ -58,6 +59,7 @@ export const ALL_ENTITIES = [ ColumnNode, ClassEntity, FileElementNode, + LinkElementNode, RichTextElementNode, SubmissionContainerElementNode, SubmissionItemNode, diff --git a/apps/server/src/shared/domain/entity/boardnode/index.ts b/apps/server/src/shared/domain/entity/boardnode/index.ts index cd7cc9d65be..a3a56e6dfe0 100644 --- a/apps/server/src/shared/domain/entity/boardnode/index.ts +++ b/apps/server/src/shared/domain/entity/boardnode/index.ts @@ -2,9 +2,10 @@ export * from './boardnode.entity'; export * from './card-node.entity'; export * from './column-board-node.entity'; 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 './rich-text-element-node.entity'; export * from './submission-container-element-node.entity'; export * from './submission-item-node.entity'; -export * from './external-tool-element-node.entity'; export * from './types'; diff --git a/apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.spec.ts b/apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.spec.ts new file mode 100644 index 00000000000..1093e57922e --- /dev/null +++ b/apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.spec.ts @@ -0,0 +1,54 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { linkElementFactory } from '@shared/testing'; +import { LinkElementNode } from './link-element-node.entity'; +import { BoardDoBuilder, BoardNodeType } from './types'; + +describe(LinkElementNode.name, () => { + describe('when trying to create a link element', () => { + const setup = () => { + const elementProps = { url: 'https://www.any-fake.url/that-is-linked.html', title: 'A Great WebPage' }; + const builder: DeepMocked = createMock(); + + return { elementProps, builder }; + }; + + it('should create a LinkElementNode', () => { + const { elementProps } = setup(); + + const element = new LinkElementNode(elementProps); + + expect(element.type).toEqual(BoardNodeType.LINK_ELEMENT); + }); + }); + + describe('useDoBuilder()', () => { + const setup = () => { + const element = new LinkElementNode({ + url: 'https://www.any-fake.url/that-is-linked.html', + title: 'A Great WebPage', + }); + const builder: DeepMocked = createMock(); + const elementDo = linkElementFactory.build(); + + builder.buildLinkElement.mockReturnValue(elementDo); + + return { element, builder, elementDo }; + }; + + it('should call the specific builder method', () => { + const { element, builder } = setup(); + + element.useDoBuilder(builder); + + expect(builder.buildLinkElement).toHaveBeenCalledWith(element); + }); + + it('should return RichTextElementDo', () => { + const { element, builder, elementDo } = setup(); + + const result = element.useDoBuilder(builder); + + expect(result).toEqual(elementDo); + }); + }); +}); diff --git a/apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.ts b/apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.ts new file mode 100644 index 00000000000..0102821d97b --- /dev/null +++ b/apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.ts @@ -0,0 +1,36 @@ +import { Entity, Property } from '@mikro-orm/core'; +import { AnyBoardDo } from '../../domainobject'; +import { BoardNode, BoardNodeProps } from './boardnode.entity'; +import { BoardDoBuilder, BoardNodeType } from './types'; + +@Entity({ discriminatorValue: BoardNodeType.LINK_ELEMENT }) +export class LinkElementNode extends BoardNode { + @Property() + url: string; + + @Property() + title: string; + + @Property() + imageUrl?: string; + + constructor(props: LinkElementNodeProps) { + super(props); + this.type = BoardNodeType.LINK_ELEMENT; + this.url = props.url; + this.title = props.title; + this.imageUrl = props.imageUrl; + } + + useDoBuilder(builder: BoardDoBuilder): AnyBoardDo { + const domainObject = builder.buildLinkElement(this); + + return domainObject; + } +} + +export interface LinkElementNodeProps extends BoardNodeProps { + url: string; + title: string; + imageUrl?: string; +} 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 a5c2a8b2e16..1b759a41180 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 @@ -1,18 +1,20 @@ -import { SubmissionItem } from '@shared/domain/domainobject/board/submission-item.do'; import type { Card, Column, ColumnBoard, ExternalToolElement, FileElement, + LinkElement, RichTextElement, SubmissionContainerElement, + SubmissionItem, } from '../../../domainobject'; import type { CardNode } from '../card-node.entity'; import type { ColumnBoardNode } from '../column-board-node.entity'; import type { ColumnNode } from '../column-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'; import type { RichTextElementNode } from '../rich-text-element-node.entity'; import type { SubmissionContainerElementNode } from '../submission-container-element-node.entity'; import type { SubmissionItemNode } from '../submission-item-node.entity'; @@ -22,6 +24,7 @@ export interface BoardDoBuilder { buildColumn(boardNode: ColumnNode): Column; buildCard(boardNode: CardNode): Card; buildFileElement(boardNode: FileElementNode): FileElement; + buildLinkElement(boardNode: LinkElementNode): LinkElement; buildRichTextElement(boardNode: RichTextElementNode): RichTextElement; buildSubmissionContainerElement(boardNode: SubmissionContainerElementNode): SubmissionContainerElement; buildSubmissionItem(boardNode: SubmissionItemNode): SubmissionItem; 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 a1b44207907..0b25a81b053 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 @@ -3,6 +3,7 @@ export enum BoardNodeType { COLUMN = 'column', CARD = 'card', FILE_ELEMENT = 'file-element', + LINK_ELEMENT = 'link-element', RICH_TEXT_ELEMENT = 'rich-text-element', SUBMISSION_CONTAINER_ELEMENT = 'submission-container-element', SUBMISSION_ITEM = 'submission-item', diff --git a/apps/server/src/shared/testing/factory/boardnode/index.ts b/apps/server/src/shared/testing/factory/boardnode/index.ts index 410a399ccff..14ae5c29312 100644 --- a/apps/server/src/shared/testing/factory/boardnode/index.ts +++ b/apps/server/src/shared/testing/factory/boardnode/index.ts @@ -1,8 +1,9 @@ export * from './card-node.factory'; export * from './column-board-node.factory'; export * from './column-node.factory'; +export * from './external-tool-element-node.factory'; export * from './file-element-node.factory'; +export * from './link-element-node.factory'; export * from './rich-text-element-node.factory'; export * from './submission-container-element-node.factory'; export * from './submission-item-node.factory'; -export * from './external-tool-element-node.factory'; diff --git a/apps/server/src/shared/testing/factory/boardnode/link-element-node.factory.ts b/apps/server/src/shared/testing/factory/boardnode/link-element-node.factory.ts new file mode 100644 index 00000000000..1725634705f --- /dev/null +++ b/apps/server/src/shared/testing/factory/boardnode/link-element-node.factory.ts @@ -0,0 +1,14 @@ +/* istanbul ignore file */ +import { LinkElementNode, LinkElementNodeProps } from '@shared/domain'; +import { BaseFactory } from '../base.factory'; + +export const linkElementNodeFactory = BaseFactory.define( + LinkElementNode, + ({ sequence }) => { + const url = `https://www.example.com/link/${sequence}`; + return { + url, + title: `The example page ${sequence}`, + }; + } +); 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 e7b3bae56ed..9a6cdf84839 100644 --- a/apps/server/src/shared/testing/factory/domainobject/board/index.ts +++ b/apps/server/src/shared/testing/factory/domainobject/board/index.ts @@ -1,8 +1,9 @@ export * from './card.do.factory'; export * from './column-board.do.factory'; export * from './column.do.factory'; +export * from './external-tool.do.factory'; export * from './file-element.do.factory'; +export * from './link-element.do.factory'; export * from './rich-text-element.do.factory'; export * from './submission-container-element.do.factory'; export * from './submission-item.do.factory'; -export * from './external-tool.do.factory'; diff --git a/apps/server/src/shared/testing/factory/domainobject/board/link-element.do.factory.ts b/apps/server/src/shared/testing/factory/domainobject/board/link-element.do.factory.ts new file mode 100644 index 00000000000..af0e55a1912 --- /dev/null +++ b/apps/server/src/shared/testing/factory/domainobject/board/link-element.do.factory.ts @@ -0,0 +1,15 @@ +/* istanbul ignore file */ +import { LinkElement, LinkElementProps } from '@shared/domain'; +import { ObjectId } from 'bson'; +import { BaseFactory } from '../../base.factory'; + +export const linkElementFactory = BaseFactory.define(LinkElement, ({ sequence }) => { + return { + id: new ObjectId().toHexString(), + url: `https://www.example.com/link/${sequence}`, + title: 'Website opengraph title', + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }; +}); diff --git a/config/default.schema.json b/config/default.schema.json index 9103cfd7d4f..e6084e5c331 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1050,6 +1050,11 @@ "default": false, "description": "Enable submissions in column board." }, + "FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED": { + "type": "boolean", + "default": false, + "description": "Enable link elements in column board." + }, "COLUMN_BOARD_HELP_LINK": { "type": "string", "default": "https://docs.dbildungscloud.de/pages/viewpage.action?pageId=270827606", diff --git a/config/development.json b/config/development.json index a2b8ba524a9..43d1b18640f 100644 --- a/config/development.json +++ b/config/development.json @@ -68,5 +68,6 @@ "FEATURE_COURSE_SHARE": true, "FEATURE_COLUMN_BOARD_ENABLED": true, "FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED": true, + "FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED": true, "FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED": true } diff --git a/package-lock.json b/package-lock.json index 92a97849e7e..dde18164a6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,6 +99,7 @@ "nest-winston": "^1.9.4", "nestjs-console": "^9.0.0", "oauth-1.0a": "^2.2.6", + "open-graph-scraper": "^6.2.2", "p-limit": "^3.1.0", "papaparse": "^5.1.1", "passport": "^0.6.0", @@ -7427,6 +7428,11 @@ "node": ">= 0.8" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + }, "node_modules/bowser": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", @@ -7902,6 +7908,162 @@ "node": "*" } }, + "node_modules/cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio-select/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/cheerio-select/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/cheerio-select/node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/cheerio-select/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -8882,6 +9044,83 @@ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" }, + "node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-select/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/css-select/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/css-select/node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/css-select/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/daemon": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/daemon/-/daemon-1.1.0.tgz", @@ -9154,9 +9393,9 @@ } }, "node_modules/domelementtype": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz", - "integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", "funding": [ { "type": "github", @@ -18029,6 +18268,17 @@ "gauge": "~1.2.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/number-allocator": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.9.tgz", @@ -18452,6 +18702,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open-graph-scraper": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/open-graph-scraper/-/open-graph-scraper-6.2.2.tgz", + "integrity": "sha512-cQO0c0HF9ZMhSoIEOKMyxbSYwKn6qWBDEdQeCvZnAVwKCxSWj2DV8AwC1J4JCiwZbn/C4grGCJXpvmlAyTXrBg==", + "dependencies": { + "chardet": "^1.6.0", + "cheerio": "^1.0.0-rc.12", + "undici": "^5.22.1", + "validator": "^13.9.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/open-graph-scraper/node_modules/chardet": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-1.6.0.tgz", + "integrity": "sha512-+QOTw3otC4+FxdjK9RopGpNOglADbr4WPFi0SonkO99JbpkTPbMxmdm4NenhF5Zs+4gPXLI1+y2uazws5TMe8w==" + }, "node_modules/optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -18769,6 +19038,54 @@ "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", "integrity": "sha1-8r0iH2zJcKk42IVWq8WJyqqiveE=" }, + "node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", + "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "dependencies": { + "domhandler": "^5.0.2", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -23512,6 +23829,17 @@ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==" }, + "node_modules/undici": { + "version": "5.25.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.25.2.tgz", + "integrity": "sha512-tch8RbCfn1UUH1PeVCXva4V8gDpGAud/w0WubD6sHC46vYQ3KDxL+xv1A2UxK0N6jrVedutuPHxe1XIoqerwMw==", + "dependencies": { + "busboy": "^1.6.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/universal-analytics": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/universal-analytics/-/universal-analytics-0.5.3.tgz", @@ -23726,9 +24054,9 @@ } }, "node_modules/validator": { - "version": "13.7.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz", - "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==", + "version": "13.11.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", + "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==", "engines": { "node": ">= 0.10" } @@ -30064,6 +30392,11 @@ } } }, + "boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + }, "bowser": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", @@ -30431,6 +30764,114 @@ "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", "dev": true }, + "cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "requires": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "dependencies": { + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + }, + "htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + } + } + }, + "cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "requires": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "dependencies": { + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + } + } + }, "chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -31229,6 +31670,58 @@ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" }, + "css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "requires": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "dependencies": { + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + } + } + }, + "css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==" + }, "daemon": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/daemon/-/daemon-1.1.0.tgz", @@ -31427,9 +31920,9 @@ } }, "domelementtype": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz", - "integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==" + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" }, "domhandler": { "version": "4.3.0", @@ -38138,6 +38631,14 @@ "gauge": "~1.2.0" } }, + "nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "requires": { + "boolbase": "^1.0.0" + } + }, "number-allocator": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.9.tgz", @@ -38464,6 +38965,24 @@ "is-wsl": "^2.2.0" } }, + "open-graph-scraper": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/open-graph-scraper/-/open-graph-scraper-6.2.2.tgz", + "integrity": "sha512-cQO0c0HF9ZMhSoIEOKMyxbSYwKn6qWBDEdQeCvZnAVwKCxSWj2DV8AwC1J4JCiwZbn/C4grGCJXpvmlAyTXrBg==", + "requires": { + "chardet": "^1.6.0", + "cheerio": "^1.0.0-rc.12", + "undici": "^5.22.1", + "validator": "^13.9.0" + }, + "dependencies": { + "chardet": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-1.6.0.tgz", + "integrity": "sha512-+QOTw3otC4+FxdjK9RopGpNOglADbr4WPFi0SonkO99JbpkTPbMxmdm4NenhF5Zs+4gPXLI1+y2uazws5TMe8w==" + } + } + }, "optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -38696,6 +39215,40 @@ "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", "integrity": "sha1-8r0iH2zJcKk42IVWq8WJyqqiveE=" }, + "parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "requires": { + "entities": "^4.4.0" + }, + "dependencies": { + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + } + } + }, + "parse5-htmlparser2-tree-adapter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", + "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "requires": { + "domhandler": "^5.0.2", + "parse5": "^7.0.0" + }, + "dependencies": { + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + } + } + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -42272,6 +42825,14 @@ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==" }, + "undici": { + "version": "5.25.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.25.2.tgz", + "integrity": "sha512-tch8RbCfn1UUH1PeVCXva4V8gDpGAud/w0WubD6sHC46vYQ3KDxL+xv1A2UxK0N6jrVedutuPHxe1XIoqerwMw==", + "requires": { + "busboy": "^1.6.0" + } + }, "universal-analytics": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/universal-analytics/-/universal-analytics-0.5.3.tgz", @@ -42462,9 +43023,9 @@ } }, "validator": { - "version": "13.7.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz", - "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==" + "version": "13.11.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", + "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==" }, "vary": { "version": "1.1.2", diff --git a/package.json b/package.json index 9a8c78c9377..97cb84c069e 100644 --- a/package.json +++ b/package.json @@ -181,6 +181,7 @@ "nest-winston": "^1.9.4", "nestjs-console": "^9.0.0", "oauth-1.0a": "^2.2.6", + "open-graph-scraper": "^6.2.2", "p-limit": "^3.1.0", "papaparse": "^5.1.1", "passport": "^0.6.0", diff --git a/src/services/config/publicAppConfigService.js b/src/services/config/publicAppConfigService.js index 31a3ae22224..06a54c6cf96 100644 --- a/src/services/config/publicAppConfigService.js +++ b/src/services/config/publicAppConfigService.js @@ -39,6 +39,7 @@ const exposedVars = [ 'SC_TITLE', 'FEATURE_COLUMN_BOARD_ENABLED', 'FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED', + 'FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED', 'FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED', 'FEATURE_COURSE_SHARE', 'FEATURE_COURSE_SHARE_NEW',